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
+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
}