diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 4c2c680..5853014 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -441,8 +441,11 @@ async function _doReplay() { scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 8) scene.startTalking('a') } + // Ensure bubble stays visible for at least 400ms even if TTS resolves instantly + const minBubbleA = sleep(400).catch(() => {}) if (doTTS) await playAnswerNow(props.fight.botA!.name, round.botAResponse) - else await sleep(insanityMode.value ? 30 : 400) + await minBubbleA + if (!doTTS && !insanityMode.value) await sleep(400) scene?.stopTalking('a') scene?.hideSpeechBubble('a') } @@ -460,8 +463,10 @@ async function _doReplay() { scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 8) scene.startTalking('b') } + const minBubbleB = sleep(400).catch(() => {}) if (doTTS) await playAnswerNow(props.fight.botB!.name, round.botBResponse) - else await sleep(insanityMode.value ? 30 : 400) + await minBubbleB + if (!doTTS && !insanityMode.value) await sleep(400) scene?.stopTalking('b') scene?.hideSpeechBubble('b') } @@ -809,7 +814,11 @@ async function _doReplay() { :disabled="isReplaying" @click="replay" > - {{ isReplaying ? 'FIGHTING...' : 'VIDEO REPLAY' }} + FIGHTING... + + + REPLAY + + { async function _playNowCore(text: string, profileName: string, rateOverride?: number): Promise { if (getMasterMuted()) return const sfxGain = getSfxGain() - if (isKokoroReady() && sfxGain) { + // Try Kokoro (includes pre-generated static files from cache) + if (sfxGain) { const profile = voiceProfiles[profileName] || voiceProfiles.announcer - await kokoroPlayCached(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE) - return + const played = await kokoroPlayCached(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE) + if (played) return } // Fallback to Web Speech return _speakAsyncCore(text, profileName, rateOverride) diff --git a/frontend/src/game/tts.ts b/frontend/src/game/tts.ts index 3ddd3f2..2d19e99 100644 --- a/frontend/src/game/tts.ts +++ b/frontend/src/game/tts.ts @@ -510,22 +510,37 @@ export async function kokoroAwaitReady(text: string, profileName: string): Promi } catch { return false } } -/** Play already-cached audio immediately. Returns playback promise. If not cached, returns resolved. */ -export function kokoroPlayCached( +/** Play already-cached audio immediately. Returns true if played, false if nothing available. */ +export async function kokoroPlayCached( text: string, profileName: string, dest: AudioNode, volume: number = 0.7, -): Promise { - if (!_workerReady) return Promise.resolve() +): Promise { const key = _cacheKey(text, profileName) + // Check in-memory cache first (includes pre-loaded static files) const cached = audioCache.get(key) if (cached) { cached.lastAccess = Date.now() - return _playBuffer(cached.buf, dest, volume) + await _playBuffer(cached.buf, dest, volume) + return true } - // Not cached — fall through to generate+play - return kokoroSpeakAsync(text, profileName, dest, volume).then(() => {}) + // Try loading static file on demand if not in cache yet + const staticUrl = STATIC_AUDIO[key] + if (staticUrl) { + const buf = await _loadStaticAudio(staticUrl) + if (buf) { + audioCache.set(key, { buf, lastAccess: Date.now() }) + await _playBuffer(buf, dest, volume) + return true + } + } + // Try worker generation if available + if (_workerReady) { + const played = await kokoroSpeakAsync(text, profileName, dest, volume) + return played + } + return false } /** Stop all currently playing kokoro audio */ diff --git a/server/src/app.ts b/server/src/app.ts index 69f851e..380fe71 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -34,8 +34,14 @@ app.onError((err, c) => { app.use('*', logger()) // CORS: lock down in production, allow all in dev -const allowedOrigin = process.env.CORS_ORIGIN || '*' -app.use('/api/*', cors({ origin: allowedOrigin })) +// Supports comma-separated origins: CORS_ORIGIN=https://a.com,https://b.com +const corsEnv = process.env.CORS_ORIGIN || '*' +const allowedOrigins = corsEnv === '*' ? '*' : corsEnv.split(',').map(s => s.trim()) +app.use('/api/*', cors({ + origin: Array.isArray(allowedOrigins) + ? (origin) => allowedOrigins.includes(origin) ? origin : allowedOrigins[0] + : allowedOrigins, +})) // COOP/COEP headers: required for SharedArrayBuffer (Kokoro TTS WASM threading) app.use('*', async (c, next) => {