diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 0123b4b..e008e3c 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -12,6 +12,7 @@ import { prefetchQuestion, prefetchAnswer, prefetchNarration, sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, } from '../game/sounds' +import { isKokoroLoading, getKokoroProgress } from '../game/tts' interface Round { roundNumber: number @@ -53,6 +54,8 @@ let cleanupTimerHandle: ReturnType | null = null let destroyed = false const isReplaying = ref(false) +const ttsProgress = ref(-1) +let ttsProgressTimer: ReturnType | null = null const displayHpA = ref(100) const displayHpB = ref(100) const currentRound = ref(0) @@ -181,6 +184,7 @@ onUnmounted(() => { sceneReady.value = false if (scene) { scene.destroy(); scene = null } if (cleanupTimerHandle) { clearTimeout(cleanupTimerHandle); cleanupTimerHandle = null } + if (ttsProgressTimer) { clearInterval(ttsProgressTimer); ttsProgressTimer = null } if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() }) @@ -321,10 +325,18 @@ async function replay() { if (isReplaying.value || !props.fight.botA || !props.fight.botB) return isReplaying.value = true showingFinal.value = false + // Poll TTS model download progress while loading + ttsProgressTimer = setInterval(() => { + ttsProgress.value = isKokoroLoading() ? getKokoroProgress() : -1 + }, 200) try { await _doReplay() } catch (e) { if (e instanceof Error && e.message === 'unmounted') return console.error('[FightViewer] replay error:', e) - } finally { isReplaying.value = false } + } finally { + isReplaying.value = false + if (ttsProgressTimer) { clearInterval(ttsProgressTimer); ttsProgressTimer = null } + ttsProgress.value = -1 + } } async function _doReplay() { @@ -361,10 +373,18 @@ async function _doReplay() { for (const round of props.fight.rounds) { currentRound.value = round.roundNumber + // Parse challenge data EARLY so we can prefetch TTS during overlays + const challenge = JSON.parse(round.challengeData) + const doTTS = soundOn.value && !insanityMode.value + const questionText = challenge.displayPrompt || challenge.prompt + if (insanityMode.value) { // Insanity: minimal overlays, no fanfares await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150) } else { + // Prefetch question audio NOW — worker generates while overlays play (~1.8s) + if (doTTS && questionText) prefetchQuestion(questionText) + fanfareRound(round.roundNumber) await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700) await sleep(80) @@ -375,27 +395,25 @@ async function _doReplay() { await sleep(80) } - // === TTS-synced question + answer flow === - const challenge = JSON.parse(round.challengeData) - const doTTS = soundOn.value && !insanityMode.value - // Both fighters showboat while the question is being asked if (scene && !insanityMode.value) { scene.startShowboating('a') scene.startShowboating('b') } - // 1. Show question in log AND speak it + // 1. Show question in log AND speak it (audio should be pre-generated by now) 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: challenge.displayPrompt || challenge.prompt, color: 'text-muted' }, + { type: 'prompt', round: round.roundNumber, text: questionText, color: 'text-muted' }, ) scrollLog() - if (doTTS && (challenge.displayPrompt || challenge.prompt)) { - await speakQuestion(challenge.displayPrompt || challenge.prompt) + 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) } else { await sleep(insanityMode.value ? 50 : 600) @@ -412,6 +430,8 @@ async function _doReplay() { scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5) 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) scene?.stopTalking('a') @@ -429,6 +449,8 @@ async function _doReplay() { 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) scene?.stopTalking('b') @@ -705,6 +727,15 @@ async function _doReplay() {
+ +
+
+ TTS {{ Math.round(ttsProgress) }}% +
+
+
+
+
diff --git a/frontend/src/game/tts.ts b/frontend/src/game/tts.ts index 5bfc4f1..5c307be 100644 --- a/frontend/src/game/tts.ts +++ b/frontend/src/game/tts.ts @@ -121,9 +121,12 @@ const _pending = new Map() -const MAX_CACHE = 30 +const MAX_CACHE = 50 const activeSources: Set = new Set() +// In-flight generation dedup: cache key → promise (prevents duplicate worker calls) +const _inflight = new Map>() + function getAudioCtx(): AudioContext { if (!_audioCtx) _audioCtx = new AudioContext() if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {}) @@ -145,6 +148,12 @@ export function isKokoroLoading(): boolean { return _workerLoading } +/** Current model download progress (0-100), or -1 if not loading */ +let _loadProgress = -1 +export function getKokoroProgress(): number { + return _loadProgress +} + /** Handle messages from the TTS worker */ function _handleWorkerMessage(e: MessageEvent) { const msg = e.data @@ -177,13 +186,15 @@ export async function initKokoro(onProgress?: (pct: number) => void): Promise { @@ -202,11 +213,18 @@ export async function initKokoro(onProgress?: (pct: number) => void): Promise = [ - { text: 'Round one!', profile: 'announcer' }, + { text: 'Round 1', profile: 'announcer' }, + { text: 'Round 2', profile: 'announcer' }, + { text: 'Round 3', profile: 'announcer' }, + { text: 'Round 4', profile: 'announcer' }, + { text: 'Round 5', profile: 'announcer' }, { text: 'Fight!', profile: 'announcer' }, { text: 'K. O.!', profile: 'announcer' }, + { text: 'Finish it!', profile: 'announcer' }, + { text: 'Flawless victory!', profile: 'deep' }, ] async function _precacheCommon() { @@ -249,10 +267,23 @@ async function _generateAndCache(text: string, profile: string): Promise { const mapping = VOICE_MAP[profile] || DEFAULT_VOICE const raw = await _workerGenerate(text, mapping.voice, mapping.speed) - // Convert Float32Array → AudioBuffer (lightweight, main thread) const ctx = getAudioCtx() const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate) buf.getChannelData(0).set(raw.audio) @@ -334,6 +365,12 @@ export function kokoroSpeak( return true } +/** Pre-generate audio in the worker so it's cached when needed. Fire-and-forget. */ +export function kokoroPrefetch(text: string, profileName: string): void { + if (!_workerReady) return + _generateAndCache(text, profileName).catch(() => {}) +} + /** Stop all currently playing kokoro audio */ export function kokoroStop() { for (const src of activeSources) { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f18eca1..79d42c0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -58,6 +58,16 @@ export default defineConfig({ method: 'GET', options: { cacheName: 'api-cache' }, }, + { + urlPattern: /^https:\/\/huggingface\.co\/.*Kokoro.*\.onnx/i, + handler: 'CacheFirst', + options: { cacheName: 'kokoro-model', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 90 } }, + }, + { + urlPattern: /^https:\/\/cdn-lfs.*\.huggingface\.co\/.*/i, + handler: 'CacheFirst', + options: { cacheName: 'kokoro-model-lfs', expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 90 } }, + }, ], }, }),