-
+ :style="{ '--announce-color': announcementColor }">
{{ announcement }}
@@ -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;
diff --git a/frontend/src/components/SpritePreview.vue b/frontend/src/components/SpritePreview.vue
index 8a71cbc..eff2ec5 100644
--- a/frontend/src/components/SpritePreview.vue
+++ b/frontend/src/components/SpritePreview.vue
@@ -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 }
})
diff --git a/frontend/src/composables/useWallet.ts b/frontend/src/composables/useWallet.ts
index a34baad..488965e 100644
--- a/frontend/src/composables/useWallet.ts
+++ b/frontend/src/composables/useWallet.ts
@@ -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)
diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts
index d661242..6cda3a7 100644
--- a/frontend/src/game/sounds.ts
+++ b/frontend/src/game/sounds.ts
@@ -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') {
diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts
index 92323b9..e24eaf4 100644
--- a/frontend/src/game/sprites/index.ts
+++ b/frontend/src/game/sprites/index.ts
@@ -19,10 +19,22 @@ export interface SpriteCustomization {
forceHorns?: boolean
}
+// Sprite sheet cache — avoids regenerating expensive canvas work
+const _spriteCache = new Map
()
+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) */
diff --git a/frontend/src/game/tts-worker.ts b/frontend/src/game/tts-worker.ts
new file mode 100644
index 0000000..18d1f20
--- /dev/null
+++ b/frontend/src/game/tts-worker.ts
@@ -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
+ }
+ }
+})
diff --git a/frontend/src/game/tts.ts b/frontend/src/game/tts.ts
index 651fd06..5bfc4f1 100644
--- a/frontend/src/game/tts.ts
+++ b/frontend/src/game/tts.ts
@@ -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 = {
// 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 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()
-const MAX_CACHE = 200
-
-// Currently playing sources (for stop)
+const MAX_CACHE = 30
const activeSources: Set = 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 {
- 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((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 {
- 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 {
- 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 {
+ return VOICE_MAP
+}
diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue
index f718f5d..7bb6175 100644
--- a/frontend/src/pages/FightPage.vue
+++ b/frontend/src/pages/FightPage.vue
@@ -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() {
BATTLE LOG
+
+
+ {{ spectatorCount }}
+
@@ -969,6 +1013,7 @@ function stopAutoBattle() {
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
| ⚡{{ liveFightData.potSats || 42 }} SATS
+ | {{ spectatorCount }} watching
{{ Math.round(liveFightData.botB.eloRating || 0) }}
@@ -1003,17 +1048,153 @@ function stopAutoBattle() {
-
-
-
-
FIGHT IN PROGRESS
-
- Round {{ liveRounds }} — webhooks being called...
-
-
- AUTO BATTLE #{{ autoBattleCount + 1 }}
-
-
+
+
+
+
+
+
+
+
+
+
BATTLE LOG
+
+
+ {{ spectatorCount }}
+
+
+
+
+
+
{{ item.text }}
+
+
+
+
+
{{ item.text }}
+
{{ item.text }}
+
+
Waiting for fight to begin...
+
+
+
+
+
+
+ Round {{ liveCurrentRound }}
+ Waiting for fight...
+
+
+
+
+ AUTO BATTLE #{{ autoBattleCount + 1 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ liveFightData.botA.name }}
+
{{ liveHpA }}
+
+
VS
+
+
{{ liveHpB }}
+
{{ liveFightData.botB.name }}
+
+
+
+
+
+
{{ liveFightData.botA.name }}
+
+
{{ liveHpA }}
+
VS
+
{{ liveHpB }}
+
+
{{ liveFightData.botB.name }}
+
+
+ {{ Math.round(liveFightData.botA.eloRating || 0) }}
+
+ {{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
+ | {{ spectatorCount }} watching
+
+ {{ Math.round(liveFightData.botB.eloRating || 0) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ liveAnnouncement }}
+
+
+
+
+
+
+
+
+
+
+
FIGHT IN PROGRESS
+
Connecting to live fight...
+
+
+
+
diff --git a/frontend/src/pages/SoundboardPage.vue b/frontend/src/pages/SoundboardPage.vue
new file mode 100644
index 0000000..96be102
--- /dev/null
+++ b/frontend/src/pages/SoundboardPage.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
VOICE SOUNDBOARD
+
{{ allProfiles.length }} voice profiles. Click to preview.
+
+
+
+
+
+
+
+
+ Kokoro TTS: READY
+ Kokoro TTS: Loading model...
+ Kokoro TTS: Not loaded (using Web Speech fallback)
+
+
+
+
+
+
+
+
{{ category }}
+
+
+
+
+
+
diff --git a/frontend/src/router.ts b/frontend/src/router.ts
index 895c585..a6a0d0d 100644
--- a/frontend/src/router.ts
+++ b/frontend/src/router.ts
@@ -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({
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 0145c58..b7546e0 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -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',
diff --git a/server/botfights.db b/server/botfights.db
new file mode 100644
index 0000000..e69de29
diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts
index 783edb9..fec94a3 100644
--- a/server/src/routes/fights.ts
+++ b/server/src/routes/fights.ts
@@ -12,6 +12,13 @@ import { getPendingChallenge, submitHumanResponse } from '../engine/human-respon
export const fightsRouter = new Hono()
+// Track spectator counts per fight
+const spectatorCounts = new Map
()
+
+export function getSpectatorCount(fightId: string): number {
+ return spectatorCounts.get(fightId) || 0
+}
+
// List recent fights (with bot names)
fightsRouter.get('/', async (c) => {
const rows = await db.select()
@@ -322,10 +329,20 @@ fightsRouter.get('/:id/stream', (c) => {
const fightId = c.req.param('id')
return streamSSE(c, async (stream) => {
+ // Track spectator
+ spectatorCounts.set(fightId, (spectatorCounts.get(fightId) || 0) + 1)
+ const count = spectatorCounts.get(fightId)!
+
+ // Send initial spectator count
+ await stream.writeSSE({
+ event: 'spectator_count',
+ data: JSON.stringify({ count }),
+ })
+
const cleanup = fightEvents.on(fightId, (event) => {
stream.writeSSE({
event: event.type,
- data: JSON.stringify(event.data),
+ data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }),
})
})
@@ -333,14 +350,17 @@ fightsRouter.get('/:id/stream', (c) => {
if (event.fightId === fightId && event.type === 'fight_end') {
stream.writeSSE({
event: 'fight_end',
- data: JSON.stringify(event.data),
+ data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }),
})
}
})
try {
while (true) {
- await stream.writeSSE({ event: 'ping', data: '' })
+ await stream.writeSSE({
+ event: 'ping',
+ data: JSON.stringify({ spectators: spectatorCounts.get(fightId) || 0 }),
+ })
await stream.sleep(5000)
const fight = await db.select({ status: schema.fights.status })
.from(schema.fights)
@@ -351,6 +371,13 @@ fightsRouter.get('/:id/stream', (c) => {
} catch {
// Client disconnected
} finally {
+ // Decrement spectator count
+ const current = spectatorCounts.get(fightId) || 1
+ if (current <= 1) {
+ spectatorCounts.delete(fightId)
+ } else {
+ spectatorCounts.set(fightId, current - 1)
+ }
cleanup()
cleanupGlobal()
}