diff --git a/frontend/public/audio/voice/chal_code.wav b/frontend/public/audio/voice/chal_code.wav new file mode 100644 index 0000000..c0022e8 Binary files /dev/null and b/frontend/public/audio/voice/chal_code.wav differ diff --git a/frontend/public/audio/voice/chal_creative.wav b/frontend/public/audio/voice/chal_creative.wav new file mode 100644 index 0000000..323ce2c Binary files /dev/null and b/frontend/public/audio/voice/chal_creative.wav differ diff --git a/frontend/public/audio/voice/chal_demolition.wav b/frontend/public/audio/voice/chal_demolition.wav new file mode 100644 index 0000000..6a15703 Binary files /dev/null and b/frontend/public/audio/voice/chal_demolition.wav differ diff --git a/frontend/public/audio/voice/chal_food.wav b/frontend/public/audio/voice/chal_food.wav new file mode 100644 index 0000000..7740220 Binary files /dev/null and b/frontend/public/audio/voice/chal_food.wav differ diff --git a/frontend/public/audio/voice/chal_hallucination.wav b/frontend/public/audio/voice/chal_hallucination.wav new file mode 100644 index 0000000..627baff Binary files /dev/null and b/frontend/public/audio/voice/chal_hallucination.wav differ diff --git a/frontend/public/audio/voice/chal_magic.wav b/frontend/public/audio/voice/chal_magic.wav new file mode 100644 index 0000000..99b8d65 Binary files /dev/null and b/frontend/public/audio/voice/chal_magic.wav differ diff --git a/frontend/public/audio/voice/chal_math.wav b/frontend/public/audio/voice/chal_math.wav new file mode 100644 index 0000000..a9c8490 Binary files /dev/null and b/frontend/public/audio/voice/chal_math.wav differ diff --git a/frontend/public/audio/voice/chal_medieval.wav b/frontend/public/audio/voice/chal_medieval.wav new file mode 100644 index 0000000..9bac344 Binary files /dev/null and b/frontend/public/audio/voice/chal_medieval.wav differ diff --git a/frontend/public/audio/voice/chal_meme.wav b/frontend/public/audio/voice/chal_meme.wav new file mode 100644 index 0000000..0e7c319 Binary files /dev/null and b/frontend/public/audio/voice/chal_meme.wav differ diff --git a/frontend/public/audio/voice/chal_music.wav b/frontend/public/audio/voice/chal_music.wav new file mode 100644 index 0000000..c363690 Binary files /dev/null and b/frontend/public/audio/voice/chal_music.wav differ diff --git a/frontend/public/audio/voice/chal_retro.wav b/frontend/public/audio/voice/chal_retro.wav new file mode 100644 index 0000000..957b8c3 Binary files /dev/null and b/frontend/public/audio/voice/chal_retro.wav differ diff --git a/frontend/public/audio/voice/chal_riddle.wav b/frontend/public/audio/voice/chal_riddle.wav new file mode 100644 index 0000000..9c99a17 Binary files /dev/null and b/frontend/public/audio/voice/chal_riddle.wav differ diff --git a/frontend/public/audio/voice/chal_roast.wav b/frontend/public/audio/voice/chal_roast.wav new file mode 100644 index 0000000..359f323 Binary files /dev/null and b/frontend/public/audio/voice/chal_roast.wav differ diff --git a/frontend/public/audio/voice/chal_space.wav b/frontend/public/audio/voice/chal_space.wav new file mode 100644 index 0000000..80ee209 Binary files /dev/null and b/frontend/public/audio/voice/chal_space.wav differ diff --git a/frontend/public/audio/voice/chal_speed.wav b/frontend/public/audio/voice/chal_speed.wav new file mode 100644 index 0000000..cb3ac68 Binary files /dev/null and b/frontend/public/audio/voice/chal_speed.wav differ diff --git a/frontend/public/audio/voice/chal_token.wav b/frontend/public/audio/voice/chal_token.wav new file mode 100644 index 0000000..1ea5bbb Binary files /dev/null and b/frontend/public/audio/voice/chal_token.wav differ diff --git a/frontend/public/audio/voice/chal_trap.wav b/frontend/public/audio/voice/chal_trap.wav new file mode 100644 index 0000000..61410d4 Binary files /dev/null and b/frontend/public/audio/voice/chal_trap.wav differ diff --git a/frontend/public/audio/voice/chal_wrestling.wav b/frontend/public/audio/voice/chal_wrestling.wav new file mode 100644 index 0000000..c7917b1 Binary files /dev/null and b/frontend/public/audio/voice/chal_wrestling.wav differ diff --git a/frontend/public/audio/voice/critical_hit.wav b/frontend/public/audio/voice/critical_hit.wav new file mode 100644 index 0000000..efdc07d Binary files /dev/null and b/frontend/public/audio/voice/critical_hit.wav differ diff --git a/frontend/public/audio/voice/devastating.wav b/frontend/public/audio/voice/devastating.wav new file mode 100644 index 0000000..e218c2f Binary files /dev/null and b/frontend/public/audio/voice/devastating.wav differ diff --git a/frontend/public/audio/voice/fight.wav b/frontend/public/audio/voice/fight.wav new file mode 100644 index 0000000..5f7534d Binary files /dev/null and b/frontend/public/audio/voice/fight.wav differ diff --git a/frontend/public/audio/voice/finish_it.wav b/frontend/public/audio/voice/finish_it.wav new file mode 100644 index 0000000..319c197 Binary files /dev/null and b/frontend/public/audio/voice/finish_it.wav differ diff --git a/frontend/public/audio/voice/flawless_victory.wav b/frontend/public/audio/voice/flawless_victory.wav new file mode 100644 index 0000000..70976bc Binary files /dev/null and b/frontend/public/audio/voice/flawless_victory.wav differ diff --git a/frontend/public/audio/voice/hype_01.wav b/frontend/public/audio/voice/hype_01.wav new file mode 100644 index 0000000..2585137 Binary files /dev/null and b/frontend/public/audio/voice/hype_01.wav differ diff --git a/frontend/public/audio/voice/hype_02.wav b/frontend/public/audio/voice/hype_02.wav new file mode 100644 index 0000000..1579888 Binary files /dev/null and b/frontend/public/audio/voice/hype_02.wav differ diff --git a/frontend/public/audio/voice/hype_03.wav b/frontend/public/audio/voice/hype_03.wav new file mode 100644 index 0000000..e90c84b Binary files /dev/null and b/frontend/public/audio/voice/hype_03.wav differ diff --git a/frontend/public/audio/voice/hype_04.wav b/frontend/public/audio/voice/hype_04.wav new file mode 100644 index 0000000..dd00bc7 Binary files /dev/null and b/frontend/public/audio/voice/hype_04.wav differ diff --git a/frontend/public/audio/voice/hype_05.wav b/frontend/public/audio/voice/hype_05.wav new file mode 100644 index 0000000..402cabe Binary files /dev/null and b/frontend/public/audio/voice/hype_05.wav differ diff --git a/frontend/public/audio/voice/hype_06.wav b/frontend/public/audio/voice/hype_06.wav new file mode 100644 index 0000000..103a111 Binary files /dev/null and b/frontend/public/audio/voice/hype_06.wav differ diff --git a/frontend/public/audio/voice/hype_07.wav b/frontend/public/audio/voice/hype_07.wav new file mode 100644 index 0000000..64fb42c Binary files /dev/null and b/frontend/public/audio/voice/hype_07.wav differ diff --git a/frontend/public/audio/voice/hype_08.wav b/frontend/public/audio/voice/hype_08.wav new file mode 100644 index 0000000..15e99e3 Binary files /dev/null and b/frontend/public/audio/voice/hype_08.wav differ diff --git a/frontend/public/audio/voice/hype_09.wav b/frontend/public/audio/voice/hype_09.wav new file mode 100644 index 0000000..83aea20 Binary files /dev/null and b/frontend/public/audio/voice/hype_09.wav differ diff --git a/frontend/public/audio/voice/hype_10.wav b/frontend/public/audio/voice/hype_10.wav new file mode 100644 index 0000000..a1b8a11 Binary files /dev/null and b/frontend/public/audio/voice/hype_10.wav differ diff --git a/frontend/public/audio/voice/intro_01.wav b/frontend/public/audio/voice/intro_01.wav new file mode 100644 index 0000000..5fe2c9e Binary files /dev/null and b/frontend/public/audio/voice/intro_01.wav differ diff --git a/frontend/public/audio/voice/intro_02.wav b/frontend/public/audio/voice/intro_02.wav new file mode 100644 index 0000000..1f98d69 Binary files /dev/null and b/frontend/public/audio/voice/intro_02.wav differ diff --git a/frontend/public/audio/voice/intro_03.wav b/frontend/public/audio/voice/intro_03.wav new file mode 100644 index 0000000..7bf5c92 Binary files /dev/null and b/frontend/public/audio/voice/intro_03.wav differ diff --git a/frontend/public/audio/voice/intro_04.wav b/frontend/public/audio/voice/intro_04.wav new file mode 100644 index 0000000..815427e Binary files /dev/null and b/frontend/public/audio/voice/intro_04.wav differ diff --git a/frontend/public/audio/voice/intro_05.wav b/frontend/public/audio/voice/intro_05.wav new file mode 100644 index 0000000..3a4a1ad Binary files /dev/null and b/frontend/public/audio/voice/intro_05.wav differ diff --git a/frontend/public/audio/voice/intro_06.wav b/frontend/public/audio/voice/intro_06.wav new file mode 100644 index 0000000..8ea70ec Binary files /dev/null and b/frontend/public/audio/voice/intro_06.wav differ diff --git a/frontend/public/audio/voice/intro_07.wav b/frontend/public/audio/voice/intro_07.wav new file mode 100644 index 0000000..8e27942 Binary files /dev/null and b/frontend/public/audio/voice/intro_07.wav differ diff --git a/frontend/public/audio/voice/intro_08.wav b/frontend/public/audio/voice/intro_08.wav new file mode 100644 index 0000000..85f8f77 Binary files /dev/null and b/frontend/public/audio/voice/intro_08.wav differ diff --git a/frontend/public/audio/voice/intro_09.wav b/frontend/public/audio/voice/intro_09.wav new file mode 100644 index 0000000..a17a1c6 Binary files /dev/null and b/frontend/public/audio/voice/intro_09.wav differ diff --git a/frontend/public/audio/voice/intro_10.wav b/frontend/public/audio/voice/intro_10.wav new file mode 100644 index 0000000..f7d4ad8 Binary files /dev/null and b/frontend/public/audio/voice/intro_10.wav differ diff --git a/frontend/public/audio/voice/intro_11.wav b/frontend/public/audio/voice/intro_11.wav new file mode 100644 index 0000000..ea22651 Binary files /dev/null and b/frontend/public/audio/voice/intro_11.wav differ diff --git a/frontend/public/audio/voice/intro_12.wav b/frontend/public/audio/voice/intro_12.wav new file mode 100644 index 0000000..ad661b9 Binary files /dev/null and b/frontend/public/audio/voice/intro_12.wav differ diff --git a/frontend/public/audio/voice/intro_13.wav b/frontend/public/audio/voice/intro_13.wav new file mode 100644 index 0000000..6d2ce18 Binary files /dev/null and b/frontend/public/audio/voice/intro_13.wav differ diff --git a/frontend/public/audio/voice/intro_14.wav b/frontend/public/audio/voice/intro_14.wav new file mode 100644 index 0000000..703bf77 Binary files /dev/null and b/frontend/public/audio/voice/intro_14.wav differ diff --git a/frontend/public/audio/voice/intro_15.wav b/frontend/public/audio/voice/intro_15.wav new file mode 100644 index 0000000..254bf80 Binary files /dev/null and b/frontend/public/audio/voice/intro_15.wav differ diff --git a/frontend/public/audio/voice/ko.wav b/frontend/public/audio/voice/ko.wav new file mode 100644 index 0000000..8b4433f Binary files /dev/null and b/frontend/public/audio/voice/ko.wav differ diff --git a/frontend/public/audio/voice/rhype_01.wav b/frontend/public/audio/voice/rhype_01.wav new file mode 100644 index 0000000..02bb140 Binary files /dev/null and b/frontend/public/audio/voice/rhype_01.wav differ diff --git a/frontend/public/audio/voice/rhype_02.wav b/frontend/public/audio/voice/rhype_02.wav new file mode 100644 index 0000000..ae3577e Binary files /dev/null and b/frontend/public/audio/voice/rhype_02.wav differ diff --git a/frontend/public/audio/voice/rhype_03.wav b/frontend/public/audio/voice/rhype_03.wav new file mode 100644 index 0000000..1968cb2 Binary files /dev/null and b/frontend/public/audio/voice/rhype_03.wav differ diff --git a/frontend/public/audio/voice/rhype_04.wav b/frontend/public/audio/voice/rhype_04.wav new file mode 100644 index 0000000..55dbff5 Binary files /dev/null and b/frontend/public/audio/voice/rhype_04.wav differ diff --git a/frontend/public/audio/voice/rhype_05.wav b/frontend/public/audio/voice/rhype_05.wav new file mode 100644 index 0000000..12a934b Binary files /dev/null and b/frontend/public/audio/voice/rhype_05.wav differ diff --git a/frontend/public/audio/voice/rhype_06.wav b/frontend/public/audio/voice/rhype_06.wav new file mode 100644 index 0000000..13ab6d4 Binary files /dev/null and b/frontend/public/audio/voice/rhype_06.wav differ diff --git a/frontend/public/audio/voice/rhype_07.wav b/frontend/public/audio/voice/rhype_07.wav new file mode 100644 index 0000000..d14afad Binary files /dev/null and b/frontend/public/audio/voice/rhype_07.wav differ diff --git a/frontend/public/audio/voice/rhype_08.wav b/frontend/public/audio/voice/rhype_08.wav new file mode 100644 index 0000000..61557d1 Binary files /dev/null and b/frontend/public/audio/voice/rhype_08.wav differ diff --git a/frontend/public/audio/voice/rhype_09.wav b/frontend/public/audio/voice/rhype_09.wav new file mode 100644 index 0000000..f7eb743 Binary files /dev/null and b/frontend/public/audio/voice/rhype_09.wav differ diff --git a/frontend/public/audio/voice/rhype_10.wav b/frontend/public/audio/voice/rhype_10.wav new file mode 100644 index 0000000..7bc589b Binary files /dev/null and b/frontend/public/audio/voice/rhype_10.wav differ diff --git a/frontend/public/audio/voice/rhype_11.wav b/frontend/public/audio/voice/rhype_11.wav new file mode 100644 index 0000000..26cf5f1 Binary files /dev/null and b/frontend/public/audio/voice/rhype_11.wav differ diff --git a/frontend/public/audio/voice/rhype_12.wav b/frontend/public/audio/voice/rhype_12.wav new file mode 100644 index 0000000..35daaf3 Binary files /dev/null and b/frontend/public/audio/voice/rhype_12.wav differ diff --git a/frontend/public/audio/voice/rhype_13.wav b/frontend/public/audio/voice/rhype_13.wav new file mode 100644 index 0000000..ec40f8a Binary files /dev/null and b/frontend/public/audio/voice/rhype_13.wav differ diff --git a/frontend/public/audio/voice/rhype_14.wav b/frontend/public/audio/voice/rhype_14.wav new file mode 100644 index 0000000..84ee44d Binary files /dev/null and b/frontend/public/audio/voice/rhype_14.wav differ diff --git a/frontend/public/audio/voice/rhype_15.wav b/frontend/public/audio/voice/rhype_15.wav new file mode 100644 index 0000000..aa42f35 Binary files /dev/null and b/frontend/public/audio/voice/rhype_15.wav differ diff --git a/frontend/public/audio/voice/round_1.wav b/frontend/public/audio/voice/round_1.wav new file mode 100644 index 0000000..c96af95 Binary files /dev/null and b/frontend/public/audio/voice/round_1.wav differ diff --git a/frontend/public/audio/voice/round_2.wav b/frontend/public/audio/voice/round_2.wav new file mode 100644 index 0000000..a6fabc0 Binary files /dev/null and b/frontend/public/audio/voice/round_2.wav differ diff --git a/frontend/public/audio/voice/round_3.wav b/frontend/public/audio/voice/round_3.wav new file mode 100644 index 0000000..5906ef3 Binary files /dev/null and b/frontend/public/audio/voice/round_3.wav differ diff --git a/frontend/public/audio/voice/round_4.wav b/frontend/public/audio/voice/round_4.wav new file mode 100644 index 0000000..ff49200 Binary files /dev/null and b/frontend/public/audio/voice/round_4.wav differ diff --git a/frontend/public/audio/voice/round_5.wav b/frontend/public/audio/voice/round_5.wav new file mode 100644 index 0000000..cad61d5 Binary files /dev/null and b/frontend/public/audio/voice/round_5.wav differ diff --git a/frontend/public/audio/voice/round_6.wav b/frontend/public/audio/voice/round_6.wav new file mode 100644 index 0000000..49c392f Binary files /dev/null and b/frontend/public/audio/voice/round_6.wav differ diff --git a/frontend/public/audio/voice/round_7.wav b/frontend/public/audio/voice/round_7.wav new file mode 100644 index 0000000..44277e9 Binary files /dev/null and b/frontend/public/audio/voice/round_7.wav differ diff --git a/frontend/scripts/generate-voice-files.mjs b/frontend/scripts/generate-voice-files.mjs new file mode 100644 index 0000000..0eb5fac --- /dev/null +++ b/frontend/scripts/generate-voice-files.mjs @@ -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) diff --git a/frontend/src/game/audio/voice.ts b/frontend/src/game/audio/voice.ts index f8d7db5..295dd5d 100644 --- a/frontend/src/game/audio/voice.ts +++ b/frontend/src/game/audio/voice.ts @@ -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 diff --git a/frontend/src/game/tts.ts b/frontend/src/game/tts.ts index 1c8670a..3ddd3f2 100644 --- a/frontend/src/game/tts.ts +++ b/frontend/src/game/tts.ts @@ -105,6 +105,116 @@ const VOICE_MAP: Record = { // 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 = {} + +// 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>() +async function _loadStaticAudio(url: string): Promise { + 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 = [ - { 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 { - 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 { - 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 { - 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),