// audio.js — procedural sound for REIKHELM: DESCENT. No assets: every cue is // synthesized on a shared AudioContext (oscillators + filtered noise), so the // game ships as code only. Context unlocks on the first user gesture. let ctx = null; let master = null; let ambient = null; function ac() { if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); master = ctx.createGain(); master.gain.value = 0.5; master.connect(ctx.destination); } if (ctx.state === 'suspended') ctx.resume(); return ctx; } // One reusable noise buffer (1s of white noise). let noiseBuf = null; function noise() { const c = ac(); if (!noiseBuf) { noiseBuf = c.createBuffer(1, c.sampleRate, c.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; } const src = c.createBufferSource(); src.buffer = noiseBuf; return src; } // A tone with an exponential decay envelope. function tone({ freq = 440, type = 'sine', dur = 0.2, vol = 0.3, when = 0, slide = 0 }) { const c = ac(); const t0 = c.currentTime + when; const osc = c.createOscillator(); const g = c.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, t0); if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t0 + dur); g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); osc.connect(g).connect(master); osc.start(t0); osc.stop(t0 + dur + 0.02); } // A burst of filtered noise with a decay envelope. function thump({ cutoff = 400, dur = 0.15, vol = 0.4, when = 0, q = 1, type = 'lowpass' }) { const c = ac(); const t0 = c.currentTime + when; const src = noise(); const f = c.createBiquadFilter(); f.type = type; f.frequency.value = cutoff; f.Q.value = q; const g = c.createGain(); g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); src.connect(f).connect(g).connect(master); src.start(t0); src.stop(t0 + dur + 0.02); } export const sfx = { unlock() { ac(); }, step() { thump({ cutoff: 220 + Math.random() * 80, dur: 0.12, vol: 0.35 }); thump({ cutoff: 150, dur: 0.08, vol: 0.2, when: 0.05 }); }, bump() { thump({ cutoff: 120, dur: 0.2, vol: 0.5 }); tone({ freq: 65, type: 'triangle', dur: 0.18, vol: 0.3 }); }, turn() { thump({ cutoff: 500, dur: 0.05, vol: 0.12 }); }, rest() { tone({ freq: 196, type: 'sine', dur: 0.4, vol: 0.06, slide: 30 }); }, loot() { tone({ freq: 880, type: 'triangle', dur: 0.1, vol: 0.2 }); tone({ freq: 1320, type: 'triangle', dur: 0.15, vol: 0.2, when: 0.07 }); }, relic() { [523, 659, 784, 1046].forEach((f, i) => tone({ freq: f, type: 'triangle', dur: 0.3, vol: 0.18, when: i * 0.09 })); }, potion() { tone({ freq: 300, type: 'sine', dur: 0.25, vol: 0.2, slide: 260 }); }, encounter() { tone({ freq: 110, type: 'sawtooth', dur: 0.5, vol: 0.25, slide: -45 }); thump({ cutoff: 800, dur: 0.4, vol: 0.25, when: 0.05 }); tone({ freq: 55, type: 'triangle', dur: 0.7, vol: 0.3, when: 0.1 }); }, growl() { tone({ freq: 70, type: 'sawtooth', dur: 0.5, vol: 0.07, slide: -20 }); }, hit() { thump({ cutoff: 900, dur: 0.1, vol: 0.4, type: 'bandpass', q: 1.5 }); tone({ freq: 180, type: 'square', dur: 0.07, vol: 0.12 }); }, heavyHit() { thump({ cutoff: 500, dur: 0.25, vol: 0.55, type: 'bandpass' }); tone({ freq: 90, type: 'sawtooth', dur: 0.3, vol: 0.3, slide: -30 }); }, playerHit() { thump({ cutoff: 300, dur: 0.2, vol: 0.5 }); tone({ freq: 140, type: 'square', dur: 0.12, vol: 0.15, slide: -40 }); }, cast() { tone({ freq: 440, type: 'sine', dur: 0.35, vol: 0.12, slide: 320 }); }, fizzle() { tone({ freq: 600, type: 'sawtooth', dur: 0.25, vol: 0.15, slide: -480 }); }, daze() { tone({ freq: 660, type: 'triangle', dur: 0.6, vol: 0.2, slide: -200 }); tone({ freq: 663, type: 'triangle', dur: 0.6, vol: 0.2, slide: -210 }); }, guard() { thump({ cutoff: 2500, dur: 0.08, vol: 0.2, type: 'highpass' }); tone({ freq: 520, type: 'square', dur: 0.06, vol: 0.08 }); }, recovery() { tone({ freq: 392, type: 'sine', dur: 0.4, vol: 0.18, slide: 130 }); }, death() { tone({ freq: 220, type: 'sawtooth', dur: 1.4, vol: 0.3, slide: -170 }); thump({ cutoff: 200, dur: 1.0, vol: 0.4, when: 0.1 }); }, monsterDeath() { tone({ freq: 160, type: 'sawtooth', dur: 0.7, vol: 0.25, slide: -110 }); thump({ cutoff: 350, dur: 0.5, vol: 0.3, when: 0.05 }); }, fled() { [400, 320, 260].forEach((f, i) => thump({ cutoff: f, dur: 0.1, vol: 0.3, when: i * 0.09 })); }, descend() { [200, 160, 120, 90].forEach((f, i) => { thump({ cutoff: f * 2, dur: 0.25, vol: 0.35, when: i * 0.22 }); tone({ freq: f, type: 'triangle', dur: 0.3, vol: 0.15, when: i * 0.22 }); }); }, victory() { [392, 523, 659, 784, 1046].forEach((f, i) => tone({ freq: f, type: 'triangle', dur: 0.5, vol: 0.2, when: i * 0.13 })); thump({ cutoff: 4000, dur: 1.2, vol: 0.08, when: 0.6, type: 'highpass' }); }, // A faint dungeon bed: low drone + slow filtered rumble. Idempotent. ambientOn() { const c = ac(); if (ambient) return; const g = c.createGain(); g.gain.value = 0.05; const drone = c.createOscillator(); drone.type = 'sine'; drone.frequency.value = 55; const drone2 = c.createOscillator(); drone2.type = 'sine'; drone2.frequency.value = 55.7; // beat frequency shimmer const n = noise(); n.loop = true; const f = c.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 90; const ng = c.createGain(); ng.gain.value = 0.4; drone.connect(g); drone2.connect(g); n.connect(f).connect(ng).connect(g); g.connect(master); drone.start(); drone2.start(); n.start(); ambient = { g, drone, drone2, n }; }, ambientOff() { if (!ambient) return; const { g, drone, drone2, n } = ambient; g.gain.linearRampToValueAtTime(0, ac().currentTime + 0.6); setTimeout(() => { drone.stop(); drone2.stop(); n.stop(); }, 700); ambient = null; }, };