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:
co-authored by
Claude Opus 4.6
parent
e7d1f9b97b
commit
cb8a1d50fe
@@ -734,6 +734,19 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}, duration * 1000)
|
||||
}
|
||||
|
||||
function hideSpeechBubble(side: 'a' | 'b') {
|
||||
const els = side === 'a' ? activeBubbleA : activeBubbleB
|
||||
if (!els.length) return
|
||||
els.forEach(el => {
|
||||
if (!el.exists()) return
|
||||
k.tween(el.opacity, 0, 0.25, (v) => { el.opacity = v }).then(() => {
|
||||
if (el.exists()) el.destroy()
|
||||
})
|
||||
})
|
||||
if (side === 'a') activeBubbleA = []
|
||||
else activeBubbleB = []
|
||||
}
|
||||
|
||||
function wrapBubbleText(text: string, maxChars: number): string[] {
|
||||
const words = text.split(' ')
|
||||
const lines: string[] = []
|
||||
@@ -1195,6 +1208,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) {
|
||||
showSpeechBubble(side, text, duration)
|
||||
},
|
||||
hideSpeechBubble(side: 'a' | 'b') { hideSpeechBubble(side) },
|
||||
|
||||
startTalking(side: 'a' | 'b') { startTalking(side) },
|
||||
stopTalking(side: 'a' | 'b') { stopTalking(side) },
|
||||
|
||||
@@ -205,6 +205,11 @@ export {
|
||||
prefetchQuestion,
|
||||
prefetchAnswer,
|
||||
prefetchNarration,
|
||||
awaitQuestionReady,
|
||||
awaitAnswerReady,
|
||||
playQuestionNow,
|
||||
playAnswerNow,
|
||||
playNarrationNow,
|
||||
} from './voice'
|
||||
|
||||
// music
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -376,6 +376,33 @@ export function kokoroPrefetch(text: string, profileName: string): void {
|
||||
_generateAndCache(text, profileName).catch(() => {})
|
||||
}
|
||||
|
||||
/** Await until prefetched audio is cached and ready. Returns true if cached, false on failure. */
|
||||
export async function kokoroAwaitReady(text: string, profileName: string): Promise<boolean> {
|
||||
if (!_workerReady) return false
|
||||
try {
|
||||
const buf = await _generateAndCache(text, profileName)
|
||||
return !!buf
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
/** Play already-cached audio immediately. Returns playback promise. If not cached, returns resolved. */
|
||||
export function kokoroPlayCached(
|
||||
text: string,
|
||||
profileName: string,
|
||||
dest: AudioNode,
|
||||
volume: number = 0.7,
|
||||
): Promise<void> {
|
||||
if (!_workerReady) return Promise.resolve()
|
||||
const key = _cacheKey(text, profileName)
|
||||
const cached = audioCache.get(key)
|
||||
if (cached) {
|
||||
cached.lastAccess = Date.now()
|
||||
return _playBuffer(cached.buf, dest, volume)
|
||||
}
|
||||
// Not cached — fall through to generate+play
|
||||
return kokoroSpeakAsync(text, profileName, dest, volume).then(() => {})
|
||||
}
|
||||
|
||||
/** Stop all currently playing kokoro audio */
|
||||
export function kokoroStop() {
|
||||
for (const src of activeSources) {
|
||||
|
||||
Reference in New Issue
Block a user