// ─── Audio: WebAudio synthesized SFX + ambient music ─────────────────────── import { Sfx } from '../render/fx'; let ctx: AudioContext | null = null; let master: GainNode | null = null; let musicGain: GainNode | null = null; let musicTimer: number | null = null; export const audioState = { enabled: true, music: true, volume: 0.7, started: false }; function ac(): AudioContext | null { if (!audioState.enabled) return null; if (!ctx) { try { ctx = new (window.AudioContext || (window as never as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); master = ctx.createGain(); master.gain.value = audioState.volume; master.connect(ctx.destination); musicGain = ctx.createGain(); musicGain.gain.value = 0.35; musicGain.connect(master); } catch { return null; } } if (ctx.state === 'suspended') void ctx.resume(); return ctx; } export function unlockAudio() { ac(); audioState.started = true; } interface ToneOpts { freq: number; dur?: number; type?: OscillatorType; vol?: number; slide?: number; delay?: number; attack?: number; } function tone(o: ToneOpts) { const c = ac(); if (!c || !master) return; const t0 = c.currentTime + (o.delay ?? 0); const osc = c.createOscillator(); osc.type = o.type ?? 'square'; osc.frequency.setValueAtTime(o.freq, t0); if (o.slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freq + o.slide), t0 + (o.dur ?? 0.2)); const g = c.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(o.vol ?? 0.2, t0 + (o.attack ?? 0.01)); g.gain.exponentialRampToValueAtTime(0.0001, t0 + (o.dur ?? 0.2)); osc.connect(g); g.connect(master); osc.start(t0); osc.stop(t0 + (o.dur ?? 0.2) + 0.05); } function noise(dur: number, vol = 0.25, filterFreq = 800, delay = 0) { const c = ac(); if (!c || !master) return; const t0 = c.currentTime + delay; const len = Math.max(1, Math.floor(c.sampleRate * dur)); const buf = c.createBuffer(1, len, c.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); const src = c.createBufferSource(); src.buffer = buf; const f = c.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = filterFreq; const g = c.createGain(); g.gain.value = vol; src.connect(f); f.connect(g); g.connect(master); src.start(t0); } const SFX: Record void> = { build: () => { tone({ freq: 160, dur: 0.12, type: 'triangle', vol: 0.3 }); noise(0.08, 0.15, 500); }, demolish: () => { noise(0.25, 0.3, 400); tone({ freq: 90, dur: 0.2, type: 'sawtooth', vol: 0.18, slide: -40 }); }, coin: () => { tone({ freq: 880, dur: 0.07, type: 'square', vol: 0.12 }); tone({ freq: 1320, dur: 0.09, type: 'square', vol: 0.1, delay: 0.06 }); }, click: () => tone({ freq: 660, dur: 0.04, type: 'square', vol: 0.07 }), clash: () => { noise(0.06, 0.22, 2600); tone({ freq: 2200, dur: 0.05, vol: 0.06, type: 'triangle', slide: -600 }); }, arrow: () => tone({ freq: 900, dur: 0.08, type: 'sawtooth', vol: 0.08, slide: -300 }), zap: () => { tone({ freq: 1400, dur: 0.12, type: 'sawtooth', vol: 0.14, slide: -900 }); }, fire: () => noise(0.5, 0.12, 700), crumble: () => { noise(0.4, 0.35, 300); tone({ freq: 70, dur: 0.35, type: 'sawtooth', vol: 0.2, slide: -30 }); }, smash: () => noise(0.12, 0.28, 900), horn: () => { tone({ freq: 196, dur: 0.5, type: 'sawtooth', vol: 0.16 }); tone({ freq: 262, dur: 0.55, type: 'sawtooth', vol: 0.13, delay: 0.18 }); }, monster: () => { tone({ freq: 90, dur: 0.7, type: 'sawtooth', vol: 0.24, slide: -45 }); noise(0.6, 0.14, 350, 0.1); }, heal: () => { [523, 659, 784].forEach((f, i) => tone({ freq: f, dur: 0.25, type: 'sine', vol: 0.12, delay: i * 0.09 })); }, fanfare: () => { [392, 523, 659, 784].forEach((f, i) => tone({ freq: f, dur: 0.32, type: 'triangle', vol: 0.16, delay: i * 0.13 })); }, evolve: () => { tone({ freq: 523, dur: 0.1, type: 'sine', vol: 0.12 }); tone({ freq: 784, dur: 0.14, type: 'sine', vol: 0.12, delay: 0.09 }); }, firestorm: () => { noise(0.7, 0.4, 1200); tone({ freq: 60, dur: 0.6, type: 'sawtooth', vol: 0.26, slide: -25 }); }, }; // ── ambient music: slow lyre-ish arpeggio over drone ── const SCALE = [196.0, 233.1, 261.6, 293.7, 349.2, 392.0, 466.2]; // G minor pentatonic-ish let step = 0; function musicStep() { const c = ac(); if (!c || !musicGain) return; const root = SCALE[step % SCALE.length]; // pluck const t0 = c.currentTime; for (const mult of [1, 2]) { const osc = c.createOscillator(); osc.type = mult === 1 ? 'triangle' : 'sine'; osc.frequency.value = root * mult; const g = c.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(mult === 1 ? 0.16 : 0.05, t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.4); osc.connect(g); g.connect(musicGain); osc.start(t0); osc.stop(t0 + 1.5); } if (step % 8 === 0) { // low drone swell const osc = c.createOscillator(); osc.type = 'sine'; osc.frequency.value = SCALE[(step >> 3) % 3] / 2; const g = c.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.linearRampToValueAtTime(0.08, t0 + 1.2); g.gain.linearRampToValueAtTime(0.0001, t0 + 4.5); osc.connect(g); g.connect(musicGain); osc.start(t0); osc.stop(t0 + 4.7); } step++; } export function startMusic() { if (musicTimer !== null || !audioState.music) return; musicTimer = window.setInterval(() => { if (audioState.music && audioState.enabled && document.visibilityState === 'visible') musicStep(); }, 1400); } export function stopMusic() { if (musicTimer !== null) { clearInterval(musicTimer); musicTimer = null; } } export function playSfx(name: string) { if (!audioState.enabled) return; SFX[name]?.(); } // wire the global hook used by renderer/sim Sfx.play = (name: string) => playSfx(name); export function setVolume(v: number) { audioState.volume = v; if (master) master.gain.value = v; }