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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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,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) */
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user