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:
co-authored by
Claude Opus 4.6
parent
a04125afb3
commit
74939e995d
@@ -12,6 +12,7 @@ import {
|
|||||||
prefetchQuestion, prefetchAnswer, prefetchNarration,
|
prefetchQuestion, prefetchAnswer, prefetchNarration,
|
||||||
sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage,
|
sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage,
|
||||||
} from '../game/sounds'
|
} from '../game/sounds'
|
||||||
|
import { isKokoroLoading, getKokoroProgress } from '../game/tts'
|
||||||
|
|
||||||
interface Round {
|
interface Round {
|
||||||
roundNumber: number
|
roundNumber: number
|
||||||
@@ -53,6 +54,8 @@ let cleanupTimerHandle: ReturnType<typeof setTimeout> | null = null
|
|||||||
let destroyed = false
|
let destroyed = false
|
||||||
|
|
||||||
const isReplaying = ref(false)
|
const isReplaying = ref(false)
|
||||||
|
const ttsProgress = ref(-1)
|
||||||
|
let ttsProgressTimer: ReturnType<typeof setInterval> | null = null
|
||||||
const displayHpA = ref(100)
|
const displayHpA = ref(100)
|
||||||
const displayHpB = ref(100)
|
const displayHpB = ref(100)
|
||||||
const currentRound = ref(0)
|
const currentRound = ref(0)
|
||||||
@@ -181,6 +184,7 @@ onUnmounted(() => {
|
|||||||
sceneReady.value = false
|
sceneReady.value = false
|
||||||
if (scene) { scene.destroy(); scene = null }
|
if (scene) { scene.destroy(); scene = null }
|
||||||
if (cleanupTimerHandle) { clearTimeout(cleanupTimerHandle); cleanupTimerHandle = null }
|
if (cleanupTimerHandle) { clearTimeout(cleanupTimerHandle); cleanupTimerHandle = null }
|
||||||
|
if (ttsProgressTimer) { clearInterval(ttsProgressTimer); ttsProgressTimer = null }
|
||||||
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -321,10 +325,18 @@ async function replay() {
|
|||||||
if (isReplaying.value || !props.fight.botA || !props.fight.botB) return
|
if (isReplaying.value || !props.fight.botA || !props.fight.botB) return
|
||||||
isReplaying.value = true
|
isReplaying.value = true
|
||||||
showingFinal.value = false
|
showingFinal.value = false
|
||||||
|
// Poll TTS model download progress while loading
|
||||||
|
ttsProgressTimer = setInterval(() => {
|
||||||
|
ttsProgress.value = isKokoroLoading() ? getKokoroProgress() : -1
|
||||||
|
}, 200)
|
||||||
try { await _doReplay() } catch (e) {
|
try { await _doReplay() } catch (e) {
|
||||||
if (e instanceof Error && e.message === 'unmounted') return
|
if (e instanceof Error && e.message === 'unmounted') return
|
||||||
console.error('[FightViewer] replay error:', e)
|
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() {
|
async function _doReplay() {
|
||||||
@@ -361,10 +373,18 @@ async function _doReplay() {
|
|||||||
for (const round of props.fight.rounds) {
|
for (const round of props.fight.rounds) {
|
||||||
currentRound.value = round.roundNumber
|
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) {
|
if (insanityMode.value) {
|
||||||
// Insanity: minimal overlays, no fanfares
|
// Insanity: minimal overlays, no fanfares
|
||||||
await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150)
|
await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150)
|
||||||
} else {
|
} else {
|
||||||
|
// Prefetch question audio NOW — worker generates while overlays play (~1.8s)
|
||||||
|
if (doTTS && questionText) prefetchQuestion(questionText)
|
||||||
|
|
||||||
fanfareRound(round.roundNumber)
|
fanfareRound(round.roundNumber)
|
||||||
await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
|
await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
|
||||||
await sleep(80)
|
await sleep(80)
|
||||||
@@ -375,27 +395,25 @@ async function _doReplay() {
|
|||||||
await sleep(80)
|
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
|
// Both fighters showboat while the question is being asked
|
||||||
if (scene && !insanityMode.value) {
|
if (scene && !insanityMode.value) {
|
||||||
scene.startShowboating('a')
|
scene.startShowboating('a')
|
||||||
scene.startShowboating('b')
|
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(
|
logItems.value.push(
|
||||||
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
|
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
|
||||||
)
|
)
|
||||||
scrollLog(); await sleep(insanityMode.value ? 30 : 100)
|
scrollLog(); await sleep(insanityMode.value ? 30 : 100)
|
||||||
logItems.value.push(
|
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()
|
scrollLog()
|
||||||
if (doTTS && (challenge.displayPrompt || challenge.prompt)) {
|
if (doTTS && questionText) {
|
||||||
await speakQuestion(challenge.displayPrompt || challenge.prompt)
|
// Prefetch bot A answer while question plays
|
||||||
|
if (round.botAResponse) prefetchAnswer(props.fight.botA!.name, round.botAResponse)
|
||||||
|
await speakQuestion(questionText)
|
||||||
await sleep(150)
|
await sleep(150)
|
||||||
} else {
|
} else {
|
||||||
await sleep(insanityMode.value ? 50 : 600)
|
await sleep(insanityMode.value ? 50 : 600)
|
||||||
@@ -412,6 +430,8 @@ async function _doReplay() {
|
|||||||
scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5)
|
scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5)
|
||||||
scene.startTalking('a')
|
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)
|
if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse)
|
||||||
else await sleep(insanityMode.value ? 30 : 800)
|
else await sleep(insanityMode.value ? 30 : 800)
|
||||||
scene?.stopTalking('a')
|
scene?.stopTalking('a')
|
||||||
@@ -429,6 +449,8 @@ async function _doReplay() {
|
|||||||
scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5)
|
scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5)
|
||||||
scene.startTalking('b')
|
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)
|
if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse)
|
||||||
else await sleep(insanityMode.value ? 30 : 800)
|
else await sleep(insanityMode.value ? 30 : 800)
|
||||||
scene?.stopTalking('b')
|
scene?.stopTalking('b')
|
||||||
@@ -705,6 +727,15 @@ async function _doReplay() {
|
|||||||
<!-- Canvas + floating overlays -->
|
<!-- Canvas + floating overlays -->
|
||||||
<div ref="canvasContainer" class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
|
<div ref="canvasContainer" class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
|
||||||
<canvas ref="canvasRef" class="w-full h-full block" />
|
<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 -->
|
<!-- Floating announcement -->
|
||||||
<Transition name="announce">
|
<Transition name="announce">
|
||||||
|
|||||||
@@ -121,9 +121,12 @@ const _pending = new Map<number, {
|
|||||||
// --- Audio state (main thread only) ---
|
// --- Audio state (main thread only) ---
|
||||||
let _audioCtx: AudioContext | null = null
|
let _audioCtx: AudioContext | null = null
|
||||||
const audioCache = new Map<string, AudioBuffer>()
|
const audioCache = new Map<string, AudioBuffer>()
|
||||||
const MAX_CACHE = 30
|
const MAX_CACHE = 50
|
||||||
const activeSources: Set<AudioBufferSourceNode> = new Set()
|
const activeSources: Set<AudioBufferSourceNode> = new Set()
|
||||||
|
|
||||||
|
// In-flight generation dedup: cache key → promise (prevents duplicate worker calls)
|
||||||
|
const _inflight = new Map<string, Promise<AudioBuffer | null>>()
|
||||||
|
|
||||||
function getAudioCtx(): AudioContext {
|
function getAudioCtx(): AudioContext {
|
||||||
if (!_audioCtx) _audioCtx = new AudioContext()
|
if (!_audioCtx) _audioCtx = new AudioContext()
|
||||||
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {})
|
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {})
|
||||||
@@ -145,6 +148,12 @@ export function isKokoroLoading(): boolean {
|
|||||||
return _workerLoading
|
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 */
|
/** Handle messages from the TTS worker */
|
||||||
function _handleWorkerMessage(e: MessageEvent) {
|
function _handleWorkerMessage(e: MessageEvent) {
|
||||||
const msg = e.data
|
const msg = e.data
|
||||||
@@ -177,13 +186,15 @@ export async function initKokoro(onProgress?: (pct: number) => void): Promise<vo
|
|||||||
if (msg.type === 'init-done') {
|
if (msg.type === 'init-done') {
|
||||||
_workerReady = true
|
_workerReady = true
|
||||||
_workerLoading = false
|
_workerLoading = false
|
||||||
|
_loadProgress = 100
|
||||||
// Switch to persistent handler for generate responses
|
// Switch to persistent handler for generate responses
|
||||||
_worker!.onmessage = _handleWorkerMessage
|
_worker!.onmessage = _handleWorkerMessage
|
||||||
resolve()
|
resolve()
|
||||||
} else if (msg.type === 'init-failed') {
|
} else if (msg.type === 'init-failed') {
|
||||||
reject(new Error(msg.error))
|
reject(new Error(msg.error))
|
||||||
} else if (msg.type === 'progress' && onProgress) {
|
} else if (msg.type === 'progress') {
|
||||||
onProgress(msg.progress)
|
_loadProgress = msg.progress
|
||||||
|
if (onProgress) onProgress(msg.progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_worker!.onerror = (e) => {
|
_worker!.onerror = (e) => {
|
||||||
@@ -202,11 +213,18 @@ export async function initKokoro(onProgress?: (pct: number) => void): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common phrases to pre-generate so they play instantly
|
// Common phrases to pre-generate so they play instantly.
|
||||||
|
// Must match EXACT text sent by fanfareRound/fanfareFight/announce calls.
|
||||||
const PRECACHE_PHRASES: Array<{ text: string; profile: string }> = [
|
const PRECACHE_PHRASES: Array<{ text: string; profile: string }> = [
|
||||||
{ 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: 'Fight!', profile: 'announcer' },
|
||||||
{ text: 'K. O.!', profile: 'announcer' },
|
{ text: 'K. O.!', profile: 'announcer' },
|
||||||
|
{ text: 'Finish it!', profile: 'announcer' },
|
||||||
|
{ text: 'Flawless victory!', profile: 'deep' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function _precacheCommon() {
|
async function _precacheCommon() {
|
||||||
@@ -249,10 +267,23 @@ async function _generateAndCache(text: string, profile: string): Promise<AudioBu
|
|||||||
const cached = audioCache.get(key)
|
const cached = audioCache.get(key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
|
// Dedup: if already generating this exact audio, reuse the in-flight promise
|
||||||
|
const existing = _inflight.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const promise = _doGenerate(text, profile, key)
|
||||||
|
_inflight.set(key, promise)
|
||||||
|
try {
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
_inflight.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doGenerate(text: string, profile: string, key: string): Promise<AudioBuffer | null> {
|
||||||
const mapping = VOICE_MAP[profile] || DEFAULT_VOICE
|
const mapping = VOICE_MAP[profile] || DEFAULT_VOICE
|
||||||
const raw = await _workerGenerate(text, mapping.voice, mapping.speed)
|
const raw = await _workerGenerate(text, mapping.voice, mapping.speed)
|
||||||
|
|
||||||
// Convert Float32Array → AudioBuffer (lightweight, main thread)
|
|
||||||
const ctx = getAudioCtx()
|
const ctx = getAudioCtx()
|
||||||
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate)
|
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate)
|
||||||
buf.getChannelData(0).set(raw.audio)
|
buf.getChannelData(0).set(raw.audio)
|
||||||
@@ -334,6 +365,12 @@ export function kokoroSpeak(
|
|||||||
return true
|
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 */
|
/** Stop all currently playing kokoro audio */
|
||||||
export function kokoroStop() {
|
export function kokoroStop() {
|
||||||
for (const src of activeSources) {
|
for (const src of activeSources) {
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ export default defineConfig({
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
options: { cacheName: 'api-cache' },
|
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 } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user