fix: mobile fight playback — prevent sprite loading hangs, TTS stalls, unlock audio from gesture

- Unified speakAsync/speakAsyncWithRate into _speakAsyncCore with:
  - 500ms startup check: bail immediately if speech won't start (mobile/no gesture)
  - 8s safety timeout (down from 15s) to prevent blocking
  - iOS-safe keepalive: only do Chrome pause/resume workaround on desktop Chrome
  - Immediate bail when no voices loaded
- Sprite loading: 5s timeout per sprite prevents mobile hangs from stuck Image decodes
- Scene creation: 10s timeout in FightViewer so overlay/voice flow continues even if
  canvas fails on mobile
- playRound: bail gracefully if fighter sprites missing instead of crashing
- Global audio unlock: first touch/click on site unlocks AudioContext + SpeechSynthesis
- Practice button pre-unlocks audio while still in user gesture context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 15:54:58 +00:00
co-authored by Claude Opus 4.6
parent 9287fd1783
commit 6561ef5d15
5 changed files with 97 additions and 65 deletions
+37 -48
View File
@@ -347,55 +347,24 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
speechSynthesis.speak(utter)
}
/** Like speakAsync but with a forced minimum rate + Chrome keepalive */
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
return new Promise<void>((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
if (!voicesLoaded) loadVoices()
if (speechSynthesis.paused) speechSynthesis.resume()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const utter = new SpeechSynthesisUtterance(text)
if (profile.voice) utter.voice = profile.voice
utter.pitch = profile.pitch
utter.rate = Math.max(minRate, profile.rate)
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
let done = false
const cleanup = () => {
if (done) return
done = true
clearTimeout(safetyTimeout)
clearInterval(keepalive)
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
resolve()
}
const safetyTimeout = setTimeout(cleanup, 15_000)
// Chrome bug workaround: speechSynthesis silently kills utterances after ~15s.
// Periodic pause/resume resets Chrome's internal timer.
const keepalive = setInterval(() => {
if (speechSynthesis.speaking && !speechSynthesis.paused) {
speechSynthesis.pause()
speechSynthesis.resume()
}
}, 10_000)
utter.onend = cleanup
utter.onerror = cleanup
speechSynthesis.speak(utter)
})
}
// iOS Safari breaks with pause()/resume() — only do keepalive on desktop Chrome
const _isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent)
const _isDesktopChrome = typeof navigator !== 'undefined' && /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent)
/** Like speak() but returns a promise that resolves when the utterance finishes */
function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise<void> {
/** Core async speak — resolves when speech finishes or bails fast if speech won't work */
function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise<void> {
return new Promise<void>((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
if (!voicesLoaded) loadVoices()
// No voices available = speech won't work, bail immediately
if (!voicesLoaded) { resolve(); return }
if (speechSynthesis.paused) speechSynthesis.resume()
if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const utter = new SpeechSynthesisUtterance(text)
if (profile.voice) utter.voice = profile.voice
utter.pitch = profile.pitch
utter.rate = profile.rate
utter.rate = rateOverride !== undefined ? Math.max(rateOverride, profile.rate) : profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
let done = false
@@ -403,24 +372,44 @@ function speakAsync(text: string, profileName: string, cancelPrevious: boolean =
if (done) return
done = true
clearTimeout(safetyTimeout)
clearInterval(keepalive)
clearTimeout(startupCheck)
if (keepalive) clearInterval(keepalive)
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
resolve()
}
const safetyTimeout = setTimeout(cleanup, 15_000)
// Chrome keepalive: periodic pause/resume prevents silent cutoff
const keepalive = setInterval(() => {
if (speechSynthesis.speaking && !speechSynthesis.paused) {
speechSynthesis.pause()
speechSynthesis.resume()
}
}, 10_000)
// Hard safety cap — never block longer than 8s
const safetyTimeout = setTimeout(cleanup, 8_000)
// Fast bail: if speech hasn't started within 500ms, it's not going to work (mobile/no gesture)
const startupCheck = setTimeout(() => {
if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup()
}, 500)
// Desktop Chrome keepalive: periodic pause/resume prevents Chrome's 15s silent cutoff
// Do NOT do this on iOS — it permanently kills speech on Safari
let keepalive: ReturnType<typeof setInterval> | null = null
if (_isDesktopChrome) {
keepalive = setInterval(() => {
if (speechSynthesis.speaking && !speechSynthesis.paused) {
speechSynthesis.pause()
speechSynthesis.resume()
}
}, 10_000)
}
utter.onend = cleanup
utter.onerror = cleanup
speechSynthesis.speak(utter)
})
}
/** Like speakAsync but with a forced minimum rate */
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
return _speakAsyncCore(text, profileName, minRate)
}
/** Like speak() but returns a promise that resolves when the utterance finishes */
function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise<void> {
return _speakAsyncCore(text, profileName, undefined, cancelPrevious)
}
export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()