feat: SSE live fight spectating with spectator count

Enable real-time fight spectating for all live fights (not just human
fights). Multiple spectators can watch simultaneously via SSE. Spectator
count is tracked per-fight and broadcast with every SSE event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 19:46:39 +00:00
co-authored by Claude Opus 4.6
parent a540320901
commit 610e799605
18 changed files with 1555 additions and 397 deletions
+23 -16
View File
@@ -643,9 +643,9 @@ async function _doReplay() {
<div v-if="announcementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<div class="announce-text-wrapper">
<p class="font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest announce-text uppercase announce-chromatic"
<p class="font-funky text-3xl sm:text-6xl lg:text-8xl tracking-widest announce-text-3d uppercase announce-chromatic"
:data-text="announcement"
:style="{ color: announcementColor, textShadow: `0 0 20px ${announcementColor}, 0 0 40px ${announcementColor}, 0 0 80px ${announcementColor}40, 0 0 120px ${announcementColor}20` }">
:style="{ '--announce-color': announcementColor }">
{{ announcement }}
</p>
</div>
@@ -720,20 +720,24 @@ async function _doReplay() {
position: relative;
}
.announce-text {
animation: announce-pulse 0.4s ease-in-out infinite alternate, announce-hue 2s linear infinite;
-webkit-text-stroke: 1px rgba(0,0,0,0.3);
/* Colourful 3D extruded text */
.announce-text-3d {
color: var(--announce-color, #ffffff);
paint-order: stroke fill;
-webkit-text-stroke: 2px rgba(0,0,0,0.6);
text-shadow:
2px 2px 0 #1a0020,
4px 4px 0 #2a0040,
6px 6px 0 #3a0060,
8px 8px 0 #4d0080,
0 0 20px var(--announce-color, #ffffff),
0 0 60px var(--announce-color, #ffffff);
will-change: transform;
animation: announce-3d-pulse 0.35s ease-in-out infinite alternate;
}
@keyframes announce-pulse {
from { transform: scale(1) rotate(-1deg); }
to { transform: scale(1.08) rotate(1deg); }
}
@keyframes announce-hue {
0% { filter: hue-rotate(0deg) brightness(1); }
25% { filter: hue-rotate(15deg) brightness(1.1); }
50% { filter: hue-rotate(0deg) brightness(1.2); }
75% { filter: hue-rotate(-15deg) brightness(1.1); }
100% { filter: hue-rotate(0deg) brightness(1); }
@keyframes announce-3d-pulse {
from { transform: scale(1) rotate(-0.5deg); }
to { transform: scale(1.06) rotate(0.5deg); }
}
/* Chromatic aberration on announcements */
@@ -748,8 +752,11 @@ async function _doReplay() {
left: 0;
right: 0;
text-align: center;
opacity: 0.5;
opacity: 0.35;
pointer-events: none;
text-shadow: none;
-webkit-text-stroke: 0;
will-change: transform;
}
.announce-chromatic::before {
color: #ff2d7b;
@@ -55,6 +55,8 @@ watch(() => [props.seed, props.archetype, props.customization, props.pose], () =
onUnmounted(() => {
if (animHandle) clearTimeout(animHandle)
animHandle = null
if (img) { img.onload = null; img.src = ''; img = null }
})
</script>
+11 -2
View File
@@ -38,9 +38,14 @@ function parseNwcUrl(url: string): NwcConfig {
const relay = params.get('relay')
const secret = params.get('secret')
if (!pubkey || !relay || !secret) {
throw new Error('Invalid NWC URL')
throw new Error('Invalid NWC URL: missing pubkey, relay, or secret')
}
return { pubkey, relay, secret: hexToBytes(secret) }
// Validate hex before parsing — must be even-length hex string
const trimmed = secret.trim()
if (trimmed.length === 0 || trimmed.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(trimmed)) {
throw new Error('Invalid NWC URL: secret is not valid hex (must be even-length hex string)')
}
return { pubkey, relay, secret: hexToBytes(trimmed) }
}
export function useWallet() {
@@ -159,7 +164,11 @@ export function useWallet() {
// If NWC connected, auto-pay via NWC and confirm directly
const nwcUrl = localStorage.getItem('bf_nwc_url')
let nwcValid = false
if (nwcUrl) {
try { parseNwcUrl(nwcUrl); nwcValid = true } catch { /* bad stored URL — fall through to poll */ }
}
if (nwcUrl && nwcValid) {
paymentStatus.value = 'paying'
const preimage = await payViaNWC(nwcUrl, bolt11)
+47 -41
View File
@@ -289,8 +289,9 @@ if (typeof speechSynthesis !== 'undefined') {
speechSynthesis.onvoiceschanged = loadVoices
loadVoices()
// Chrome bug workaround: speechSynthesis pauses after ~15s.
// Periodic resume() keeps it alive.
// Periodic resume() keeps it alive — but only if Kokoro isn't handling TTS.
setInterval(() => {
if (isKokoroReady()) return // Kokoro active — don't touch Web Speech
if (speechSynthesis.speaking && !speechSynthesis.paused) return
if (speechSynthesis.paused) speechSynthesis.resume()
}, 5000)
@@ -298,7 +299,7 @@ if (typeof speechSynthesis !== 'undefined') {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (speechSynthesis.paused) speechSynthesis.resume()
if (!isKokoroReady() && speechSynthesis.paused) speechSynthesis.resume()
if (ctx?.state === 'suspended') ctx.resume().catch(() => {})
}
})
@@ -309,16 +310,28 @@ let _speechQueueDepth = 0
// Scale speech volume down so voice doesn't overpower SFX/music
const VOICE_VOLUME_SCALE = 0.7
function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
let _webSpeechActive = false // track if Web Speech has queued utterances
function _cancelWebSpeech() {
if (typeof speechSynthesis !== 'undefined' && _webSpeechActive) {
speechSynthesis.cancel()
_speechQueueDepth = 0
_webSpeechActive = false
}
}
export function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
if (masterMuted) return
// Try Kokoro TTS first — high quality, no browser bugs
if (isKokoroReady() && sfxGain) {
// Kill any lingering Web Speech utterances so voices don't double
_cancelWebSpeech()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
if (cancelPrevious) kokoroStop()
kokoroSpeak(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
return
}
// Fallback: Web Speech API
// Fallback: Web Speech API (only used while Kokoro model is loading)
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
if (speechSynthesis.paused) speechSynthesis.resume()
@@ -335,21 +348,30 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
utter.rate = profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
_webSpeechActive = true
utter.onend = () => {
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
if (_speechQueueDepth === 0) _webSpeechActive = false
}
utter.onerror = (ev) => {
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
if (_speechQueueDepth === 0) _webSpeechActive = false
if (ev.error !== 'canceled' && ev.error !== 'interrupted') {
setTimeout(() => {
if (!masterMuted && typeof speechSynthesis !== 'undefined') {
const retry = new SpeechSynthesisUtterance(text)
if (profile.voice) retry.voice = profile.voice
retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
speechSynthesis.speak(retry)
}
}, 200)
// Only retry if Kokoro still isn't ready — avoid doubling
if (!isKokoroReady()) {
setTimeout(() => {
if (!masterMuted && !isKokoroReady() && typeof speechSynthesis !== 'undefined') {
const retry = new SpeechSynthesisUtterance(text)
if (profile.voice) retry.voice = profile.voice
retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
_webSpeechActive = true
retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false }
retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false }
speechSynthesis.speak(retry)
}
}, 200)
}
}
}
speechSynthesis.speak(utter)
@@ -364,6 +386,7 @@ async function _speakAsyncCore(text: string, profileName: string, rateOverride?:
if (masterMuted) return
// Try Kokoro TTS first
if (isKokoroReady() && sfxGain) {
_cancelWebSpeech()
if (cancelPrevious) kokoroStop()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const handled = await kokoroSpeakAsync(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
@@ -427,6 +450,7 @@ export function stopAllAudio() {
kokoroStop()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
_speechQueueDepth = 0
_webSpeechActive = false
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
// then reconnect so future sounds still work
if (ctx) {
@@ -446,29 +470,9 @@ export function stopAllAudio() {
}
// Public voice functions
export function announce(text: string, pitch?: number, rate?: number) {
if (masterMuted) return
// Kokoro ignores pitch/rate overrides — just use the announcer profile
if (isKokoroReady()) {
speak(text, 'announcer')
return
}
if (pitch !== undefined || rate !== undefined) {
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
const utter = new SpeechSynthesisUtterance(text)
const profile = voiceProfiles.announcer
if (profile.voice) utter.voice = profile.voice
utter.pitch = pitch ?? 1.0
utter.rate = rate ?? 0.8
utter.volume = VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
speechSynthesis.speak(utter)
} else {
speak(text, 'announcer')
}
export function announce(text: string, _pitch?: number, _rate?: number) {
// Always route through speak() — prevents doubled voices from separate Web Speech paths
speak(text, 'announcer')
}
export function announceDeep(text: string) { speak(text, 'deep') }
@@ -2882,9 +2886,11 @@ export async function ensureAudioContext() {
if (c.state === 'suspended') {
try { await c.resume() } catch {}
}
// Share AudioContext with kokoro and start loading the model
// Share AudioContext with kokoro and start loading the model.
// Defer initKokoro so the click handler finishes first — the 4.8MB module
// parse + 86MB model download happen after the UI is unblocked.
setAudioContext(c)
initKokoro()
setTimeout(() => initKokoro(), 0)
// Prime speech synthesis as fallback — mobile browsers require
// a speak() call inside a user gesture to unlock speechSynthesis
if (typeof speechSynthesis !== 'undefined') {
+19 -1
View File
@@ -19,10 +19,22 @@ export interface SpriteCustomization {
forceHorns?: boolean
}
// Sprite sheet cache — avoids regenerating expensive canvas work
const _spriteCache = new Map<string, string>()
const MAX_SPRITE_CACHE = 40
function _spriteCacheKey(seed: string, tier: number, primary: string, secondary: string, arch?: string, cust?: SpriteCustomization): string {
return `${seed}|${tier}|${primary}|${secondary}|${arch || ''}|${cust ? JSON.stringify(cust) : ''}`
}
export function generateSpriteSheet(
seed: string, tier: number, primaryColor: string, secondaryColor: string,
archetypeOverride?: string, customization?: SpriteCustomization,
): string {
const cacheKey = _spriteCacheKey(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization)
const cached = _spriteCache.get(cacheKey)
if (cached) return cached
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * MAX_FRAMES
canvas.height = FRAME_SIZE * TOTAL_ROWS
@@ -551,7 +563,13 @@ export function generateSpriteSheet(
for (let f = cfg.frames; f < MAX_FRAMES; f++) drawFrame(f, row, pose, cfg.frames - 1, cfg.frames)
}
return canvas.toDataURL()
const dataUrl = canvas.toDataURL()
if (_spriteCache.size >= MAX_SPRITE_CACHE) {
const oldest = _spriteCache.keys().next().value
if (oldest) _spriteCache.delete(oldest)
}
_spriteCache.set(cacheKey, dataUrl)
return dataUrl
}
/** Load a sprite sheet data URL into a canvas (async for reliable image decode) */
+51
View File
@@ -0,0 +1,51 @@
// Web Worker for Kokoro TTS — runs ONNX neural network inference off the main thread.
// This prevents the 1-5 second freezes that occur when generate() runs on the UI thread.
let tts: any = null
self.addEventListener('message', async (e: MessageEvent) => {
const msg = e.data
switch (msg.type) {
case 'init': {
try {
const { KokoroTTS } = await import('kokoro-js')
tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-ONNX', {
dtype: 'q8',
device: null,
progress_callback: (p: any) => {
if (p.progress !== undefined) {
self.postMessage({ type: 'progress', progress: p.progress })
}
},
})
self.postMessage({ type: 'init-done' })
} catch (err) {
self.postMessage({ type: 'init-failed', error: String(err) })
}
break
}
case 'generate': {
if (!tts) {
self.postMessage({ type: 'generate-failed', id: msg.id, error: 'not initialized' })
break
}
try {
const result = await tts.generate(msg.text, {
voice: msg.voice,
speed: msg.speed,
})
// Copy to a standalone ArrayBuffer so we can transfer ownership (zero-copy to main thread)
const audio = new Float32Array(result.audio)
self.postMessage(
{ type: 'generate-done', id: msg.id, audio, sampleRate: result.sampling_rate },
{ transfer: [audio.buffer] },
)
} catch (err) {
self.postMessage({ type: 'generate-failed', id: msg.id, error: String(err) })
}
break
}
}
})
+107 -47
View File
@@ -1,9 +1,7 @@
// Kokoro TTS engine — high-quality client-side text-to-speech
// Lazy-loads the 86MB q8 model on first use, caches in browser storage.
// All ONNX inference runs in a Web Worker so the main thread never freezes.
// Falls through to speechSynthesis if model isn't ready yet.
import type { KokoroTTS as KokoroTTSType } from 'kokoro-js'
type KokoroVoice = 'af_heart' | 'af_alloy' | 'af_aoede' | 'af_bella' | 'af_jessica' | 'af_kore' |
'af_nicole' | 'af_nova' | 'af_river' | 'af_sarah' | 'af_sky' |
'am_adam' | 'am_echo' | 'am_eric' | 'am_fenrir' | 'am_liam' | 'am_michael' | 'am_onyx' | 'am_puck' | 'am_santa' |
@@ -107,16 +105,23 @@ const VOICE_MAP: Record<string, VoiceMapping> = {
// Default fallback
const DEFAULT_VOICE: VoiceMapping = { voice: 'am_fenrir', speed: 1.0 }
let ttsInstance: KokoroTTSType | null = null
let ttsLoading = false
let ttsLoadFailed = false
// --- Worker state ---
let _worker: Worker | null = null
let _workerReady = false
let _workerLoading = false
let _workerFailed = false
let _nextReqId = 0
// Pending worker requests: id → resolve/reject
const _pending = new Map<number, {
resolve: (v: { audio: Float32Array; sampleRate: number }) => void
reject: (e: Error) => void
}>()
// --- Audio state (main thread only) ---
let _audioCtx: AudioContext | null = null
// Audio cache: key = "voice:speed:text" → AudioBuffer
const audioCache = new Map<string, AudioBuffer>()
const MAX_CACHE = 200
// Currently playing sources (for stop)
const MAX_CACHE = 30
const activeSources: Set<AudioBufferSourceNode> = new Set()
function getAudioCtx(): AudioContext {
@@ -132,56 +137,84 @@ export function setAudioContext(ctx: AudioContext) {
/** Is the kokoro model loaded and ready? */
export function isKokoroReady(): boolean {
return ttsInstance !== null
return _workerReady
}
/** Is the kokoro model currently loading? */
export function isKokoroLoading(): boolean {
return ttsLoading
return _workerLoading
}
/** Start loading the kokoro model. Call early (e.g. on first user gesture). */
/** Handle messages from the TTS worker */
function _handleWorkerMessage(e: MessageEvent) {
const msg = e.data
if (msg.type === 'generate-done') {
const p = _pending.get(msg.id)
if (p) {
_pending.delete(msg.id)
p.resolve({ audio: msg.audio, sampleRate: msg.sampleRate })
}
} else if (msg.type === 'generate-failed') {
const p = _pending.get(msg.id)
if (p) {
_pending.delete(msg.id)
p.reject(new Error(msg.error))
}
}
}
/** Start loading the kokoro model in a Web Worker. Call early (e.g. on first user gesture). */
export async function initKokoro(onProgress?: (pct: number) => void): Promise<void> {
if (ttsInstance || ttsLoading || ttsLoadFailed) return
ttsLoading = true
if (_workerReady || _workerLoading || _workerFailed) return
_workerLoading = true
try {
const { KokoroTTS } = await import('kokoro-js')
ttsInstance = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-ONNX', {
dtype: 'q8',
device: null, // auto-detect (WebGPU → WASM fallback)
progress_callback: onProgress ? (p: any) => {
if (p.progress !== undefined) onProgress(p.progress)
} : undefined,
_worker = new Worker(new URL('./tts-worker.ts', import.meta.url), { type: 'module' })
await new Promise<void>((resolve, reject) => {
_worker!.onmessage = (e) => {
const msg = e.data
if (msg.type === 'init-done') {
_workerReady = true
_workerLoading = false
// 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)
}
}
_worker!.onerror = (e) => {
reject(new Error(e.message || 'Worker failed to load'))
}
_worker!.postMessage({ type: 'init' })
})
ttsLoading = false
// Pre-cache common fight phrases in the background
_precacheCommon()
} catch (e) {
console.warn('[kokoro] Failed to load TTS model:', e)
ttsLoading = false
ttsLoadFailed = true
console.warn('[kokoro] Failed to init TTS worker:', e)
_workerLoading = false
_workerFailed = true
if (_worker) { _worker.terminate(); _worker = null }
}
}
// Common phrases to pre-generate so they play instantly
const PRECACHE_PHRASES: Array<{ text: string; profile: string }> = [
{ text: 'K. O.!', profile: 'announcer' },
{ text: 'Devastating!', profile: 'deep' },
{ text: 'Flawless victory!', profile: 'deep' },
{ text: 'Finish it!', profile: 'announcer' },
{ text: 'Fatality!', profile: 'deep' },
{ text: 'Round one!', profile: 'announcer' },
{ text: 'Round two!', profile: 'announcer' },
{ text: 'Round three!', profile: 'announcer' },
{ text: 'Fight!', profile: 'announcer' },
{ text: 'K. O.!', profile: 'announcer' },
]
async function _precacheCommon() {
if (!ttsInstance) return
for (const { text, profile } of PRECACHE_PHRASES) {
await new Promise(r => setTimeout(r, 50))
try {
await _generateAndCache(text, profile)
} catch { /* swallow — non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -190,21 +223,38 @@ function _cacheKey(text: string, profile: string): string {
return `${m.voice}:${m.speed}:${text}`
}
/** Send a generate request to the worker and wait for the result (10s timeout) */
function _workerGenerate(text: string, voice: string, speed: number): Promise<{ audio: Float32Array; sampleRate: number }> {
return new Promise((resolve, reject) => {
if (!_worker || !_workerReady) {
reject(new Error('worker not ready'))
return
}
const id = _nextReqId++
const timeout = setTimeout(() => {
_pending.delete(id)
reject(new Error('TTS generation timed out'))
}, 10_000)
_pending.set(id, {
resolve: (v) => { clearTimeout(timeout); resolve(v) },
reject: (e) => { clearTimeout(timeout); reject(e) },
})
_worker.postMessage({ type: 'generate', id, text, voice, speed })
})
}
async function _generateAndCache(text: string, profile: string): Promise<AudioBuffer | null> {
if (!ttsInstance) return null
if (!_workerReady) return null
const key = _cacheKey(text, profile)
const cached = audioCache.get(key)
if (cached) return cached
const mapping = VOICE_MAP[profile] || DEFAULT_VOICE
const raw = await ttsInstance.generate(text, {
voice: mapping.voice,
speed: mapping.speed,
})
const raw = await _workerGenerate(text, mapping.voice, mapping.speed)
// Convert Float32Array → AudioBuffer
// Convert Float32Array → AudioBuffer (lightweight, main thread)
const ctx = getAudioCtx()
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampling_rate)
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate)
buf.getChannelData(0).set(raw.audio)
// Evict oldest if cache is full
@@ -240,7 +290,7 @@ function _playBuffer(buf: AudioBuffer, dest: AudioNode, volume: number): Promise
/**
* Generate and play TTS. Returns a promise that resolves when done.
* Returns null if kokoro isn't ready (caller should fall back).
* Returns false if kokoro isn't ready (caller should fall back).
*/
export async function kokoroSpeakAsync(
text: string,
@@ -248,7 +298,7 @@ export async function kokoroSpeakAsync(
dest: AudioNode,
volume: number = 0.7,
): Promise<boolean> {
if (!ttsInstance) return false
if (!_workerReady) return false
try {
const buf = await _generateAndCache(text, profileName)
if (!buf) return false
@@ -269,7 +319,7 @@ export function kokoroSpeak(
dest: AudioNode,
volume: number = 0.7,
): boolean {
if (!ttsInstance) return false
if (!_workerReady) return false
// Check cache for instant playback
const key = _cacheKey(text, profileName)
const cached = audioCache.get(key)
@@ -277,7 +327,7 @@ export function kokoroSpeak(
_playBuffer(cached, dest, volume)
return true
}
// Generate async — will play when ready
// Generate in worker — will play when ready
_generateAndCache(text, profileName).then(buf => {
if (buf) _playBuffer(buf, dest, volume)
}).catch(() => {})
@@ -301,3 +351,13 @@ export function kokoroClearCache() {
export function getKokoroVoice(profileName: string): VoiceMapping {
return VOICE_MAP[profileName] || DEFAULT_VOICE
}
/** Get all voice profile names */
export function getVoiceProfileNames(): string[] {
return Object.keys(VOICE_MAP)
}
/** Get the full voice map (for soundboard UI) */
export function getVoiceMap(): Record<string, { voice: string; speed: number }> {
return VOICE_MAP
}
+197 -16
View File
@@ -67,9 +67,23 @@ watch(() => route.params.fightId, async (newId) => {
}
if (isLive.value) {
startPolling()
connectSSE()
if (isHumanFight.value) {
startHumanPolling()
connectSSE()
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
} else {
// Bot-vs-bot spectating: init live scene for real-time viewing
if (!liveFightData.value) await loadFight()
if (!liveFightData.value) {
// loadFight sets liveFightData only for human fights; set it for spectating too
const res = await fetch(`/api/fights/${fightId.value}`)
if (res.ok) {
const data = await res.json()
if (data.botA && data.botB) liveFightData.value = data
}
}
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
@@ -112,6 +126,7 @@ const liveSoundOn = ref(true)
const liveAnnouncement = ref('')
const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false)
const spectatorCount = ref(0)
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
@@ -212,9 +227,11 @@ function startPolling() {
pollCount++
const s = await loadFight()
if (s === 'finished') {
// Human fights handle end via SSE fight_end event don't transition here
if (!isHumanFight.value) {
// SSE fight_end handles transition for all live fights with SSE connected
if (!eventSource) {
// Fallback: no SSE connected, transition directly
isLive.value = false
disconnectSSE()
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
@@ -361,6 +378,22 @@ async function initLiveScene() {
function connectSSE() {
eventSource = new EventSource(`/api/fights/${fightId.value}/stream`)
eventSource.addEventListener('spectator_count', (e) => {
try {
const data = JSON.parse(e.data)
spectatorCount.value = data.count || 0
} catch { /* ignore */ }
})
eventSource.addEventListener('ping', (e) => {
try {
if (e.data) {
const data = JSON.parse(e.data)
if (data.spectators !== undefined) spectatorCount.value = data.spectators
}
} catch { /* ignore */ }
})
eventSource.addEventListener('round_start', (e) => {
try {
const data = JSON.parse(e.data)
@@ -409,7 +442,9 @@ function connectSSE() {
eventSource.addEventListener('round_end', (e) => {
try {
handleRoundEnd(JSON.parse(e.data)).catch(() => {})
const data = JSON.parse(e.data)
if (data.spectators !== undefined) spectatorCount.value = data.spectators
handleRoundEnd(data).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE round_end failed:', err)
}
@@ -417,7 +452,9 @@ function connectSSE() {
eventSource.addEventListener('fight_end', (e) => {
try {
handleFightEnd(JSON.parse(e.data)).catch(() => {})
const data = JSON.parse(e.data)
if (data.spectators !== undefined) spectatorCount.value = data.spectators
handleFightEnd(data).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE fight_end failed:', err)
}
@@ -441,6 +478,7 @@ function connectSSE() {
function disconnectSSE() {
if (eventSource) { eventSource.close(); eventSource = null }
spectatorCount.value = 0
}
async function showLiveOverlay(text: string, color: string, duration: number) {
@@ -816,6 +854,12 @@ function stopAutoBattle() {
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-yellow" />
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-green" />
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
<span v-if="spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{{ spectatorCount }}
</span>
</div>
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
<div v-for="(item, idx) in liveLogItems" :key="idx">
@@ -969,6 +1013,7 @@ function stopAutoBattle() {
<span class="font-pixel text-[9px] text-text-muted">
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
<span v-if="liveFightData.mode === 'ranked'" class="text-neon-cyan"> | {{ liveFightData.potSats || 42 }} SATS</span>
<span v-if="spectatorCount > 0" class="text-neon-cyan"> | {{ spectatorCount }} watching</span>
</span>
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</div>
@@ -1003,17 +1048,153 @@ function stopAutoBattle() {
</div>
</div>
<!-- LIVE BOT FIGHT: spinner -->
<div v-else-if="isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4">
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
<p class="font-mono text-text-muted text-xs">
Round {{ liveRounds }} webhooks being called...
</p>
<p v-if="autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider">
AUTO BATTLE #{{ autoBattleCount + 1 }}
<button class="ml-2 text-ko hover:text-text-primary transition-colors" @click="stopAutoBattle">STOP</button>
</p>
<!-- LIVE BOT FIGHT: spectator view with live scene -->
<div v-else-if="isLive && !isHumanFight" class="flex-1 flex flex-col lg:flex-row gap-1 sm:gap-2 min-h-0 overflow-hidden">
<!-- Battle Log mobile: bottom 40%, desktop: left 35% -->
<div class="flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden
h-[40%] lg:h-auto lg:w-[35%] order-2 lg:order-1">
<div class="bg-surface-raised border-b border-border px-3 py-1 lg:py-1.5 flex items-center gap-2 flex-shrink-0">
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-ko" />
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-yellow" />
<span class="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-green" />
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
<span v-if="spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{{ spectatorCount }}
</span>
</div>
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
<div v-for="(item, idx) in liveLogItems" :key="idx">
<div v-if="item.type === 'divider'" class="py-1.5"><div class="border-t border-white/5" /></div>
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
<div v-else-if="item.type === 'challenge'" class="bg-neon-green/[0.06] border border-neon-green/20 rounded-md px-3 py-1.5 my-1">
<p class="text-neon-green text-xs font-mono leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
</div>
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
</div>
<div v-if="liveLogItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Waiting for fight to begin...</div>
</div>
<!-- Spectator footer -->
<div class="px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0">
<div class="flex items-center justify-between">
<p class="font-mono text-text-muted text-xs">
<span v-if="liveCurrentRound > 0">Round {{ liveCurrentRound }}</span>
<span v-else>Waiting for fight...</span>
</p>
<button
class="w-7 h-7 flex items-center justify-center border border-border/50 text-text-muted
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
:title="liveSoundOn ? 'Mute' : 'Unmute'"
@click="toggleLiveSound"
>
<svg v-if="liveSoundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
</div>
<p v-if="autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider mt-1">
AUTO BATTLE #{{ autoBattleCount + 1 }}
<button class="ml-2 text-ko hover:text-text-primary transition-colors" @click="stopAutoBattle">STOP</button>
</p>
</div>
</div>
<!-- Game Canvas mobile: top 60%, desktop: right 65% -->
<div class="h-[60%] lg:h-auto lg:flex-1 lg:w-[65%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2">
<!-- Health bars -->
<div v-if="liveFightData?.botA" class="px-2 sm:px-3 py-1 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<!-- Mobile: compact single-row names + HP -->
<div class="sm:hidden">
<div class="flex items-center gap-1">
<p class="font-marker text-[10px] tracking-wider truncate text-neon-cyan flex-1 min-w-0">{{ liveFightData.botA.name }}</p>
<span class="font-mono font-bold text-[10px] w-5 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
<div class="w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-neon-cyan transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
</div>
<span class="font-funky text-neon-purple text-[10px] px-0.5 flex-shrink-0">VS</span>
<div class="w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-neon-pink transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div>
<span class="font-mono font-bold text-[10px] w-5 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<p class="font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB.name }}</p>
</div>
</div>
<!-- Desktop: single-row layout -->
<div class="hidden sm:block">
<div class="flex items-center gap-2">
<p class="font-marker text-sm tracking-wider truncate text-neon-cyan flex-shrink-0 max-w-[20%]">{{ liveFightData.botA.name }}</p>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
</div>
<span class="font-mono font-bold text-sm w-8 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
<span class="font-funky text-neon-purple text-xl px-1">VS</span>
<span class="font-mono font-bold text-sm w-8 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div>
<p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB.name }}</p>
</div>
<div class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
<span class="font-pixel text-[9px] text-text-muted">
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
<span v-if="spectatorCount > 0" class="text-neon-cyan ml-1">| {{ spectatorCount }} watching</span>
</span>
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</div>
</div>
</div>
<!-- Canvas area -->
<div class="flex-1 relative min-h-0">
<canvas ref="liveCanvas" class="w-full h-full block" />
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="liveAnnouncementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<p class="font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest uppercase announce-text"
:style="{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }">
{{ liveAnnouncement }}
</p>
</div>
</Transition>
<!-- Loading scene overlay -->
<div v-if="!liveSceneReady && liveFightData" class="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
<div class="text-center">
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin mx-auto mb-3" />
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
</div>
</div>
<!-- No fight data yet -->
<div v-if="!liveFightData" class="absolute inset-0 flex flex-col items-center justify-center bg-black z-10 gap-4">
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
<p class="font-mono text-text-muted text-xs">Connecting to live fight...</p>
</div>
</div>
</div>
</div>
<div v-else-if="fightError" class="flex-1 flex flex-col items-center justify-center gap-3">
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getVoiceMap, isKokoroReady, isKokoroLoading } from '../game/tts'
import { speak, ensureAudioContext } from '../game/sounds'
const voiceMap = getVoiceMap()
const allProfiles = Object.entries(voiceMap).map(([name, { voice, speed }]) => ({
name,
kokoroVoice: voice,
speed,
}))
const customText = ref('Devastating blow! That had to hurt!')
const filter = ref('')
const audioReady = ref(false)
const playing = ref<string | null>(null)
const filtered = computed(() => {
if (!filter.value) return allProfiles
const q = filter.value.toLowerCase()
return allProfiles.filter(p =>
p.name.includes(q) || p.kokoroVoice.includes(q)
)
})
// Group by category based on voice name prefix
const categories = computed(() => {
const groups: Record<string, typeof allProfiles> = {}
for (const p of filtered.value) {
let cat = 'Other'
if (['announcer', 'deep', 'smooth', 'question_reader', 'news'].includes(p.name)) cat = 'Authoritative'
else if (['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'karen', 'terrified', 'power_up', 'wrestler_v'].includes(p.name)) cat = 'High Energy'
else if (['boomer', 'movie', 'demon_v', 'final_boss', 'boss_taunt', 'game_over', 'giant', 'mainframe'].includes(p.name)) cat = 'Deep / Menacing'
else if (['preacher', 'wizard_v', 'sensei', 'professor', 'ancient', 'opera'].includes(p.name)) cat = 'Calm / Wise'
else if (['chipmunk', 'baby', 'fairy', 'angel', 'tutorial', 'valley'].includes(p.name)) cat = 'Cute / High'
else if (['robot', 'ai_core', 'mech', 'android_v', 'siri', 'hal', 'dial_up', 'glitch', 'glitchbot'].includes(p.name)) cat = 'Robots'
else if (['posh', 'aussie', 'scottish', 'french', 'texan'].includes(p.name)) cat = 'Accents'
else if (['whisper', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v', 'alien_v', 'echo_v'].includes(p.name)) cat = 'Characters'
else if (['grandpa', 'grandma', 'crotchety'].includes(p.name)) cat = 'Old People'
else if (['drunk', 'sleepy', 'stoner', 'npc', 'conspiracy'].includes(p.name)) cat = 'Misc Characters'
if (!groups[cat]) groups[cat] = []
groups[cat].push(p)
}
return groups
})
async function initAudio() {
await ensureAudioContext()
audioReady.value = true
}
function playVoice(profileName: string) {
if (!audioReady.value) return
playing.value = profileName
speak(customText.value || 'Devastating blow! That had to hurt!', profileName, true)
setTimeout(() => { if (playing.value === profileName) playing.value = null }, 3000)
}
const samplePhrases = [
'Devastating blow! That had to hurt!',
'Round one! Fight!',
'K. O.! And the winner is...',
'What an incredible combo!',
'The crowd goes wild!',
'Is that all you got?',
'Satoshi would be proud!',
'Lightning fast attack!',
'Not your keys, not your coins!',
'Stack sats and throw hands!',
]
function randomPhrase() {
customText.value = samplePhrases[Math.floor(Math.random() * samplePhrases.length)]
}
</script>
<template>
<div class="min-h-screen bg-black text-green-400 p-4 sm:p-8 font-mono">
<h1 class="text-2xl sm:text-3xl font-bold text-cyan-400 mb-2">VOICE SOUNDBOARD</h1>
<p class="text-zinc-500 text-sm mb-6">{{ allProfiles.length }} voice profiles. Click to preview.</p>
<!-- Audio init banner -->
<div v-if="!audioReady" class="mb-6">
<button
@click="initAudio"
class="px-6 py-3 bg-cyan-600 hover:bg-cyan-500 text-black font-bold rounded text-lg transition-colors"
>
CLICK TO ENABLE AUDIO
</button>
</div>
<!-- Status -->
<div v-if="audioReady" class="mb-4 flex items-center gap-3 text-sm">
<span v-if="isKokoroReady()" class="text-green-400">Kokoro TTS: READY</span>
<span v-else-if="isKokoroLoading()" class="text-yellow-400">Kokoro TTS: Loading model...</span>
<span v-else class="text-zinc-500">Kokoro TTS: Not loaded (using Web Speech fallback)</span>
</div>
<!-- Custom text + filter -->
<div class="flex flex-col sm:flex-row gap-3 mb-6">
<div class="flex-1 flex gap-2">
<input
v-model="customText"
class="flex-1 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
placeholder="Type custom text to speak..."
/>
<button
@click="randomPhrase"
class="px-3 py-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-600 rounded text-xs text-zinc-400 transition-colors whitespace-nowrap"
>
Random
</button>
</div>
<input
v-model="filter"
class="sm:w-48 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
placeholder="Filter voices..."
/>
</div>
<!-- Voice grid by category -->
<div v-for="(profiles, category) in categories" :key="category" class="mb-8">
<h2 class="text-lg font-bold text-yellow-400 mb-3 border-b border-zinc-800 pb-1">{{ category }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
<button
v-for="p in profiles"
:key="p.name"
:disabled="!audioReady"
@click="playVoice(p.name)"
class="text-left px-3 py-2 rounded border transition-all"
:class="[
playing === p.name
? 'bg-cyan-900/40 border-cyan-500 text-cyan-300'
: 'bg-zinc-900/60 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/60',
!audioReady && 'opacity-40 cursor-not-allowed'
]"
>
<div class="font-bold text-sm" :class="playing === p.name ? 'text-cyan-300' : 'text-green-400'">
{{ p.name }}
</div>
<div class="text-xs text-zinc-500 mt-0.5">
{{ p.kokoroVoice }} @ {{ p.speed }}x
</div>
</button>
</div>
</div>
</div>
</template>
+5
View File
@@ -61,6 +61,11 @@ const routes = [
name: 'docs',
component: () => import('./pages/DocsPage.vue'),
},
{
path: '/soundboard',
name: 'soundboard',
component: () => import('./pages/SoundboardPage.vue'),
},
]
export const router = createRouter({
+15
View File
@@ -55,8 +55,23 @@ export default defineConfig({
},
}),
],
worker: {
format: 'es', // Required for dynamic import('kokoro-js') inside the TTS worker
},
optimizeDeps: {
// Pre-bundle kokoro-js on first startup so Vite doesn't stall mid-page-load
// discovering it as a new dep. The 4.8MB bundle is cached in node_modules/.vite/deps.
include: ['kokoro-js'],
},
server: {
port: 9101,
headers: {
// Enable SharedArrayBuffer for onnxruntime WASM threads.
// Without these, Kokoro TTS runs single-threaded on the main thread,
// blocking the UI for seconds on every TTS generation.
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless',
},
proxy: {
'/api': {
target: 'http://localhost:9100',