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.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)
+59 -16
View File
@@ -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<void> {
return new Promise<void>((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<void> {
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<void> {
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<void> {
const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text
const trimmed = smartTruncate(text, 200)
return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
}