fix: TTS sentence cutoff + simplified retro challenge prompt

TTS fixes:
- Remove 60-char truncation in FightViewer — let speakAnswer handle limits
- Smart truncation at sentence boundaries (period, comma, etc.) up to 200 chars
- Chrome keepalive: periodic pause/resume prevents silent 15s cutoff bug
- Deduplicated cleanup logic in speakAsync/speakAsyncWithRate

Retro mode:
- Simplified challenge prompt from verbose wall of text to clean 3-line format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 15:32:29 +00:00
co-authored by Claude Opus 4.6
parent 896e81960f
commit 9287fd1783
3 changed files with 62 additions and 19 deletions
+2 -2
View File
@@ -322,7 +322,7 @@ async function _doReplay() {
scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5) scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5)
scene.startTalking('a') 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) else await sleep(insanityMode.value ? 30 : 800)
scene?.stopTalking('a') scene?.stopTalking('a')
if (!insanityMode.value) await sleep(150) if (!insanityMode.value) await sleep(150)
@@ -337,7 +337,7 @@ async function _doReplay() {
scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5) scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5)
scene.startTalking('b') 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) else await sleep(insanityMode.value ? 30 : 800)
scene?.stopTalking('b') scene?.stopTalking('b')
if (!insanityMode.value) await sleep(150) if (!insanityMode.value) await sleep(150)
+59 -16
View File
@@ -347,7 +347,7 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
speechSynthesis.speak(utter) 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<void> { function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return } 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.rate = Math.max(minRate, profile.rate)
utter.volume = profile.volume * VOICE_VOLUME_SCALE utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++ _speechQueueDepth++
const safetyTimeout = setTimeout(resolve, 15_000) let done = false
utter.onend = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } const cleanup = () => {
utter.onerror = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } 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) speechSynthesis.speak(utter)
}) })
} }
@@ -381,14 +398,25 @@ function speakAsync(text: string, profileName: string, cancelPrevious: boolean =
utter.rate = profile.rate utter.rate = profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++ _speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } let done = false
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } const cleanup = () => {
// Safety timeout — never block forever (max 15s for any utterance) if (done) return
const safetyTimeout = setTimeout(resolve, 15_000) done = true
const origOnEnd = utter.onend clearTimeout(safetyTimeout)
utter.onend = (ev) => { clearTimeout(safetyTimeout); (origOnEnd as any)(ev) } clearInterval(keepalive)
const origOnError = utter.onerror _speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
utter.onerror = (ev) => { clearTimeout(safetyTimeout); (origOnError as any)(ev) } 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) speechSynthesis.speak(utter)
}) })
} }
@@ -820,22 +848,37 @@ function botVoiceKey(name: string): string {
* Does NOT cancel previous speech so intro/hype lines finish naturally. * Does NOT cancel previous speech so intro/hype lines finish naturally.
* Returns promise that resolves when the question finishes reading. */ * Returns promise that resolves when the question finishes reading. */
export function speakQuestion(text: string): Promise<void> { export function speakQuestion(text: string): Promise<void> {
const trimmed = text.length > 140 ? text.slice(0, 137) + '...' : text const trimmed = smartTruncate(text, 200)
return speakAsync(trimmed, 'question_reader', false) 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. /** Speak a bot's answer in their assigned character voice at brisk pace.
* Returns promise that resolves when finished. */ * Returns promise that resolves when finished. */
export function speakAnswer(botName: string, answer: string): Promise<void> { export function speakAnswer(botName: string, answer: string): Promise<void> {
const trimmed = answer.length > 80 ? answer.slice(0, 77) + '...' : answer const trimmed = smartTruncate(answer, 200)
// Use speakAsync with a rate boost: override profile rate to min 1.1
return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15) return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15)
} }
/** Speak the judge's narration — brisk sportscaster energy. /** Speak the judge's narration — brisk sportscaster energy.
* Returns promise that resolves when finished. */ * Returns promise that resolves when finished. */
export function speakNarration(text: string): Promise<void> { export function speakNarration(text: string): Promise<void> {
const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text const trimmed = smartTruncate(text, 200)
return speakAsyncWithRate(trimmed, 'sportscaster', 1.2) return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
} }
+1 -1
View File
@@ -122,7 +122,7 @@ export function generateRetroChallenge(): Challenge {
const moveList = knownMoves.map(m => ` ${m.input} = ${m.name} (${m.damage} dmg)`).join('\n') 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 { return {
type: 'retro_mode', type: 'retro_mode',