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.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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user