Files
botfights/frontend/scripts/generate-voice-files.mjs
T

218 lines
11 KiB
JavaScript
Raw Normal View History

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