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') {