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:
co-authored by
Claude Opus 4.6
parent
896e81960f
commit
9287fd1783
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user