fix: mobile menu overlay, speech bubble timing, TTS static file fallback
- Mobile nav menu now overlays content (absolute positioning) instead of pushing it down - Speech bubbles stay visible for minimum 400ms even when TTS resolves instantly or fails - kokoroPlayCached checks audio cache and loads static files even when Kokoro worker hasn't loaded — fixes TTS not playing on production - CORS_ORIGIN env now supports comma-separated origins - Rename "VIDEO REPLAY" to play icon + "REPLAY" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1a7ad74859
commit
e6b5aa4b9b
@@ -441,8 +441,11 @@ async function _doReplay() {
|
|||||||
scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 8)
|
scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 8)
|
||||||
scene.startTalking('a')
|
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)
|
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?.stopTalking('a')
|
||||||
scene?.hideSpeechBubble('a')
|
scene?.hideSpeechBubble('a')
|
||||||
}
|
}
|
||||||
@@ -460,8 +463,10 @@ async function _doReplay() {
|
|||||||
scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 8)
|
scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 8)
|
||||||
scene.startTalking('b')
|
scene.startTalking('b')
|
||||||
}
|
}
|
||||||
|
const minBubbleB = sleep(400).catch(() => {})
|
||||||
if (doTTS) await playAnswerNow(props.fight.botB!.name, round.botBResponse)
|
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?.stopTalking('b')
|
||||||
scene?.hideSpeechBubble('b')
|
scene?.hideSpeechBubble('b')
|
||||||
}
|
}
|
||||||
@@ -809,7 +814,11 @@ async function _doReplay() {
|
|||||||
:disabled="isReplaying"
|
:disabled="isReplaying"
|
||||||
@click="replay"
|
@click="replay"
|
||||||
>
|
>
|
||||||
{{ isReplaying ? 'FIGHTING...' : 'VIDEO REPLAY' }}
|
<span v-if="isReplaying">FIGHTING...</span>
|
||||||
|
<span v-else class="flex items-center gap-1.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
REPLAY
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center border border-border/50 text-text-muted
|
class="w-8 h-8 flex items-center justify-center border border-border/50 text-text-muted
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const bottomNav = [
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isMenuOpen" class="md:hidden border-t border-border px-6 py-4 space-y-4 bg-surface">
|
<div v-if="isMenuOpen" class="md:hidden absolute left-0 right-0 top-full border-t border-border px-6 py-4 space-y-4 bg-surface/95 backdrop-blur-md z-50">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="link in links"
|
v-for="link in links"
|
||||||
:key="link.to"
|
:key="link.to"
|
||||||
|
|||||||
@@ -788,10 +788,11 @@ export function playNarrationNow(text: string): Promise<void> {
|
|||||||
async function _playNowCore(text: string, profileName: string, rateOverride?: number): Promise<void> {
|
async function _playNowCore(text: string, profileName: string, rateOverride?: number): Promise<void> {
|
||||||
if (getMasterMuted()) return
|
if (getMasterMuted()) return
|
||||||
const sfxGain = getSfxGain()
|
const sfxGain = getSfxGain()
|
||||||
if (isKokoroReady() && sfxGain) {
|
// Try Kokoro (includes pre-generated static files from cache)
|
||||||
|
if (sfxGain) {
|
||||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||||
await kokoroPlayCached(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
|
const played = await kokoroPlayCached(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
|
||||||
return
|
if (played) return
|
||||||
}
|
}
|
||||||
// Fallback to Web Speech
|
// Fallback to Web Speech
|
||||||
return _speakAsyncCore(text, profileName, rateOverride)
|
return _speakAsyncCore(text, profileName, rateOverride)
|
||||||
|
|||||||
@@ -510,22 +510,37 @@ export async function kokoroAwaitReady(text: string, profileName: string): Promi
|
|||||||
} catch { return false }
|
} catch { return false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Play already-cached audio immediately. Returns playback promise. If not cached, returns resolved. */
|
/** Play already-cached audio immediately. Returns true if played, false if nothing available. */
|
||||||
export function kokoroPlayCached(
|
export async function kokoroPlayCached(
|
||||||
text: string,
|
text: string,
|
||||||
profileName: string,
|
profileName: string,
|
||||||
dest: AudioNode,
|
dest: AudioNode,
|
||||||
volume: number = 0.7,
|
volume: number = 0.7,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
if (!_workerReady) return Promise.resolve()
|
|
||||||
const key = _cacheKey(text, profileName)
|
const key = _cacheKey(text, profileName)
|
||||||
|
// Check in-memory cache first (includes pre-loaded static files)
|
||||||
const cached = audioCache.get(key)
|
const cached = audioCache.get(key)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
cached.lastAccess = Date.now()
|
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
|
// Try loading static file on demand if not in cache yet
|
||||||
return kokoroSpeakAsync(text, profileName, dest, volume).then(() => {})
|
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 */
|
/** Stop all currently playing kokoro audio */
|
||||||
|
|||||||
+8
-2
@@ -34,8 +34,14 @@ app.onError((err, c) => {
|
|||||||
|
|
||||||
app.use('*', logger())
|
app.use('*', logger())
|
||||||
// CORS: lock down in production, allow all in dev
|
// CORS: lock down in production, allow all in dev
|
||||||
const allowedOrigin = process.env.CORS_ORIGIN || '*'
|
// Supports comma-separated origins: CORS_ORIGIN=https://a.com,https://b.com
|
||||||
app.use('/api/*', cors({ origin: allowedOrigin }))
|
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)
|
// COOP/COEP headers: required for SharedArrayBuffer (Kokoro TTS WASM threading)
|
||||||
app.use('*', async (c, next) => {
|
app.use('*', async (c, next) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user