// Audio context, gain nodes, volume/mute management // This module has ZERO imports from other audio modules to prevent circular deps. let ctx: AudioContext | null = null let musicGain: GainNode | null = null let sfxGain: GainNode | null = null let musicPlaying = false let musicTimeout: number | null = null // AudioContext is ONLY created inside ensureAudioContext() (called from user gesture). // Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings. let _audioUnlocked = false let masterMuted = false const MUSIC_VOL = 0.12 const SFX_VOL = 0.25 export function initCtx() { if (ctx) return ctx = new AudioContext() musicGain = ctx.createGain() musicGain.gain.value = masterMuted ? 0 : 0.12 musicGain.connect(ctx.destination) sfxGain = ctx.createGain() sfxGain.gain.value = masterMuted ? 0 : 0.25 sfxGain.connect(ctx.destination) } export function getCtx(): AudioContext { if (!ctx) initCtx() if (ctx!.state === 'suspended') { ctx!.resume().catch(err => console.warn('[Audio] context resume failed:', err)) } return ctx! } // Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid) export function getSfxDest(): AudioNode { const c = getCtx() return sfxGain ?? c.destination } export function getMusicGain(): GainNode | null { return musicGain } export function getSfxGain(): GainNode | null { return sfxGain } export function isMusicPlaying(): boolean { return musicPlaying } export function setMusicPlaying(v: boolean) { musicPlaying = v } export function getMusicTimeout(): number | null { return musicTimeout } export function setMusicTimeout(v: number | null) { musicTimeout = v } export function isAudioUnlocked(): boolean { return _audioUnlocked } export function setAudioUnlocked(v: boolean) { _audioUnlocked = v } export function isMasterMuted(): boolean { return masterMuted } export function getMasterMuted(): boolean { return masterMuted } export function setMusicVolume(v: number) { if (musicGain) musicGain.gain.value = Math.max(0, Math.min(1, v)) } export function setSfxVolume(v: number) { if (sfxGain) sfxGain.gain.value = Math.max(0, Math.min(1, v)) } export function setMasterMute(muted: boolean) { masterMuted = muted if (muted) { if (musicGain) musicGain.gain.value = 0 if (sfxGain) sfxGain.gain.value = 0 // Note: kokoroStop and speechSynthesis.cancel are called from the caller // to avoid circular deps — see index.ts setMasterMute wrapper } else { if (musicGain) musicGain.gain.value = MUSIC_VOL if (sfxGain) sfxGain.gain.value = SFX_VOL } } // Disconnect gain nodes to instantly kill all in-flight oscillators/buffers, // then reconnect so future sounds still work export function resetGainNodes() { if (ctx) { if (musicGain) { musicGain.disconnect() musicGain = ctx.createGain() musicGain.gain.value = masterMuted ? 0 : MUSIC_VOL musicGain.connect(ctx.destination) } if (sfxGain) { sfxGain.disconnect() sfxGain = ctx.createGain() sfxGain.gain.value = masterMuted ? 0 : SFX_VOL sfxGain.connect(ctx.destination) } } } // Tracked audio timers — SFX fire-and-forget delays that must be cleaned on scene destroy const _audioTimers = new Set>() export function audioTimeout(fn: () => void, ms: number): ReturnType { const id = setTimeout(() => { _audioTimers.delete(id); fn() }, ms) _audioTimers.add(id) return id } export function clearAllAudioTimers() { for (const id of _audioTimers) clearTimeout(id) _audioTimers.clear() } export { MUSIC_VOL, SFX_VOL }