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>
218 lines
11 KiB
JavaScript
218 lines
11 KiB
JavaScript
#!/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)
|