From 8afdc2d898b24133acbbbdb9ba70b2ae79e5b3b3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 09:59:16 +0000 Subject: [PATCH] fix: replace raw setTimeout with tracked timers in game audio (BUG-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/game/FightScene.ts | 6 ++-- frontend/src/game/audio/context.ts | 14 +++++++++ frontend/src/game/audio/index.ts | 1 + frontend/src/game/audio/sfx.ts | 50 +++++++++++++++--------------- frontend/src/game/audio/voice.ts | 8 ++--- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 3ccc795..0f6474e 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -32,7 +32,7 @@ import { sfxJetpack, sfxExplosion, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly, sfxBoing, sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh, sfxRapidPunch, sfxPowerUp, sfxCoin, sfxRandomComedy, - startMusic, stopMusic, announceCool, + startMusic, stopMusic, announceCool, clearAllAudioTimers, } from './audio' @@ -1225,7 +1225,7 @@ export async function createFightScene(config: FightSceneConfig) { // Timeout entrance to prevent hanging on mobile (max 8s) await Promise.race([ entranceSystem.playEntrance(), - new Promise(r => setTimeout(r, 8000)), + new Promise(r => trackedTimeout(r, 8000)), ]) }, @@ -1285,6 +1285,8 @@ export async function createFightScene(config: FightSceneConfig) { clearTimeout(id) } cleanupTimers.clear() + // Clear audio timers (SFX/voice stagger delays) + clearAllAudioTimers() // Destroy all remaining game objects before quitting try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {} k.quit() diff --git a/frontend/src/game/audio/context.ts b/frontend/src/game/audio/context.ts index 5018cd1..00b63d3 100644 --- a/frontend/src/game/audio/context.ts +++ b/frontend/src/game/audio/context.ts @@ -120,4 +120,18 @@ export function resetGainNodes() { } } +// 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 } diff --git a/frontend/src/game/audio/index.ts b/frontend/src/game/audio/index.ts index de4c908..5573e38 100644 --- a/frontend/src/game/audio/index.ts +++ b/frontend/src/game/audio/index.ts @@ -91,6 +91,7 @@ export { setMusicVolume, setSfxVolume, resetGainNodes, + clearAllAudioTimers, MUSIC_VOL, SFX_VOL, } from './context' diff --git a/frontend/src/game/audio/sfx.ts b/frontend/src/game/audio/sfx.ts index 6ba88b8..1fe6670 100644 --- a/frontend/src/game/audio/sfx.ts +++ b/frontend/src/game/audio/sfx.ts @@ -1,6 +1,6 @@ // All sound effect functions — impacts, fanfares, crowd sounds, comedy SFX -import { getCtx, getSfxDest } from './context' +import { getCtx, getSfxDest, audioTimeout } from './context' import { tone, noise, sweep, reverbTail, bodyThump, highSnap, fmImpact } from './primitives' import { announce, speak } from './voice' @@ -17,7 +17,7 @@ export function fanfareRound(roundNum: number) { tone(392, 'square', 0.25, d, t + 0.36) // G4 noise(0.08, d, t + 0.36) // Announce after fanfare - setTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400) + audioTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400) } export function fanfareFight() { @@ -29,7 +29,7 @@ export function fanfareFight() { tone(659, 'square', 0.08, d, t + 0.08) tone(784, 'square', 0.15, d, t + 0.16) noise(0.1, d, t + 0.16) - setTimeout(() => announce('Fight!', 0.3, 1.1), 200) + audioTimeout(() => announce('Fight!', 0.3, 1.1), 200) } export function fanfareDevastating() { @@ -39,7 +39,7 @@ export function fanfareDevastating() { tone(220, 'sawtooth', 0.2, d, t) tone(175, 'sawtooth', 0.3, d, t + 0.15) noise(0.15, d, t + 0.1) - setTimeout(() => speak('Devastating!', 'deep', false, true), 150) + audioTimeout(() => speak('Devastating!', 'deep', false, true), 150) } export function fanfareCritical() { @@ -51,7 +51,7 @@ export function fanfareCritical() { tone(659, 'square', 0.1, d, t + 0.16) tone(880, 'square', 0.2, d, t + 0.24) noise(0.12, d, t + 0.24) - setTimeout(() => announce('Critical hit!', 0.3, 1.0), 300) + audioTimeout(() => announce('Critical hit!', 0.3, 1.0), 300) } export function fanfareCombo(count: number) { @@ -61,7 +61,7 @@ export function fanfareCombo(count: number) { for (let i = 0; i < Math.min(count, 5); i++) { tone(440 + i * 80, 'square', 0.08, d, t + i * 0.06) } - if (count >= 3) setTimeout(() => announce(`${count} hit combo!`, 0.4, 1.0), 200) + if (count >= 3) audioTimeout(() => announce(`${count} hit combo!`, 0.4, 1.0), 200) } // === MODERN SFX === @@ -104,7 +104,7 @@ export function sfxCritical() { highSnap(5000, 0.03, d, t + 0.02) fmImpact(200, 7, 0.25, d, t + 0.03) noise(0.15, d, t) - setTimeout(() => { + audioTimeout(() => { bodyThump(90, 0.2, d) sweep(200, 600, 'sawtooth', 0.2, d) noise(0.1, d) @@ -165,7 +165,7 @@ export function sfxExplosion() { noise(0.2, d, t) sweep(300, 30, 'sawtooth', 0.4, d) fmImpact(400, 5, 0.3, d, t + 0.05) - setTimeout(() => { + audioTimeout(() => { bodyThump(50, 0.3, d) noise(0.15, d) }, 120) @@ -209,13 +209,13 @@ export function sfxKO() { highSnap(2000, 0.06, d, t) noise(0.25, d, t) fmImpact(150, 5, 0.4, d, t) - setTimeout(() => { + audioTimeout(() => { bodyThump(40, 0.4, d) noise(0.15, d) highSnap(3000, 0.04, d) }, 180) reverbTail(1.0, d, t + 0.05) - setTimeout(() => speak('K. O.!', 'announcer', false, true), 500) + audioTimeout(() => speak('K. O.!', 'announcer', false, true), 500) } export function sfxPerfect() { @@ -225,14 +225,14 @@ export function sfxPerfect() { noise(0.3, d, t) fmImpact(300, 7, 0.5, d, t) sweep(800, 30, 'sawtooth', 0.6, d) - setTimeout(() => { + audioTimeout(() => { tone(262, 'square', 0.2, d); tone(262, 'triangle', 0.2, d) tone(330, 'square', 0.2, d); tone(330, 'triangle', 0.2, d) tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d) fmImpact(523, 1.5, 0.3, d) }, 400) reverbTail(1.2, d, t + 0.1) - setTimeout(() => announce('Perfect!', 0.2, 0.5), 800) + audioTimeout(() => announce('Perfect!', 0.2, 0.5), 800) } export function sfxWin() { @@ -258,7 +258,7 @@ export function sfxWin() { } export function sfxWinAnnounce(winnerName: string) { - setTimeout(() => announce(`${winnerName} wins!`, 0.3, 0.7), 200) + audioTimeout(() => announce(`${winnerName} wins!`, 0.3, 0.7), 200) } export function sfxRoundStart() { @@ -280,8 +280,8 @@ export function sfxBoing() { const c = getCtx(); const t = c.currentTime sweep(200, 900, 'sine', 0.12, d) fmImpact(400, 3, 0.1, d, t) - setTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80) - setTimeout(() => sweep(400, 550, 'sine', 0.06, d), 140) + audioTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80) + audioTimeout(() => sweep(400, 550, 'sine', 0.06, d), 140) } export function sfxWomp() { @@ -333,8 +333,8 @@ export function sfxZap() { const c = getCtx(); const t = c.currentTime sweep(100, 4000, 'sawtooth', 0.06, d) fmImpact(1000, 7, 0.1, d, t) - setTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40) - setTimeout(() => sweep(600, 5000, 'sawtooth', 0.05, d), 80) + audioTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40) + audioTimeout(() => sweep(600, 5000, 'sawtooth', 0.05, d), 80) highSnap(6000, 0.03, d, t) } @@ -344,7 +344,7 @@ export function sfxZoomWhoosh() { sweep(80, 1800, 'sawtooth', 0.2, d) sweep(100, 2200, 'sine', 0.18, d) noise(0.15, d, t) - setTimeout(() => { + audioTimeout(() => { sweep(1800, 150, 'sawtooth', 0.12, d) sweep(2200, 200, 'sine', 0.1, d) }, 150) @@ -387,7 +387,7 @@ export function sfxFail() { const c = getCtx(); const t = c.currentTime sweep(600, 80, 'sawtooth', 0.25, d) bodyThump(80, 0.2, d, t + 0.15, 0.15) - setTimeout(() => noise(0.08, d), 180) + audioTimeout(() => noise(0.08, d), 180) reverbTail(0.3, d, t + 0.2) } @@ -463,7 +463,7 @@ export function sfxRecordScratch() { sweep(2500, 80, 'square', 0.12, d) noise(0.1, d, t) highSnap(4000, 0.03, d, t) - setTimeout(() => sweep(200, 400, 'sine', 0.08, d), 100) + audioTimeout(() => sweep(200, 400, 'sine', 0.08, d), 100) } export function sfxRubberChicken() { @@ -489,7 +489,7 @@ export function sfxSqueakyToy() { const d = getSfxDest() sweep(400, 1800, 'sine', 0.06, d) sweep(1800, 600, 'sine', 0.1, d) - setTimeout(() => sweep(500, 1500, 'sine', 0.05, d), 120) + audioTimeout(() => sweep(500, 1500, 'sine', 0.05, d), 120) } export function sfxWetSlap() { @@ -582,7 +582,7 @@ export function sfxWindowsError() { tone(440, 'square', 0.15, d, t) tone(466, 'square', 0.15, d, t) tone(220, 'triangle', 0.2, d, t) - setTimeout(() => { + audioTimeout(() => { tone(349, 'square', 0.2, d) tone(175, 'triangle', 0.25, d) }, 180) @@ -657,7 +657,7 @@ export function sfxFailHorn() { tone(311, 'sawtooth', 0.3, d, t) tone(156, 'sawtooth', 0.3, d, t) tone(233, 'square', 0.3, d, t) - setTimeout(() => { + audioTimeout(() => { tone(277, 'sawtooth', 0.5, d) tone(139, 'sawtooth', 0.5, d) tone(208, 'square', 0.5, d) @@ -717,7 +717,7 @@ export function sfxMissionFailed() { tone(196, 'sawtooth', 0.3, d, t) tone(247, 'sawtooth', 0.3, d, t) bodyThump(82, 0.2, d, t, 0.2) - setTimeout(() => { + audioTimeout(() => { tone(139, 'sawtooth', 0.6, d) tone(165, 'sawtooth', 0.6, d) tone(208, 'sawtooth', 0.6, d) @@ -934,5 +934,5 @@ export function sfxModem(durationMs = 1800): Promise { tone(f, 'sine', 0.04, d, t + 1.4 + i * 0.08) } - return new Promise(resolve => setTimeout(resolve, durationMs)) + return new Promise(resolve => audioTimeout(resolve, durationMs)) } diff --git a/frontend/src/game/audio/voice.ts b/frontend/src/game/audio/voice.ts index b0b32fe..9326532 100644 --- a/frontend/src/game/audio/voice.ts +++ b/frontend/src/game/audio/voice.ts @@ -1,6 +1,6 @@ // Voice profiles, speak, announce, TTS speech queue, Creator voice lines -import { getCtx, getSfxDest, getMasterMuted, getSfxGain } from './context' +import { getCtx, getSfxDest, getMasterMuted, getSfxGain, audioTimeout } from './context' import { tone, noise } from './primitives' import { initKokoro, isKokoroReady, kokoroSpeak, kokoroSpeakAsync, kokoroStop, kokoroPrefetch, kokoroAwaitReady, kokoroPlayCached, setAudioContext } from '../tts' @@ -270,7 +270,7 @@ export function speak(text: string, profileName: string, cancelPrevious: boolean if (_speechQueueDepth === 0) _webSpeechActive = false if (ev.error !== 'canceled' && ev.error !== 'interrupted') { if (!isKokoroReady()) { - setTimeout(() => { + audioTimeout(() => { if (!getMasterMuted() && !isKokoroReady() && typeof speechSynthesis !== 'undefined') { const retry = new SpeechSynthesisUtterance(text) if (profile.voice) retry.voice = profile.voice @@ -327,8 +327,8 @@ async function _speakAsyncCore(text: string, profileName: string, rateOverride?: _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) resolve() } - const safetyTimeout = setTimeout(cleanup, 8_000) - const startupCheck = setTimeout(() => { + const safetyTimeout = audioTimeout(cleanup, 8_000) + const startupCheck = audioTimeout(() => { if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup() }, 500) let keepalive: ReturnType | null = null