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