Added audioTimeout() to audio/context.ts — tracked timer set cleared on scene destroy via clearAllAudioTimers(). Converted 20 sfx.ts + 3 voice.ts raw setTimeout calls to audioTimeout. Converted FightScene playEntrance timeout to trackedTimeout. Remaining setTimeout in tts.ts worker layer and music.ts (already tracked via setMusicTimeout) are self-managing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
3.6 KiB
TypeScript
138 lines
3.6 KiB
TypeScript
// 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<ReturnType<typeof setTimeout>>()
|
|
|
|
export function audioTimeout(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
|
|
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 }
|