perf: cache Kokoro TTS model in service worker

Add CacheFirst runtimeCaching rules for Hugging Face model requests
(huggingface.co and cdn-lfs) with 90-day expiration. Add
getKokoroProgress() export to tts.ts for tracking download progress.
Add TTS model download progress bar overlay in FightViewer.vue that
shows during model loading and auto-hides when complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 20:49:00 +00:00
co-authored by Claude Opus 4.6
parent a04125afb3
commit 74939e995d
3 changed files with 93 additions and 15 deletions
+40 -9
View File
@@ -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<typeof setTimeout> | null = null
let destroyed = false
const isReplaying = ref(false)
const ttsProgress = ref(-1)
let ttsProgressTimer: ReturnType<typeof setInterval> | 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() {
<!-- Canvas + floating overlays -->
<div ref="canvasContainer" class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
<canvas ref="canvasRef" class="w-full h-full block" />
<!-- TTS model download progress -->
<div v-if="ttsProgress >= 0 && ttsProgress < 100" class="absolute bottom-1 left-2 right-2 z-30">
<div class="bg-black/60 rounded px-2 py-1 flex items-center gap-2">
<span class="text-[10px] text-text-muted font-mono whitespace-nowrap">TTS {{ Math.round(ttsProgress) }}%</span>
<div class="flex-1 h-1 bg-white/10 rounded overflow-hidden">
<div class="h-full bg-neon-green transition-all duration-200" :style="{ width: ttsProgress + '%' }" />
</div>
</div>
</div>
<!-- Floating announcement -->
<Transition name="announce">