#!/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)