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:
co-authored by
Claude Opus 4.6
parent
a540320901
commit
610e799605
+47
-41
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user