From cb8a1d50fef585cf041c0bb7bc469f752beb3769 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 10:15:54 +0000 Subject: [PATCH] feat: sync TTS voice with chat bubbles and battle log for zero-gap playback Adds await-then-play pattern: audio is pre-generated and cached before visuals appear, so log text + speech bubble + mouth animation + voice all fire in the same frame. Prefetches both answers during question playback for instant transitions. Adds hideSpeechBubble() to dismiss bubbles when voice ends instead of fixed 5s timer. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/FightViewer.vue | 62 ++++++++++++------------- frontend/src/game/FightScene.ts | 14 ++++++ frontend/src/game/audio/index.ts | 5 ++ frontend/src/game/audio/voice.ts | 44 +++++++++++++++++- frontend/src/game/tts.ts | 27 +++++++++++ 5 files changed, 120 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 37ccc3d..9e06981 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -10,6 +10,8 @@ import { setMasterMute, isMasterMuted, ensureAudioContext, speakQuestion, speakAnswer, speakNarration, prefetchQuestion, prefetchAnswer, prefetchNarration, + awaitQuestionReady, awaitAnswerReady, + playQuestionNow, playAnswerNow, playNarrationNow, sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, } from '../game/audio' import { isKokoroLoading, getKokoroProgress } from '../game/tts' @@ -400,60 +402,61 @@ async function _doReplay() { scene.startShowboating('b') } - // 1. Show question in log AND speak it (audio should be pre-generated by now) + // 1. Ensure question audio is ready BEFORE showing anything (zero-gap) + if (doTTS && questionText) { + // Kick off answer prefetches early so they generate in parallel + if (round.botAResponse) prefetchAnswer(props.fight.botA!.name, round.botAResponse) + if (round.botBResponse) prefetchAnswer(props.fight.botB!.name, round.botBResponse) + await awaitQuestionReady(questionText) + } + // Show question text + play voice at the exact same moment logItems.value.push( { type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' }, ) - scrollLog(); await sleep(insanityMode.value ? 30 : 100) logItems.value.push( { type: 'prompt', round: round.roundNumber, text: questionText, color: 'text-muted' }, ) scrollLog() if (doTTS && questionText) { - // Prefetch bot A answer while question plays - if (round.botAResponse) prefetchAnswer(props.fight.botA!.name, round.botAResponse) - await speakQuestion(questionText) - await sleep(150) + await playQuestionNow(questionText) } else { - await sleep(insanityMode.value ? 50 : 600) + await sleep(insanityMode.value ? 50 : 300) } - // 2. Bot A: log + bubble + mouth + TTS (all synced) - // Stop A's showboat when it's their turn to talk, B keeps showboating + // 2. Bot A: ensure audio ready, then show log + bubble + mouth + voice all at once if (round.botAResponse) { + if (doTTS) await awaitAnswerReady(props.fight.botA!.name, round.botAResponse) scene?.stopShowboating('a') logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' }) scrollLog() if (scene && !insanityMode.value) { - scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5) + scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 8) scene.startTalking('a') } - // Prefetch bot B answer while bot A talks - if (doTTS && round.botBResponse) prefetchAnswer(props.fight.botB!.name, round.botBResponse) - if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse) - else await sleep(insanityMode.value ? 30 : 800) + if (doTTS) await playAnswerNow(props.fight.botA!.name, round.botAResponse) + else await sleep(insanityMode.value ? 30 : 400) scene?.stopTalking('a') - if (!insanityMode.value) await sleep(150) + scene?.hideSpeechBubble('a') } - // 3. Bot B: log + bubble + mouth + TTS - // Stop B's showboat when it's their turn to talk + // 3. Bot B: ensure audio ready, then show log + bubble + mouth + voice all at once if (round.botBResponse) { + if (doTTS) await awaitAnswerReady(props.fight.botB!.name, round.botBResponse) scene?.stopShowboating('b') logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' }) scrollLog() - if (scene && !insanityMode.value) { - scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5) - scene.startTalking('b') - } // Prefetch narration while bot B talks if (doTTS && round.narration) prefetchNarration(round.narration) - if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse) - else await sleep(insanityMode.value ? 30 : 800) + if (scene && !insanityMode.value) { + scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 8) + scene.startTalking('b') + } + if (doTTS) await playAnswerNow(props.fight.botB!.name, round.botBResponse) + else await sleep(insanityMode.value ? 30 : 400) scene?.stopTalking('b') - if (!insanityMode.value) await sleep(150) + scene?.hideSpeechBubble('b') } // Stop all showboating before fight animation @@ -815,14 +818,11 @@ async function _doReplay() {