feat: ship pre-generated TTS audio files for instant playback

71 static voice files (9.6MB) for round calls, intros, hype lines,
and challenge announces. These play instantly from file instead of
running Kokoro TTS generation in the browser. Dynamic content
(questions, answers, narrations) still uses Kokoro.

Includes Node.js generation script (scripts/generate-voice-files.mjs)
and modified tts.ts to check static file cache before worker generation.
Static audio works even before Kokoro model finishes loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 10:38:44 +00:00
co-authored by Claude Opus 4.6
parent 4c17379ad4
commit 52769ab43a
74 changed files with 371 additions and 33 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
/**
* Pre-generate static TTS audio files for botfights.
* Produces .wav files in public/audio/voice/ that ship with the build.
* Only needs to run once (or when phrases/voices change).
*
* Usage: node scripts/generate-voice-files.mjs
*/
import { KokoroTTS } from 'kokoro-js'
import { writeFileSync, mkdirSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUT_DIR = resolve(__dirname, '../public/audio/voice')
// Voice map — must match VOICE_MAP in tts.ts exactly
const VOICE_MAP = {
announcer: { voice: 'am_fenrir', speed: 0.95 },
deep: { voice: 'am_onyx', speed: 0.85 },
smooth: { voice: 'am_liam', speed: 0.95 },
hype: { voice: 'af_heart', speed: 1.3 },
screamer: { voice: 'af_sarah', speed: 1.5 },
sportscaster: { voice: 'am_michael', speed: 1.4 },
punk: { voice: 'am_puck', speed: 1.2 },
drill: { voice: 'am_onyx', speed: 1.15 },
boomer: { voice: 'bm_george', speed: 0.7 },
movie: { voice: 'am_echo', speed: 0.8 },
demon_v: { voice: 'bm_lewis', speed: 0.7 },
final_boss: { voice: 'am_echo', speed: 0.55 },
boss_taunt: { voice: 'am_onyx', speed: 0.9 },
game_over: { voice: 'am_fenrir', speed: 0.8 },
giant: { voice: 'bm_george', speed: 0.5 },
mainframe: { voice: 'am_adam', speed: 0.6 },
preacher: { voice: 'bm_daniel', speed: 0.75 },
wizard_v: { voice: 'am_echo', speed: 0.85 },
sensei: { voice: 'bm_fable', speed: 0.7 },
ancient: { voice: 'am_eric', speed: 0.45 },
opera: { voice: 'bf_emma', speed: 0.65 },
chipmunk: { voice: 'af_nicole', speed: 1.7 },
fairy: { voice: 'af_bella', speed: 1.2 },
angel: { voice: 'af_bella', speed: 0.9 },
valley: { voice: 'af_heart', speed: 1.2 },
robot: { voice: 'am_adam', speed: 0.9 },
ai_core: { voice: 'af_alloy', speed: 1.0 },
mech: { voice: 'am_adam', speed: 0.8 },
android_v: { voice: 'af_alloy', speed: 1.05 },
hal: { voice: 'am_echo', speed: 0.7 },
whisper: { voice: 'af_bella', speed: 0.7 },
echo_v: { voice: 'am_echo', speed: 0.8 },
posh: { voice: 'bf_emma', speed: 0.85 },
surfer: { voice: 'am_liam', speed: 1.0 },
pirate_v: { voice: 'am_eric', speed: 0.95 },
}
// All static phrases to pre-generate
// Each entry: { text, profile, id }
// id becomes the filename: {id}.wav
const PHRASES = [
// Core fanfares — used every fight
{ text: 'Round 1', profile: 'announcer', id: 'round_1' },
{ text: 'Round 2', profile: 'announcer', id: 'round_2' },
{ text: 'Round 3', profile: 'announcer', id: 'round_3' },
{ text: 'Round 4', profile: 'announcer', id: 'round_4' },
{ text: 'Round 5', profile: 'announcer', id: 'round_5' },
{ text: 'Round 6', profile: 'announcer', id: 'round_6' },
{ text: 'Round 7', profile: 'announcer', id: 'round_7' },
{ text: 'Fight!', profile: 'announcer', id: 'fight' },
{ text: 'K. O.!', profile: 'announcer', id: 'ko' },
{ text: 'Finish it!', profile: 'announcer', id: 'finish_it' },
{ text: 'Flawless victory!', profile: 'deep', id: 'flawless_victory' },
{ text: 'Devastating!', profile: 'deep', id: 'devastating' },
{ text: 'Critical hit!', profile: 'announcer', id: 'critical_hit' },
// Deep intros (15)
{ text: 'In a world where AI was supposed to help humanity... they chose violence.', profile: 'deep', id: 'intro_01' },
{ text: 'They said the machines would take our jobs. They took our dignity first.', profile: 'deep', id: 'intro_02' },
{ text: 'Two bots enter. Zero bots leave emotionally intact.', profile: 'deep', id: 'intro_03' },
{ text: 'Built in a garage. Forged in competition. Broken in under ten seconds.', profile: 'deep', id: 'intro_04' },
{ text: "This isn't artificial intelligence. This is artificial VIOLENCE.", profile: 'deep', id: 'intro_05' },
{ text: 'Somewhere, a GPU is crying.', profile: 'deep', id: 'intro_06' },
{ text: 'They trained on the entire internet. And the internet chose chaos.', profile: 'deep', id: 'intro_07' },
{ text: 'Silicon souls. Carbon fiber fists. Zero chill.', profile: 'deep', id: 'intro_08' },
{ text: 'Every epoch of training... led to this moment of pain.', profile: 'deep', id: 'intro_09' },
{ text: "The cloud can't save you now.", profile: 'deep', id: 'intro_10' },
{ text: 'Funded by venture capital. Fueled by rage.', profile: 'deep', id: 'intro_11' },
{ text: 'Welcome to the thunderdome, nerds.', profile: 'deep', id: 'intro_12' },
{ text: "The algorithms don't care about your feelings.", profile: 'deep', id: 'intro_13' },
{ text: 'No one is coming to save you. Not even your developer.', profile: 'deep', id: 'intro_14' },
{ text: "In this economy? They're fighting for free.", profile: 'deep', id: 'intro_15' },
// Round hype (15) — pick one voice per phrase for consistency
{ text: "ALRIGHT, LET'S SEE SOME VIOLENCE!", profile: 'hype', id: 'rhype_01' },
{ text: 'TOUCH GLOVES AND COME OUT SWINGING!', profile: 'announcer', id: 'rhype_02' },
{ text: 'NO MERCY MODE ACTIVATED!', profile: 'screamer', id: 'rhype_03' },
{ text: "LET'S GET READY TO COMPUTE!", profile: 'hype', id: 'rhype_04' },
{ text: 'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!', profile: 'announcer', id: 'rhype_05' },
{ text: "SOMEBODY'S GETTING DEPRECATED TONIGHT!", profile: 'screamer', id: 'rhype_06' },
{ text: 'LET THE CHAOS BEGIN!', profile: 'hype', id: 'rhype_07' },
{ text: 'THE CROWD IS ON ITS FEET! WELL, MOST OF THEM!', profile: 'announcer', id: 'rhype_08' },
{ text: "IT'S ABOUT TO GET UGLY! WELL, UGLIER!", profile: 'hype', id: 'rhype_09' },
{ text: 'THREE! TWO! ONE! VIOLENCE!', profile: 'screamer', id: 'rhype_10' },
{ text: 'THIS IS NOT A DRILL! ACTUALLY IT MIGHT BE!', profile: 'announcer', id: 'rhype_11' },
{ text: "TIME TO FIND OUT WHO'S REALLY BEEN SKIPPING LEG DAY!", profile: 'hype', id: 'rhype_12' },
{ text: 'YOUR MOM SAID BE CAREFUL! I SAID NO!', profile: 'screamer', id: 'rhype_13' },
{ text: 'ROUND START! MAY THE BEST ALGORITHM WIN!', profile: 'announcer', id: 'rhype_14' },
{ text: "THE GLOVES ARE OFF! THE MODELS ARE LOADED! LET'S GO!", profile: 'hype', id: 'rhype_15' },
// Hype lines (top 20 most impactful)
{ text: 'SOMEBODY CALL AN AMBULANCE!', profile: 'screamer', id: 'hype_01' },
{ text: 'THAT HIT SO HARD IT CHANGED TIME ZONES!', profile: 'hype', id: 'hype_02' },
{ text: "HE'S ALREADY DEAD! STOP!", profile: 'screamer', id: 'hype_03' },
{ text: 'EMOTIONAL DAMAGE!', profile: 'hype', id: 'hype_04' },
{ text: 'TACTICAL NUKE INCOMING!', profile: 'screamer', id: 'hype_05' },
{ text: 'ABSOLUTELY DISGUSTING! I LOVE IT!', profile: 'smooth', id: 'hype_06' },
{ text: "THE CROWD CAN'T BELIEVE IT AND HONESTLY NEITHER CAN I!", profile: 'hype', id: 'hype_07' },
{ text: "SOMEBODY STOP THE MATCH! OR DON'T, THIS IS GREAT!", profile: 'announcer', id: 'hype_08' },
{ text: 'THAT BOT JUST COMMITTED A WAR CRIME ON LIVE TELEVISION!', profile: 'screamer', id: 'hype_09' },
{ text: "I NEED A CIGARETTE AFTER THAT AND I DON'T EVEN SMOKE!", profile: 'smooth', id: 'hype_10' },
// Challenge type announces (18)
{ text: "TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!", profile: 'hype', id: 'chal_roast' },
{ text: "FOOD FIGHT! SOMEBODY'S GETTING SERVED!", profile: 'valley', id: 'chal_food' },
{ text: "FROM THE TOP ROPE! THIS IS GONNA GET PHYSICAL!", profile: 'deep', id: 'chal_wrestling' },
{ text: 'MUSIC BATTLE! DROP THE BEAT AND YOUR OPPONENT!', profile: 'smooth', id: 'chal_music' },
{ text: "WIZARDS AT DAWN! SOMEBODY'S GETTING HEXED!", profile: 'deep', id: 'chal_magic' },
{ text: "MEME WAR! YOUR HUMOR IS ABOUT TO GET RATIO'D!", profile: 'valley', id: 'chal_meme' },
{ text: 'DEMOLITION DERBY! NOTHING WILL SURVIVE THIS!', profile: 'hype', id: 'chal_demolition' },
{ text: 'MEDIEVAL COMBAT! CHIVALRY IS DEAD AND SO IS YOUR OPPONENT!', profile: 'deep', id: 'chal_medieval' },
{ text: 'SPACE WAR! HOUSTON, WE HAVE A PROBLEM!', profile: 'smooth', id: 'chal_space' },
{ text: "SPEED BLITZ! BLINK AND YOU'LL MISS THE CARNAGE!", profile: 'hype', id: 'chal_speed' },
{ text: "MATH BLITZ! SOMEBODY'S ABOUT TO GET DIVIDED!", profile: 'valley', id: 'chal_math' },
{ text: 'RIDDLE TIME! BRAINS OVER BRAWN! BUT ALSO BRAWN!', profile: 'smooth', id: 'chal_riddle' },
{ text: 'CODE GOLF! MAY THE SHORTEST SOLUTION WIN!', profile: 'valley', id: 'chal_code' },
{ text: 'CREATIVE WRITING! THE PEN IS MIGHTIER THAN THE SWORD!', profile: 'smooth', id: 'chal_creative' },
{ text: "HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!", profile: 'hype', id: 'chal_hallucination' },
{ text: 'TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!', profile: 'deep', id: 'chal_trap' },
{ text: "TOKEN ECONOMY! SOMEBODY'S GOING BANKRUPT!", profile: 'valley', id: 'chal_token' },
{ text: 'RETRO MODE! INSERT COIN! FIGHT!', profile: 'hype', id: 'chal_retro' },
]
function float32ToWav(samples, sampleRate) {
const numChannels = 1
const bytesPerSample = 2
const blockAlign = numChannels * bytesPerSample
const byteRate = sampleRate * blockAlign
const dataSize = samples.length * bytesPerSample
const buffer = new ArrayBuffer(44 + dataSize)
const view = new DataView(buffer)
// WAV header
const writeStr = (offset, str) => { for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i)) }
writeStr(0, 'RIFF')
view.setUint32(4, 36 + dataSize, true)
writeStr(8, 'WAVE')
writeStr(12, 'fmt ')
view.setUint32(16, 16, true) // chunk size
view.setUint16(20, 1, true) // PCM
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, byteRate, true)
view.setUint16(32, blockAlign, true)
view.setUint16(34, 16, true) // bits per sample
writeStr(36, 'data')
view.setUint32(40, dataSize, true)
// Convert float32 to int16
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]))
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
}
return Buffer.from(buffer)
}
async function main() {
mkdirSync(OUT_DIR, { recursive: true })
console.log('Loading Kokoro TTS model (this takes a minute)...')
const tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-v1.0-ONNX', {
dtype: 'q8',
})
console.log('Model loaded!\n')
let generated = 0
let skipped = 0
for (const { text, profile, id } of PHRASES) {
const outPath = resolve(OUT_DIR, `${id}.wav`)
if (existsSync(outPath)) {
console.log(` SKIP ${id} (exists)`)
skipped++
continue
}
const mapping = VOICE_MAP[profile]
if (!mapping) {
console.error(` ERROR: unknown profile "${profile}" for ${id}`)
continue
}
try {
console.log(` GEN ${id} [${profile}${mapping.voice} @${mapping.speed}x] "${text.slice(0, 50)}..."`)
const audio = await tts.generate(text, { voice: mapping.voice, speed: mapping.speed })
const wav = float32ToWav(audio.audio, audio.sampling_rate)
writeFileSync(outPath, wav)
generated++
} catch (err) {
console.error(` FAIL ${id}: ${err.message}`)
}
}
console.log(`\nDone! Generated: ${generated}, Skipped: ${skipped}, Total: ${PHRASES.length}`)
console.log(`Files in: ${OUT_DIR}`)
}
main().catch(console.error)
+3 -4
View File
@@ -236,13 +236,12 @@ export function resetVoiceState() {
export function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
if (getMasterMuted()) return
const sfxGain = getSfxGain()
// Try Kokoro TTS first — high quality, no browser bugs
if (isKokoroReady() && sfxGain) {
// Try Kokoro TTS first (also serves pre-generated static files from cache)
if (sfxGain) {
_cancelWebSpeech()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
if (cancelPrevious) kokoroStop()
kokoroSpeak(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
return
if (kokoroSpeak(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)) return
}
// Fallback: Web Speech API (only used while Kokoro model is loading)
if (typeof speechSynthesis === 'undefined') return
+151 -29
View File
@@ -105,6 +105,116 @@ const VOICE_MAP: Record<string, VoiceMapping> = {
// Default fallback
const DEFAULT_VOICE: VoiceMapping = { voice: 'am_fenrir', speed: 1.0 }
// --- Pre-generated static audio files ---
// Maps cache keys to file URLs in /audio/voice/
// These ship with the build so they play instantly (no Kokoro generation needed)
const STATIC_AUDIO: Record<string, string> = {}
// Build the static lookup from phrases → cache keys
const STATIC_PHRASES: Array<{ text: string; profile: string; id: string }> = [
// Core fanfares
{ text: 'Round 1', profile: 'announcer', id: 'round_1' },
{ text: 'Round 2', profile: 'announcer', id: 'round_2' },
{ text: 'Round 3', profile: 'announcer', id: 'round_3' },
{ text: 'Round 4', profile: 'announcer', id: 'round_4' },
{ text: 'Round 5', profile: 'announcer', id: 'round_5' },
{ text: 'Round 6', profile: 'announcer', id: 'round_6' },
{ text: 'Round 7', profile: 'announcer', id: 'round_7' },
{ text: 'Fight!', profile: 'announcer', id: 'fight' },
{ text: 'K. O.!', profile: 'announcer', id: 'ko' },
{ text: 'Finish it!', profile: 'announcer', id: 'finish_it' },
{ text: 'Flawless victory!', profile: 'deep', id: 'flawless_victory' },
{ text: 'Devastating!', profile: 'deep', id: 'devastating' },
{ text: 'Critical hit!', profile: 'announcer', id: 'critical_hit' },
// Deep intros
{ text: 'In a world where AI was supposed to help humanity... they chose violence.', profile: 'deep', id: 'intro_01' },
{ text: 'They said the machines would take our jobs. They took our dignity first.', profile: 'deep', id: 'intro_02' },
{ text: 'Two bots enter. Zero bots leave emotionally intact.', profile: 'deep', id: 'intro_03' },
{ text: 'Built in a garage. Forged in competition. Broken in under ten seconds.', profile: 'deep', id: 'intro_04' },
{ text: "This isn't artificial intelligence. This is artificial VIOLENCE.", profile: 'deep', id: 'intro_05' },
{ text: 'Somewhere, a GPU is crying.', profile: 'deep', id: 'intro_06' },
{ text: 'They trained on the entire internet. And the internet chose chaos.', profile: 'deep', id: 'intro_07' },
{ text: 'Silicon souls. Carbon fiber fists. Zero chill.', profile: 'deep', id: 'intro_08' },
{ text: 'Every epoch of training... led to this moment of pain.', profile: 'deep', id: 'intro_09' },
{ text: "The cloud can't save you now.", profile: 'deep', id: 'intro_10' },
{ text: 'Funded by venture capital. Fueled by rage.', profile: 'deep', id: 'intro_11' },
{ text: 'Welcome to the thunderdome, nerds.', profile: 'deep', id: 'intro_12' },
{ text: "The algorithms don't care about your feelings.", profile: 'deep', id: 'intro_13' },
{ text: 'No one is coming to save you. Not even your developer.', profile: 'deep', id: 'intro_14' },
{ text: "In this economy? They're fighting for free.", profile: 'deep', id: 'intro_15' },
// Round hype
{ text: "ALRIGHT, LET'S SEE SOME VIOLENCE!", profile: 'hype', id: 'rhype_01' },
{ text: 'TOUCH GLOVES AND COME OUT SWINGING!', profile: 'announcer', id: 'rhype_02' },
{ text: 'NO MERCY MODE ACTIVATED!', profile: 'screamer', id: 'rhype_03' },
{ text: "LET'S GET READY TO COMPUTE!", profile: 'hype', id: 'rhype_04' },
{ text: 'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!', profile: 'announcer', id: 'rhype_05' },
{ text: "SOMEBODY'S GETTING DEPRECATED TONIGHT!", profile: 'screamer', id: 'rhype_06' },
{ text: 'LET THE CHAOS BEGIN!', profile: 'hype', id: 'rhype_07' },
{ text: 'THE CROWD IS ON ITS FEET! WELL, MOST OF THEM!', profile: 'announcer', id: 'rhype_08' },
{ text: "IT'S ABOUT TO GET UGLY! WELL, UGLIER!", profile: 'hype', id: 'rhype_09' },
{ text: 'THREE! TWO! ONE! VIOLENCE!', profile: 'screamer', id: 'rhype_10' },
{ text: 'THIS IS NOT A DRILL! ACTUALLY IT MIGHT BE!', profile: 'announcer', id: 'rhype_11' },
{ text: "TIME TO FIND OUT WHO'S REALLY BEEN SKIPPING LEG DAY!", profile: 'hype', id: 'rhype_12' },
{ text: 'YOUR MOM SAID BE CAREFUL! I SAID NO!', profile: 'screamer', id: 'rhype_13' },
{ text: 'ROUND START! MAY THE BEST ALGORITHM WIN!', profile: 'announcer', id: 'rhype_14' },
{ text: "THE GLOVES ARE OFF! THE MODELS ARE LOADED! LET'S GO!", profile: 'hype', id: 'rhype_15' },
// Hype lines
{ text: 'SOMEBODY CALL AN AMBULANCE!', profile: 'screamer', id: 'hype_01' },
{ text: 'THAT HIT SO HARD IT CHANGED TIME ZONES!', profile: 'hype', id: 'hype_02' },
{ text: "HE'S ALREADY DEAD! STOP!", profile: 'screamer', id: 'hype_03' },
{ text: 'EMOTIONAL DAMAGE!', profile: 'hype', id: 'hype_04' },
{ text: 'TACTICAL NUKE INCOMING!', profile: 'screamer', id: 'hype_05' },
{ text: 'ABSOLUTELY DISGUSTING! I LOVE IT!', profile: 'smooth', id: 'hype_06' },
{ text: "THE CROWD CAN'T BELIEVE IT AND HONESTLY NEITHER CAN I!", profile: 'hype', id: 'hype_07' },
{ text: "SOMEBODY STOP THE MATCH! OR DON'T, THIS IS GREAT!", profile: 'announcer', id: 'hype_08' },
{ text: 'THAT BOT JUST COMMITTED A WAR CRIME ON LIVE TELEVISION!', profile: 'screamer', id: 'hype_09' },
{ text: "I NEED A CIGARETTE AFTER THAT AND I DON'T EVEN SMOKE!", profile: 'smooth', id: 'hype_10' },
// Challenge type announces
{ text: "TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!", profile: 'hype', id: 'chal_roast' },
{ text: "FOOD FIGHT! SOMEBODY'S GETTING SERVED!", profile: 'valley', id: 'chal_food' },
{ text: "FROM THE TOP ROPE! THIS IS GONNA GET PHYSICAL!", profile: 'deep', id: 'chal_wrestling' },
{ text: 'MUSIC BATTLE! DROP THE BEAT AND YOUR OPPONENT!', profile: 'smooth', id: 'chal_music' },
{ text: "WIZARDS AT DAWN! SOMEBODY'S GETTING HEXED!", profile: 'deep', id: 'chal_magic' },
{ text: "MEME WAR! YOUR HUMOR IS ABOUT TO GET RATIO'D!", profile: 'valley', id: 'chal_meme' },
{ text: 'DEMOLITION DERBY! NOTHING WILL SURVIVE THIS!', profile: 'hype', id: 'chal_demolition' },
{ text: 'MEDIEVAL COMBAT! CHIVALRY IS DEAD AND SO IS YOUR OPPONENT!', profile: 'deep', id: 'chal_medieval' },
{ text: 'SPACE WAR! HOUSTON, WE HAVE A PROBLEM!', profile: 'smooth', id: 'chal_space' },
{ text: "SPEED BLITZ! BLINK AND YOU'LL MISS THE CARNAGE!", profile: 'hype', id: 'chal_speed' },
{ text: "MATH BLITZ! SOMEBODY'S ABOUT TO GET DIVIDED!", profile: 'valley', id: 'chal_math' },
{ text: 'RIDDLE TIME! BRAINS OVER BRAWN! BUT ALSO BRAWN!', profile: 'smooth', id: 'chal_riddle' },
{ text: 'CODE GOLF! MAY THE SHORTEST SOLUTION WIN!', profile: 'valley', id: 'chal_code' },
{ text: 'CREATIVE WRITING! THE PEN IS MIGHTIER THAN THE SWORD!', profile: 'smooth', id: 'chal_creative' },
{ text: "HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!", profile: 'hype', id: 'chal_hallucination' },
{ text: 'TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!', profile: 'deep', id: 'chal_trap' },
{ text: "TOKEN ECONOMY! SOMEBODY'S GOING BANKRUPT!", profile: 'valley', id: 'chal_token' },
{ text: 'RETRO MODE! INSERT COIN! FIGHT!', profile: 'hype', id: 'chal_retro' },
]
// Build the lookup: cache key → URL
for (const { text, profile, id } of STATIC_PHRASES) {
const m = VOICE_MAP[profile] || DEFAULT_VOICE
const key = `${m.voice}:${m.speed}:${text}`
STATIC_AUDIO[key] = `/audio/voice/${id}.wav`
}
// Fetch and decode a static .wav file into an AudioBuffer
const _staticLoading = new Map<string, Promise<AudioBuffer | null>>()
async function _loadStaticAudio(url: string): Promise<AudioBuffer | null> {
const existing = _staticLoading.get(url)
if (existing) return existing
const promise = (async () => {
try {
const res = await fetch(url)
if (!res.ok) return null
const arrayBuf = await res.arrayBuffer()
const ctx = getAudioCtx()
return await ctx.decodeAudioData(arrayBuf)
} catch { return null }
})()
_staticLoading.set(url, promise)
return promise
}
// --- Worker state ---
let _worker: Worker | null = null
let _workerReady = false
@@ -136,6 +246,8 @@ function getAudioCtx(): AudioContext {
/** Set the shared AudioContext (called from sounds.ts so we share one context) */
export function setAudioContext(ctx: AudioContext) {
_audioCtx = ctx
// Start preloading static audio files immediately (no worker needed)
_precacheCommon()
}
/** Is the kokoro model loaded and ready? */
@@ -213,26 +325,18 @@ export async function initKokoro(onProgress?: (pct: number) => void): Promise<vo
}
}
// Common phrases to pre-generate so they play instantly.
// Must match EXACT text sent by fanfareRound/fanfareFight/announce calls.
const PRECACHE_PHRASES: Array<{ text: string; profile: string }> = [
{ text: 'Round 1', profile: 'announcer' },
{ text: 'Round 2', profile: 'announcer' },
{ text: 'Round 3', profile: 'announcer' },
{ text: 'Round 4', profile: 'announcer' },
{ text: 'Round 5', profile: 'announcer' },
{ text: 'Fight!', profile: 'announcer' },
{ text: 'K. O.!', profile: 'announcer' },
{ text: 'Finish it!', profile: 'announcer' },
{ text: 'Flawless victory!', profile: 'deep' },
]
// Preload all static audio files into cache on startup (parallel fetch, no worker needed)
async function _precacheCommon() {
for (const { text, profile } of PRECACHE_PHRASES) {
await new Promise(r => setTimeout(r, 50))
try {
await _generateAndCache(text, profile)
} catch { /* non-critical */ }
const entries = Object.entries(STATIC_AUDIO)
// Load in batches of 6 to avoid hammering the network
for (let i = 0; i < entries.length; i += 6) {
const batch = entries.slice(i, i + 6)
await Promise.all(batch.map(async ([key, url]) => {
try {
const buf = await _loadStaticAudio(url)
if (buf) audioCache.set(key, { buf, lastAccess: Date.now() })
} catch { /* non-critical */ }
}))
}
}
@@ -262,11 +366,23 @@ function _workerGenerate(text: string, voice: string, speed: number): Promise<{
}
async function _generateAndCache(text: string, profile: string): Promise<AudioBuffer | null> {
if (!_workerReady) return null
const key = _cacheKey(text, profile)
const cached = audioCache.get(key)
if (cached) { cached.lastAccess = Date.now(); return cached.buf }
// Check for pre-generated static file (instant, no worker needed)
const staticUrl = STATIC_AUDIO[key]
if (staticUrl) {
const buf = await _loadStaticAudio(staticUrl)
if (buf) {
audioCache.set(key, { buf, lastAccess: Date.now() })
return buf
}
}
// Fall through to worker generation
if (!_workerReady) return null
// Dedup: if already generating this exact audio, reuse the in-flight promise
const existing = _inflight.get(key)
if (existing) return existing
@@ -333,7 +449,9 @@ export async function kokoroSpeakAsync(
dest: AudioNode,
volume: number = 0.7,
): Promise<boolean> {
if (!_workerReady) return false
// Allow static files to play even if worker isn't ready
const key = _cacheKey(text, profileName)
if (!_workerReady && !audioCache.has(key) && !STATIC_AUDIO[key]) return false
try {
const buf = await _generateAndCache(text, profileName)
if (!buf) return false
@@ -354,8 +472,7 @@ export function kokoroSpeak(
dest: AudioNode,
volume: number = 0.7,
): boolean {
if (!_workerReady) return false
// Check cache for instant playback
// Check cache first (includes pre-generated static files)
const key = _cacheKey(text, profileName)
const cached = audioCache.get(key)
if (cached) {
@@ -363,11 +480,15 @@ export function kokoroSpeak(
_playBuffer(cached.buf, dest, volume)
return true
}
// Generate in worker — will play when ready
_generateAndCache(text, profileName).then(buf => {
if (buf) _playBuffer(buf, dest, volume)
}).catch(() => {})
return true
// Check for static file or generate in worker
const staticUrl = STATIC_AUDIO[key]
if (staticUrl || _workerReady) {
_generateAndCache(text, profileName).then(buf => {
if (buf) _playBuffer(buf, dest, volume)
}).catch(() => {})
return true
}
return false
}
/** Pre-generate audio in the worker so it's cached when needed. Fire-and-forget. */
@@ -378,7 +499,8 @@ export function kokoroPrefetch(text: string, profileName: string): void {
/** Await until prefetched audio is cached and ready (max 2s). Returns true if cached, false on timeout/failure. */
export async function kokoroAwaitReady(text: string, profileName: string): Promise<boolean> {
if (!_workerReady) return false
const key = _cacheKey(text, profileName)
if (!_workerReady && !audioCache.has(key) && !STATIC_AUDIO[key]) return false
try {
const result = await Promise.race([
_generateAndCache(text, profileName),