let ctx: AudioContext | null = null; function getCtx(): AudioContext { if (!ctx) ctx = new AudioContext(); if (ctx.state === 'suspended') ctx.resume(); return ctx; } function playTone( freq: number, duration: number, type: OscillatorType = 'sine', volume = 0.15, rampDown = true, ) { const ac = getCtx(); const osc = ac.createOscillator(); const gain = ac.createGain(); osc.type = type; osc.frequency.value = freq; gain.gain.value = volume; if (rampDown) gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + duration); osc.connect(gain).connect(ac.destination); osc.start(); osc.stop(ac.currentTime + duration); } function playNoise(duration: number, volume = 0.04) { const ac = getCtx(); const bufferSize = ac.sampleRate * duration; const buffer = ac.createBuffer(1, bufferSize, ac.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * 0.5; const src = ac.createBufferSource(); src.buffer = buffer; const gain = ac.createGain(); gain.gain.value = volume; gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + duration); const filter = ac.createBiquadFilter(); filter.type = 'highpass'; filter.frequency.value = 4000; src.connect(filter).connect(gain).connect(ac.destination); src.start(); } // Mute control let _muted = false; export function isMuted() { return _muted; } export function setMuted(v: boolean) { _muted = v; } export function toggleMute() { _muted = !_muted; return _muted; } function guard(fn: () => void) { if (!_muted) fn(); } // Ambient lab hum (low drone) let ambientOsc: OscillatorNode | null = null; let ambientGain: GainNode | null = null; export function startAmbient() { if (ambientOsc || _muted) return; const ac = getCtx(); ambientOsc = ac.createOscillator(); ambientGain = ac.createGain(); ambientOsc.type = 'sine'; ambientOsc.frequency.value = 80; ambientGain.gain.value = 0.015; // Add slight modulation for realism const lfo = ac.createOscillator(); const lfoGain = ac.createGain(); lfo.type = 'sine'; lfo.frequency.value = 0.3; lfoGain.gain.value = 5; lfo.connect(lfoGain).connect(ambientOsc.frequency); lfo.start(); ambientOsc.connect(ambientGain).connect(ac.destination); ambientOsc.start(); } export function stopAmbient() { try { ambientOsc?.stop(); } catch { /* ignore */ } ambientOsc = null; ambientGain = null; } export const sfx = { episodeStart() { guard(() => { playTone(523, 0.15, 'sine', 0.12); setTimeout(() => playTone(659, 0.15, 'sine', 0.12), 100); setTimeout(() => playTone(784, 0.25, 'sine', 0.10), 200); }); }, scientistSpeak() { guard(() => { playTone(440, 0.08, 'triangle', 0.06); setTimeout(() => playTone(520, 0.08, 'triangle', 0.05), 60); setTimeout(() => playTone(480, 0.12, 'triangle', 0.04), 120); }); }, labManagerSpeak() { guard(() => { playTone(330, 0.08, 'square', 0.04); setTimeout(() => playTone(350, 0.10, 'square', 0.04), 70); setTimeout(() => playTone(310, 0.12, 'square', 0.03), 140); }); }, judgeAppear() { guard(() => { playTone(220, 0.3, 'sawtooth', 0.06); playTone(330, 0.3, 'sawtooth', 0.05); setTimeout(() => { playTone(440, 0.4, 'sine', 0.10); playTone(554, 0.4, 'sine', 0.07); }, 200); }); }, gavel() { guard(() => { playNoise(0.08, 0.12); playTone(180, 0.15, 'square', 0.10); setTimeout(() => { playNoise(0.06, 0.08); playTone(160, 0.2, 'square', 0.08); }, 250); }); }, scoreReveal() { guard(() => { const notes = [523, 587, 659, 784]; notes.forEach((f, i) => { setTimeout(() => playTone(f, 0.18, 'sine', 0.08), i * 90); }); }); }, success() { guard(() => { playTone(523, 0.12, 'sine', 0.10); setTimeout(() => playTone(659, 0.12, 'sine', 0.10), 120); setTimeout(() => playTone(784, 0.12, 'sine', 0.10), 240); setTimeout(() => playTone(1047, 0.3, 'sine', 0.12), 360); }); }, failure() { guard(() => { playTone(400, 0.2, 'sawtooth', 0.07); setTimeout(() => playTone(350, 0.2, 'sawtooth', 0.07), 200); setTimeout(() => playTone(300, 0.4, 'sawtooth', 0.06), 400); }); }, click() { guard(() => playTone(800, 0.04, 'square', 0.05)); }, roundTick() { guard(() => playTone(1200, 0.05, 'sine', 0.06)); }, negotiate() { guard(() => { playTone(392, 0.1, 'triangle', 0.05); setTimeout(() => playTone(440, 0.1, 'triangle', 0.05), 80); }); }, // New: typing sound for message streaming effect typeChar() { guard(() => playTone(1400, 0.02, 'square', 0.02)); }, // New: protocol change notification protocolChange() { guard(() => { playTone(600, 0.08, 'sine', 0.06); setTimeout(() => playTone(750, 0.08, 'sine', 0.06), 80); }); }, // New: auto-play tick autoTick() { guard(() => playTone(900, 0.03, 'triangle', 0.04)); }, };