fix: replace raw setTimeout with tracked timers in game audio (BUG-4)

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>
This commit is contained in:
Dorian
2026-03-13 09:59:16 +00:00
co-authored by Claude Opus 4.6
parent 3c5c6c6b95
commit 8afdc2d898
5 changed files with 48 additions and 31 deletions
+4 -2
View File
@@ -32,7 +32,7 @@ import {
sfxJetpack, sfxExplosion, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly, sfxJetpack, sfxExplosion, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly,
sfxBoing, sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh, sfxBoing, sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh,
sfxRapidPunch, sfxPowerUp, sfxCoin, sfxRandomComedy, sfxRapidPunch, sfxPowerUp, sfxCoin, sfxRandomComedy,
startMusic, stopMusic, announceCool, startMusic, stopMusic, announceCool, clearAllAudioTimers,
} from './audio' } from './audio'
@@ -1225,7 +1225,7 @@ export async function createFightScene(config: FightSceneConfig) {
// Timeout entrance to prevent hanging on mobile (max 8s) // Timeout entrance to prevent hanging on mobile (max 8s)
await Promise.race([ await Promise.race([
entranceSystem.playEntrance(), entranceSystem.playEntrance(),
new Promise<void>(r => setTimeout(r, 8000)), new Promise<void>(r => trackedTimeout(r, 8000)),
]) ])
}, },
@@ -1285,6 +1285,8 @@ export async function createFightScene(config: FightSceneConfig) {
clearTimeout(id) clearTimeout(id)
} }
cleanupTimers.clear() cleanupTimers.clear()
// Clear audio timers (SFX/voice stagger delays)
clearAllAudioTimers()
// Destroy all remaining game objects before quitting // Destroy all remaining game objects before quitting
try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {} try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {}
k.quit() k.quit()
+14
View File
@@ -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<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 } export { MUSIC_VOL, SFX_VOL }
+1
View File
@@ -91,6 +91,7 @@ export {
setMusicVolume, setMusicVolume,
setSfxVolume, setSfxVolume,
resetGainNodes, resetGainNodes,
clearAllAudioTimers,
MUSIC_VOL, MUSIC_VOL,
SFX_VOL, SFX_VOL,
} from './context' } from './context'
+25 -25
View File
@@ -1,6 +1,6 @@
// All sound effect functions — impacts, fanfares, crowd sounds, comedy SFX // 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 { tone, noise, sweep, reverbTail, bodyThump, highSnap, fmImpact } from './primitives'
import { announce, speak } from './voice' import { announce, speak } from './voice'
@@ -17,7 +17,7 @@ export function fanfareRound(roundNum: number) {
tone(392, 'square', 0.25, d, t + 0.36) // G4 tone(392, 'square', 0.25, d, t + 0.36) // G4
noise(0.08, d, t + 0.36) noise(0.08, d, t + 0.36)
// Announce after fanfare // Announce after fanfare
setTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400) audioTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400)
} }
export function fanfareFight() { export function fanfareFight() {
@@ -29,7 +29,7 @@ export function fanfareFight() {
tone(659, 'square', 0.08, d, t + 0.08) tone(659, 'square', 0.08, d, t + 0.08)
tone(784, 'square', 0.15, d, t + 0.16) tone(784, 'square', 0.15, d, t + 0.16)
noise(0.1, 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() { export function fanfareDevastating() {
@@ -39,7 +39,7 @@ export function fanfareDevastating() {
tone(220, 'sawtooth', 0.2, d, t) tone(220, 'sawtooth', 0.2, d, t)
tone(175, 'sawtooth', 0.3, d, t + 0.15) tone(175, 'sawtooth', 0.3, d, t + 0.15)
noise(0.15, d, t + 0.1) noise(0.15, d, t + 0.1)
setTimeout(() => speak('Devastating!', 'deep', false, true), 150) audioTimeout(() => speak('Devastating!', 'deep', false, true), 150)
} }
export function fanfareCritical() { export function fanfareCritical() {
@@ -51,7 +51,7 @@ export function fanfareCritical() {
tone(659, 'square', 0.1, d, t + 0.16) tone(659, 'square', 0.1, d, t + 0.16)
tone(880, 'square', 0.2, d, t + 0.24) tone(880, 'square', 0.2, d, t + 0.24)
noise(0.12, 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) { export function fanfareCombo(count: number) {
@@ -61,7 +61,7 @@ export function fanfareCombo(count: number) {
for (let i = 0; i < Math.min(count, 5); i++) { for (let i = 0; i < Math.min(count, 5); i++) {
tone(440 + i * 80, 'square', 0.08, d, t + i * 0.06) 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 === // === MODERN SFX ===
@@ -104,7 +104,7 @@ export function sfxCritical() {
highSnap(5000, 0.03, d, t + 0.02) highSnap(5000, 0.03, d, t + 0.02)
fmImpact(200, 7, 0.25, d, t + 0.03) fmImpact(200, 7, 0.25, d, t + 0.03)
noise(0.15, d, t) noise(0.15, d, t)
setTimeout(() => { audioTimeout(() => {
bodyThump(90, 0.2, d) bodyThump(90, 0.2, d)
sweep(200, 600, 'sawtooth', 0.2, d) sweep(200, 600, 'sawtooth', 0.2, d)
noise(0.1, d) noise(0.1, d)
@@ -165,7 +165,7 @@ export function sfxExplosion() {
noise(0.2, d, t) noise(0.2, d, t)
sweep(300, 30, 'sawtooth', 0.4, d) sweep(300, 30, 'sawtooth', 0.4, d)
fmImpact(400, 5, 0.3, d, t + 0.05) fmImpact(400, 5, 0.3, d, t + 0.05)
setTimeout(() => { audioTimeout(() => {
bodyThump(50, 0.3, d) bodyThump(50, 0.3, d)
noise(0.15, d) noise(0.15, d)
}, 120) }, 120)
@@ -209,13 +209,13 @@ export function sfxKO() {
highSnap(2000, 0.06, d, t) highSnap(2000, 0.06, d, t)
noise(0.25, d, t) noise(0.25, d, t)
fmImpact(150, 5, 0.4, d, t) fmImpact(150, 5, 0.4, d, t)
setTimeout(() => { audioTimeout(() => {
bodyThump(40, 0.4, d) bodyThump(40, 0.4, d)
noise(0.15, d) noise(0.15, d)
highSnap(3000, 0.04, d) highSnap(3000, 0.04, d)
}, 180) }, 180)
reverbTail(1.0, d, t + 0.05) 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() { export function sfxPerfect() {
@@ -225,14 +225,14 @@ export function sfxPerfect() {
noise(0.3, d, t) noise(0.3, d, t)
fmImpact(300, 7, 0.5, d, t) fmImpact(300, 7, 0.5, d, t)
sweep(800, 30, 'sawtooth', 0.6, d) sweep(800, 30, 'sawtooth', 0.6, d)
setTimeout(() => { audioTimeout(() => {
tone(262, 'square', 0.2, d); tone(262, 'triangle', 0.2, d) 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(330, 'square', 0.2, d); tone(330, 'triangle', 0.2, d)
tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d) tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d)
fmImpact(523, 1.5, 0.3, d) fmImpact(523, 1.5, 0.3, d)
}, 400) }, 400)
reverbTail(1.2, d, t + 0.1) 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() { export function sfxWin() {
@@ -258,7 +258,7 @@ export function sfxWin() {
} }
export function sfxWinAnnounce(winnerName: string) { 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() { export function sfxRoundStart() {
@@ -280,8 +280,8 @@ export function sfxBoing() {
const c = getCtx(); const t = c.currentTime const c = getCtx(); const t = c.currentTime
sweep(200, 900, 'sine', 0.12, d) sweep(200, 900, 'sine', 0.12, d)
fmImpact(400, 3, 0.1, d, t) fmImpact(400, 3, 0.1, d, t)
setTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80) audioTimeout(() => { 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(400, 550, 'sine', 0.06, d), 140)
} }
export function sfxWomp() { export function sfxWomp() {
@@ -333,8 +333,8 @@ export function sfxZap() {
const c = getCtx(); const t = c.currentTime const c = getCtx(); const t = c.currentTime
sweep(100, 4000, 'sawtooth', 0.06, d) sweep(100, 4000, 'sawtooth', 0.06, d)
fmImpact(1000, 7, 0.1, d, t) fmImpact(1000, 7, 0.1, d, t)
setTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40) audioTimeout(() => { 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(600, 5000, 'sawtooth', 0.05, d), 80)
highSnap(6000, 0.03, d, t) highSnap(6000, 0.03, d, t)
} }
@@ -344,7 +344,7 @@ export function sfxZoomWhoosh() {
sweep(80, 1800, 'sawtooth', 0.2, d) sweep(80, 1800, 'sawtooth', 0.2, d)
sweep(100, 2200, 'sine', 0.18, d) sweep(100, 2200, 'sine', 0.18, d)
noise(0.15, d, t) noise(0.15, d, t)
setTimeout(() => { audioTimeout(() => {
sweep(1800, 150, 'sawtooth', 0.12, d) sweep(1800, 150, 'sawtooth', 0.12, d)
sweep(2200, 200, 'sine', 0.1, d) sweep(2200, 200, 'sine', 0.1, d)
}, 150) }, 150)
@@ -387,7 +387,7 @@ export function sfxFail() {
const c = getCtx(); const t = c.currentTime const c = getCtx(); const t = c.currentTime
sweep(600, 80, 'sawtooth', 0.25, d) sweep(600, 80, 'sawtooth', 0.25, d)
bodyThump(80, 0.2, d, t + 0.15, 0.15) 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) reverbTail(0.3, d, t + 0.2)
} }
@@ -463,7 +463,7 @@ export function sfxRecordScratch() {
sweep(2500, 80, 'square', 0.12, d) sweep(2500, 80, 'square', 0.12, d)
noise(0.1, d, t) noise(0.1, d, t)
highSnap(4000, 0.03, 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() { export function sfxRubberChicken() {
@@ -489,7 +489,7 @@ export function sfxSqueakyToy() {
const d = getSfxDest() const d = getSfxDest()
sweep(400, 1800, 'sine', 0.06, d) sweep(400, 1800, 'sine', 0.06, d)
sweep(1800, 600, 'sine', 0.1, 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() { export function sfxWetSlap() {
@@ -582,7 +582,7 @@ export function sfxWindowsError() {
tone(440, 'square', 0.15, d, t) tone(440, 'square', 0.15, d, t)
tone(466, 'square', 0.15, d, t) tone(466, 'square', 0.15, d, t)
tone(220, 'triangle', 0.2, d, t) tone(220, 'triangle', 0.2, d, t)
setTimeout(() => { audioTimeout(() => {
tone(349, 'square', 0.2, d) tone(349, 'square', 0.2, d)
tone(175, 'triangle', 0.25, d) tone(175, 'triangle', 0.25, d)
}, 180) }, 180)
@@ -657,7 +657,7 @@ export function sfxFailHorn() {
tone(311, 'sawtooth', 0.3, d, t) tone(311, 'sawtooth', 0.3, d, t)
tone(156, 'sawtooth', 0.3, d, t) tone(156, 'sawtooth', 0.3, d, t)
tone(233, 'square', 0.3, d, t) tone(233, 'square', 0.3, d, t)
setTimeout(() => { audioTimeout(() => {
tone(277, 'sawtooth', 0.5, d) tone(277, 'sawtooth', 0.5, d)
tone(139, 'sawtooth', 0.5, d) tone(139, 'sawtooth', 0.5, d)
tone(208, 'square', 0.5, d) tone(208, 'square', 0.5, d)
@@ -717,7 +717,7 @@ export function sfxMissionFailed() {
tone(196, 'sawtooth', 0.3, d, t) tone(196, 'sawtooth', 0.3, d, t)
tone(247, 'sawtooth', 0.3, d, t) tone(247, 'sawtooth', 0.3, d, t)
bodyThump(82, 0.2, d, t, 0.2) bodyThump(82, 0.2, d, t, 0.2)
setTimeout(() => { audioTimeout(() => {
tone(139, 'sawtooth', 0.6, d) tone(139, 'sawtooth', 0.6, d)
tone(165, 'sawtooth', 0.6, d) tone(165, 'sawtooth', 0.6, d)
tone(208, 'sawtooth', 0.6, d) tone(208, 'sawtooth', 0.6, d)
@@ -934,5 +934,5 @@ export function sfxModem(durationMs = 1800): Promise<void> {
tone(f, 'sine', 0.04, d, t + 1.4 + i * 0.08) 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))
} }
+4 -4
View File
@@ -1,6 +1,6 @@
// Voice profiles, speak, announce, TTS speech queue, Creator voice lines // 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 { tone, noise } from './primitives'
import { initKokoro, isKokoroReady, kokoroSpeak, kokoroSpeakAsync, kokoroStop, kokoroPrefetch, kokoroAwaitReady, kokoroPlayCached, setAudioContext } from '../tts' 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 (_speechQueueDepth === 0) _webSpeechActive = false
if (ev.error !== 'canceled' && ev.error !== 'interrupted') { if (ev.error !== 'canceled' && ev.error !== 'interrupted') {
if (!isKokoroReady()) { if (!isKokoroReady()) {
setTimeout(() => { audioTimeout(() => {
if (!getMasterMuted() && !isKokoroReady() && typeof speechSynthesis !== 'undefined') { if (!getMasterMuted() && !isKokoroReady() && typeof speechSynthesis !== 'undefined') {
const retry = new SpeechSynthesisUtterance(text) const retry = new SpeechSynthesisUtterance(text)
if (profile.voice) retry.voice = profile.voice 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) _speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
resolve() resolve()
} }
const safetyTimeout = setTimeout(cleanup, 8_000) const safetyTimeout = audioTimeout(cleanup, 8_000)
const startupCheck = setTimeout(() => { const startupCheck = audioTimeout(() => {
if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup() if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup()
}, 500) }, 500)
let keepalive: ReturnType<typeof setInterval> | null = null let keepalive: ReturnType<typeof setInterval> | null = null