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,
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<void>(r => setTimeout(r, 8000)),
new Promise<void>(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()
+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 }
+1
View File
@@ -91,6 +91,7 @@ export {
setMusicVolume,
setSfxVolume,
resetGainNodes,
clearAllAudioTimers,
MUSIC_VOL,
SFX_VOL,
} from './context'
+25 -25
View File
@@ -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<void> {
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
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<typeof setInterval> | null = null