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
@@ -121,9 +121,12 @@ const _pending = new Map<number, {
|
||||
// --- Audio state (main thread only) ---
|
||||
let _audioCtx: AudioContext | null = null
|
||||
const audioCache = new Map<string, AudioBuffer>()
|
||||
const MAX_CACHE = 30
|
||||
const MAX_CACHE = 50
|
||||
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 {
|
||||
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<vo
|
||||
if (msg.type === 'init-done') {
|
||||
_workerReady = true
|
||||
_workerLoading = false
|
||||
_loadProgress = 100
|
||||
// Switch to persistent handler for generate responses
|
||||
_worker!.onmessage = _handleWorkerMessage
|
||||
resolve()
|
||||
} else if (msg.type === 'init-failed') {
|
||||
reject(new Error(msg.error))
|
||||
} else if (msg.type === 'progress' && onProgress) {
|
||||
onProgress(msg.progress)
|
||||
} else if (msg.type === 'progress') {
|
||||
_loadProgress = msg.progress
|
||||
if (onProgress) onProgress(msg.progress)
|
||||
}
|
||||
}
|
||||
_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 }> = [
|
||||
{ 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<AudioBu
|
||||
const cached = audioCache.get(key)
|
||||
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 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) {
|
||||
|
||||
Reference in New Issue
Block a user