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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 10:15:54 +00:00
co-authored by Claude Opus 4.6
parent e7d1f9b97b
commit cb8a1d50fe
5 changed files with 120 additions and 32 deletions
+5
View File
@@ -205,6 +205,11 @@ export {
prefetchQuestion,
prefetchAnswer,
prefetchNarration,
awaitQuestionReady,
awaitAnswerReady,
playQuestionNow,
playAnswerNow,
playNarrationNow,
} from './voice'
// music
+43 -1
View File
@@ -2,7 +2,7 @@
import { getCtx, getSfxDest, getMasterMuted, getSfxGain } from './context'
import { tone, noise } from './primitives'
import { initKokoro, isKokoroReady, kokoroSpeak, kokoroSpeakAsync, kokoroStop, kokoroPrefetch, setAudioContext } from '../tts'
import { initKokoro, isKokoroReady, kokoroSpeak, kokoroSpeakAsync, kokoroStop, kokoroPrefetch, kokoroAwaitReady, kokoroPlayCached, setAudioContext } from '../tts'
// === VOICE PROFILES ===
@@ -756,6 +756,48 @@ export function prefetchNarration(text: string) {
kokoroPrefetch(smartTruncate(text, 200), 'sportscaster')
}
// === AWAIT + PLAY-NOW pattern for tight sync ===
// Call awaitReady to block until audio is generated, then playNow fires instantly
export async function awaitQuestionReady(text: string): Promise<void> {
if (getMasterMuted()) return
const trimmed = smartTruncate(text, 200)
if (isKokoroReady()) await kokoroAwaitReady(trimmed, 'question_reader')
}
export async function awaitAnswerReady(botName: string, answer: string): Promise<void> {
if (getMasterMuted()) return
const trimmed = smartTruncate(answer, 200)
if (isKokoroReady()) await kokoroAwaitReady(trimmed, botVoiceKey(botName))
}
export function playQuestionNow(text: string): Promise<void> {
const trimmed = smartTruncate(text, 200)
return _playNowCore(trimmed, 'question_reader')
}
export function playAnswerNow(botName: string, answer: string): Promise<void> {
const trimmed = smartTruncate(answer, 200)
return _playNowCore(trimmed, botVoiceKey(botName), 1.15)
}
export function playNarrationNow(text: string): Promise<void> {
const trimmed = smartTruncate(text, 200)
return _playNowCore(trimmed, 'sportscaster', 1.2)
}
async function _playNowCore(text: string, profileName: string, rateOverride?: number): Promise<void> {
if (getMasterMuted()) return
const sfxGain = getSfxGain()
if (isKokoroReady() && sfxGain) {
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
await kokoroPlayCached(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
return
}
// Fallback to Web Speech
return _speakAsyncCore(text, profileName, rateOverride)
}
// Re-export TTS utilities needed by ensureAudioContext (in index.ts)
export { initKokoro, setAudioContext, loadVoices as _loadVoices }