/** * Shared procedural SFX bus — −18…−12 LUFS ballpark, ducks under TTS/PTT. * See packages/ui-web/MOTION-EVOO-CHARACTER.md */ type AudioContextCtor = new () => AudioContext; /** Master output ~−12 LUFS for clear, warm procedural peaks. */ const SFX_MASTER_GAIN = 0.85; /** Duck depth when voice lane active (TTS or mic wash). */ const SFX_DUCK_RATIO = 0.28; const DUCK_RAMP_S = 0.065; function audioContextCtor(): AudioContextCtor | undefined { if (typeof AudioContext === "function") return AudioContext; if (typeof window === "undefined") return undefined; const host = window as Window & { webkitAudioContext?: AudioContextCtor }; return host.webkitAudioContext; } class OoAudioBus { private ctx: AudioContext | null = null; private sfxGain: GainNode | null = null; private voiceHold = 0; private unlockBound = false; ensureContext(): AudioContext | null { this.bindGestureUnlock(); if (this.ctx && this.ctx.state !== "closed") return this.ctx; const Ctor = audioContextCtor(); if (!Ctor) return null; try { const ctx = new Ctor(); const master = ctx.createGain(); master.gain.value = 1; master.connect(ctx.destination); const sfxGain = ctx.createGain(); sfxGain.gain.value = SFX_MASTER_GAIN; sfxGain.connect(master); this.ctx = ctx; this.sfxGain = sfxGain; return ctx; } catch { return null; } } /** Unlocks Web Audio on the user's first click, key, or tap so async SSE events can play audio. */ bindGestureUnlock(): void { if ( this.unlockBound || typeof window === "undefined" || typeof window.addEventListener !== "function" ) { return; } this.unlockBound = true; const unlock = () => { const ctx = this.ensureContext(); if (ctx && ctx.state === "suspended") { void ctx.resume().catch(() => undefined); } if (typeof window !== "undefined" && "speechSynthesis" in window) { try { window.speechSynthesis.getVoices?.(); } catch { // Ignore voice preload errors. } } }; window.addEventListener("pointerdown", unlock, { capture: true, passive: true }); window.addEventListener("keydown", unlock, { capture: true, passive: true }); window.addEventListener("touchstart", unlock, { capture: true, passive: true }); } /** Route for oo-sound / oo-vocals / oo-voice-mark oscillators. */ sfxDestination(ctx: AudioContext): AudioNode { if (ctx !== this.ctx) { const master = ctx.createGain(); master.gain.value = SFX_MASTER_GAIN; master.connect(ctx.destination); return master; } return this.sfxGain ?? ctx.destination; } /** TTS playback or PTT/listening — duck SFX so vocals don't fight the wash. */ setVoiceLaneActive(active: boolean) { const ctx = this.ensureContext(); if (!ctx || !this.sfxGain) return; this.voiceHold = Math.max(0, this.voiceHold + (active ? 1 : -1)); const t = ctx.currentTime; const target = this.voiceHold > 0 ? SFX_MASTER_GAIN * SFX_DUCK_RATIO : SFX_MASTER_GAIN; this.sfxGain.gain.cancelScheduledValues(t); this.sfxGain.gain.setValueAtTime(this.sfxGain.gain.value, t); this.sfxGain.gain.linearRampToValueAtTime(target, t + DUCK_RAMP_S); } /** Test helper — reset duck state. */ resetVoiceHoldForTests() { this.voiceHold = 0; if (this.sfxGain) this.sfxGain.gain.value = SFX_MASTER_GAIN; } } export const ooAudioBus = new OoAudioBus();