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:
co-authored by
Claude Opus 4.6
parent
9287fd1783
commit
6561ef5d15
+37
-48
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user