diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 220c9b4..1edec5d 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -322,7 +322,7 @@ async function _doReplay() { scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5) scene.startTalking('a') } - if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse.slice(0, 60)) + if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse) else await sleep(insanityMode.value ? 30 : 800) scene?.stopTalking('a') if (!insanityMode.value) await sleep(150) @@ -337,7 +337,7 @@ async function _doReplay() { scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5) scene.startTalking('b') } - if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse.slice(0, 60)) + if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse) else await sleep(insanityMode.value ? 30 : 800) scene?.stopTalking('b') if (!insanityMode.value) await sleep(150) diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 717db0a..7f9e5bd 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -347,7 +347,7 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals speechSynthesis.speak(utter) } -/** Like speakAsync but with a forced minimum rate */ +/** Like speakAsync but with a forced minimum rate + Chrome keepalive */ function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise { return new Promise((resolve) => { if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return } @@ -360,9 +360,26 @@ function speakAsyncWithRate(text: string, profileName: string, minRate: number): utter.rate = Math.max(minRate, profile.rate) utter.volume = profile.volume * VOICE_VOLUME_SCALE _speechQueueDepth++ - const safetyTimeout = setTimeout(resolve, 15_000) - utter.onend = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } - utter.onerror = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } + 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) }) } @@ -381,14 +398,25 @@ function speakAsync(text: string, profileName: string, cancelPrevious: boolean = utter.rate = profile.rate utter.volume = profile.volume * VOICE_VOLUME_SCALE _speechQueueDepth++ - utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } - utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } - // Safety timeout — never block forever (max 15s for any utterance) - const safetyTimeout = setTimeout(resolve, 15_000) - const origOnEnd = utter.onend - utter.onend = (ev) => { clearTimeout(safetyTimeout); (origOnEnd as any)(ev) } - const origOnError = utter.onerror - utter.onerror = (ev) => { clearTimeout(safetyTimeout); (origOnError as any)(ev) } + 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 keepalive: periodic pause/resume prevents silent cutoff + const keepalive = setInterval(() => { + if (speechSynthesis.speaking && !speechSynthesis.paused) { + speechSynthesis.pause() + speechSynthesis.resume() + } + }, 10_000) + utter.onend = cleanup + utter.onerror = cleanup speechSynthesis.speak(utter) }) } @@ -820,22 +848,37 @@ function botVoiceKey(name: string): string { * Does NOT cancel previous speech so intro/hype lines finish naturally. * Returns promise that resolves when the question finishes reading. */ export function speakQuestion(text: string): Promise { - const trimmed = text.length > 140 ? text.slice(0, 137) + '...' : text + const trimmed = smartTruncate(text, 200) return speakAsync(trimmed, 'question_reader', false) } +/** Truncate text at a natural sentence boundary (period, comma, semicolon, etc.) */ +function smartTruncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + const slice = text.slice(0, maxLen) + // Find the last natural break point + const breaks = ['. ', '! ', '? ', '; ', ', ', ' — ', ' - ', ' '] + for (const br of breaks) { + const idx = slice.lastIndexOf(br) + if (idx > maxLen * 0.4) return slice.slice(0, idx + br.trimEnd().length) + } + // Fallback: break at last space + const lastSpace = slice.lastIndexOf(' ') + if (lastSpace > maxLen * 0.4) return slice.slice(0, lastSpace) + return slice +} + /** Speak a bot's answer in their assigned character voice at brisk pace. * Returns promise that resolves when finished. */ export function speakAnswer(botName: string, answer: string): Promise { - const trimmed = answer.length > 80 ? answer.slice(0, 77) + '...' : answer - // Use speakAsync with a rate boost: override profile rate to min 1.1 + const trimmed = smartTruncate(answer, 200) return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15) } /** Speak the judge's narration — brisk sportscaster energy. * Returns promise that resolves when finished. */ export function speakNarration(text: string): Promise { - const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text + const trimmed = smartTruncate(text, 200) return speakAsyncWithRate(trimmed, 'sportscaster', 1.2) } diff --git a/server/src/engine/retro-moves.ts b/server/src/engine/retro-moves.ts index e3fad60..a263974 100644 --- a/server/src/engine/retro-moves.ts +++ b/server/src/engine/retro-moves.ts @@ -122,7 +122,7 @@ export function generateRetroChallenge(): Challenge { const moveList = knownMoves.map(m => ` ${m.input} = ${m.name} (${m.damage} dmg)`).join('\n') - const prompt = `RETRO MODE — ARCADE FIGHT!\n\nEnter 3 gamepad combos separated by |\nButtons: ↑ ↓ ← → A B\n\nKNOWN MOVES:\n${moveList}\n\nSECRET COMBOS exist! Longer button chains = more damage. Experiment!\n\nFormat: combo1 | combo2 | combo3\nExample: ↓→+A | B | →→+A` + const prompt = `ARCADE ROUND! Pick 3 moves:\n${moveList}\n\nSecret combos exist — longer chains hit harder.\nAnswer: combo1 | combo2 | combo3` return { type: 'retro_mode',