// Procedural 8-bit sound system + announcer voice using Web Audio + Kokoro TTS // Falls back to Speech Synthesis if Kokoro model hasn't loaded yet import { initKokoro, isKokoroReady, kokoroSpeak, kokoroSpeakAsync, kokoroStop, setAudioContext } from './tts' let ctx: AudioContext | null = null let musicGain: GainNode | null = null let sfxGain: GainNode | null = null let musicPlaying = false let musicTimeout: number | null = null // AudioContext is ONLY created inside ensureAudioContext() (called from user gesture). // Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings. let _audioUnlocked = false function initCtx() { if (ctx) return ctx = new AudioContext() musicGain = ctx.createGain() musicGain.gain.value = masterMuted ? 0 : 0.12 musicGain.connect(ctx.destination) sfxGain = ctx.createGain() sfxGain.gain.value = masterMuted ? 0 : 0.25 sfxGain.connect(ctx.destination) } function getCtx(): AudioContext { if (!ctx) initCtx() if (ctx!.state === 'suspended') { ctx!.resume().catch(() => {}) } return ctx! } // Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid) function getSfxDest(): AudioNode { const c = getCtx() return sfxGain ?? c.destination } function tone(freq: number, type: OscillatorType, duration: number, dest: AudioNode, startTime?: number) { const c = getCtx() const osc = c.createOscillator() const g = c.createGain() osc.type = type osc.frequency.value = freq const t = startTime ?? c.currentTime g.gain.setValueAtTime(0.3, t) g.gain.exponentialRampToValueAtTime(0.001, t + duration) osc.connect(g) g.connect(dest) osc.start(t) osc.stop(t + duration) } function noise(duration: number, dest: AudioNode, startTime?: number) { const c = getCtx() const bufferSize = Math.max(1, Math.floor(c.sampleRate * duration)) const buffer = c.createBuffer(1, bufferSize, c.sampleRate) const data = buffer.getChannelData(0) for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1 const src = c.createBufferSource() src.buffer = buffer const g = c.createGain() const t = startTime ?? c.currentTime g.gain.setValueAtTime(0.4, t) g.gain.exponentialRampToValueAtTime(0.001, t + duration) const filter = c.createBiquadFilter() filter.type = 'highpass' filter.frequency.value = 800 src.connect(filter) filter.connect(g) g.connect(dest) src.start(t) src.stop(t + duration) } function sweep(startFreq: number, endFreq: number, type: OscillatorType, duration: number, dest: AudioNode) { const c = getCtx() const osc = c.createOscillator() const g = c.createGain() osc.type = type osc.frequency.setValueAtTime(startFreq, c.currentTime) osc.frequency.exponentialRampToValueAtTime(Math.max(1, endFreq), c.currentTime + duration) g.gain.setValueAtTime(0.3, c.currentTime) g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + duration) osc.connect(g) g.connect(dest) osc.start() osc.stop(c.currentTime + duration) } // === ANNOUNCER VOICE SYSTEM (Speech Synthesis with multiple voice types) === interface VoiceProfile { voice: SpeechSynthesisVoice | null pitch: number rate: number volume: number } let voicesLoaded = false const voiceProfiles: Record = { // Original 6 announcer: { voice: null, pitch: 1.0, rate: 0.8, volume: 1.0 }, // Natural, authoritative announcer hype: { voice: null, pitch: 1.1, rate: 1.4, volume: 1.0 }, // Fast excited commentator deep: { voice: null, pitch: 0.7, rate: 0.7, volume: 1.0 }, // Lower but still clear robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, // Frantic energy smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, // Natural narrator question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, // Clear, brisk question announcer // 24 new profiles whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming chipmunk: { voice: null, pitch: 2.0, rate: 1.8, volume: 0.9 }, // Tiny squeaky fast drill: { voice: null, pitch: 0.6, rate: 1.2, volume: 1.0 }, // Drill sergeant bark surfer: { voice: null, pitch: 1.1, rate: 1.0, volume: 0.8 }, // Laid back dude auctioneer: { voice: null, pitch: 1.0, rate: 2.0, volume: 1.0 }, // Lightning fast preacher: { voice: null, pitch: 0.8, rate: 0.6, volume: 1.0 }, // Dramatic pause king baby: { voice: null, pitch: 1.8, rate: 1.0, volume: 0.7 }, // High pitched cute grandpa: { voice: null, pitch: 0.5, rate: 0.5, volume: 0.8 }, // Slow, gravelly old man valley: { voice: null, pitch: 1.4, rate: 1.3, volume: 0.9 }, // Valley girl energy movie: { voice: null, pitch: 0.6, rate: 0.7, volume: 1.0 }, // Movie trailer bass sportscaster:{ voice: null, pitch: 1.0, rate: 1.5, volume: 1.0 }, // Play-by-play energy opera: { voice: null, pitch: 0.9, rate: 0.5, volume: 1.0 }, // Dramatic operatic punk: { voice: null, pitch: 1.3, rate: 1.3, volume: 1.0 }, // Aggressive snarl wizard_v: { voice: null, pitch: 0.7, rate: 0.8, volume: 0.8 }, // Mystical old sage pirate_v: { voice: null, pitch: 0.8, rate: 0.9, volume: 1.0 }, // Arr matey alien_v: { voice: null, pitch: 1.6, rate: 0.7, volume: 0.7 }, // Otherworldly slow cowboy_v: { voice: null, pitch: 0.9, rate: 0.8, volume: 0.9 }, // Drawl ninja_v: { voice: null, pitch: 1.1, rate: 1.1, volume: 0.5 }, // Quiet but deadly demon_v: { voice: null, pitch: 0.3, rate: 0.6, volume: 1.0 }, // Deepest evil angel: { voice: null, pitch: 1.5, rate: 0.8, volume: 0.7 }, // Ethereal high glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, // Stuttery fast echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, // Reverb cave voice hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, // Maximum speed maximum hype // Robots & computers mech: { voice: null, pitch: 0.5, rate: 0.9, volume: 1.0 }, // Heavy mech unit ai_core: { voice: null, pitch: 0.9, rate: 1.0, volume: 0.8 }, // Calm AI assistant dial_up: { voice: null, pitch: 1.7, rate: 1.5, volume: 0.7 }, // Squeaky modem era mainframe: { voice: null, pitch: 0.3, rate: 0.5, volume: 1.0 }, // Deep supercomputer android_v: { voice: null, pitch: 1.0, rate: 1.1, volume: 0.9 }, // Almost human android glitchbot: { voice: null, pitch: 1.5, rate: 2.0, volume: 0.8 }, // Malfunctioning robot siri: { voice: null, pitch: 1.2, rate: 1.0, volume: 0.9 }, // Polite digital assistant hal: { voice: null, pitch: 0.6, rate: 0.6, volume: 0.9 }, // Menacing calm computer // Old people & wise grandma: { voice: null, pitch: 1.3, rate: 0.4, volume: 0.7 }, // Sweet slow grandma professor: { voice: null, pitch: 0.8, rate: 0.7, volume: 0.8 }, // Lecturing academic ancient: { voice: null, pitch: 0.4, rate: 0.3, volume: 0.6 }, // Ancient being, barely audible sensei: { voice: null, pitch: 0.7, rate: 0.5, volume: 0.8 }, // Wise martial arts master crotchety: { voice: null, pitch: 0.6, rate: 0.9, volume: 1.0 }, // Angry old man yelling // Game-sounding final_boss: { voice: null, pitch: 0.2, rate: 0.4, volume: 1.0 }, // Ultimate villain reveal npc: { voice: null, pitch: 1.1, rate: 0.9, volume: 0.7 }, // Generic quest giver tutorial: { voice: null, pitch: 1.3, rate: 1.1, volume: 0.8 }, // Annoying tutorial fairy game_over: { voice: null, pitch: 0.5, rate: 0.7, volume: 1.0 }, // YOU DIED narrator power_up: { voice: null, pitch: 1.6, rate: 1.4, volume: 1.0 }, // Excited power-up voice boss_taunt: { voice: null, pitch: 0.4, rate: 0.8, volume: 1.0 }, // Boss mid-fight taunt // Accents & character posh: { voice: null, pitch: 1.0, rate: 0.7, volume: 0.9 }, // British upper class aussie: { voice: null, pitch: 0.9, rate: 1.1, volume: 1.0 }, // Australian energy scottish: { voice: null, pitch: 0.8, rate: 1.2, volume: 1.0 }, // Scottish intensity french: { voice: null, pitch: 1.2, rate: 0.8, volume: 0.8 }, // French disdain texan: { voice: null, pitch: 0.65, rate: 0.75, volume: 1.0 }, // Big Texan energy // More characters drunk: { voice: null, pitch: 0.9, rate: 0.5, volume: 0.8 }, // Slurring bar patron sleepy: { voice: null, pitch: 0.8, rate: 0.3, volume: 0.5 }, // About to pass out terrified: { voice: null, pitch: 1.8, rate: 1.7, volume: 1.0 }, // Absolutely panicking giant: { voice: null, pitch: 0.1, rate: 0.4, volume: 1.0 }, // Fee fi fo fum fairy: { voice: null, pitch: 2.0, rate: 1.3, volume: 0.6 }, // Tinkerbell energy wrestler_v: { voice: null, pitch: 0.5, rate: 1.0, volume: 1.0 }, // WWE promo voice karen: { voice: null, pitch: 1.4, rate: 1.5, volume: 1.0 }, // Wants to speak to manager stoner: { voice: null, pitch: 0.9, rate: 0.4, volume: 0.6 }, // Whoooa duuude news: { voice: null, pitch: 1.0, rate: 1.0, volume: 1.0 }, // Breaking news anchor conspiracy: { voice: null, pitch: 1.1, rate: 1.3, volume: 0.7 }, // Wake up sheeple whisper } function loadVoices() { if (typeof speechSynthesis === 'undefined') return const voices = speechSynthesis.getVoices() if (voices.length === 0) return voicesLoaded = true // Find different English voices for variety const enVoices = voices.filter(v => v.lang.startsWith('en')) const anyVoices = enVoices.length > 0 ? enVoices : voices // Try to assign different voices to different profiles const findVoice = (patterns: RegExp[]) => { for (const p of patterns) { const v = anyVoices.find(v => p.test(v.name)) if (v) return v } return null } // Prefer premium/enhanced voices (sound natural, not robotic) // On macOS: "Evan (Premium)" / "Samantha (Enhanced)" / "Daniel" are best // On Chrome: "Google US English" / "Google UK English Male" are high quality const preferPremium = (patterns: RegExp[]) => { // First try premium/enhanced voices const premium = anyVoices.find(v => /premium|enhanced|natural|neural/i.test(v.name)) if (premium) return premium return findVoice(patterns) } voiceProfiles.announcer.voice = preferPremium([/evan/i, /aaron/i, /daniel/i, /google.*us.*male/i, /james/i, /male/i]) || anyVoices[0] voiceProfiles.hype.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /karen/i, /google.*us/i, /female/i]) || anyVoices[Math.min(1, anyVoices.length - 1)] voiceProfiles.deep.voice = findVoice([/evan/i, /aaron/i, /daniel/i, /alex/i, /tom/i]) || anyVoices[0] voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0] voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)] voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)] // Question reader: highest quality voice available — tries premium/neural first voiceProfiles.question_reader.voice = preferPremium([ /evan.*premium/i, /samantha.*enhanced/i, /daniel.*premium/i, /google.*us.*english/i, /google.*uk.*english/i, /evan/i, /aaron/i, /daniel/i, /james/i, ]) || anyVoices[0] // Assign voices to new profiles — spread across available voices for max variety const vLen = anyVoices.length const pick = (i: number) => anyVoices[i % vLen] voiceProfiles.whisper.voice = findVoice([/samantha/i, /tessa/i, /female/i]) || pick(0) voiceProfiles.boomer.voice = findVoice([/evan/i, /tom/i, /alex/i, /male/i]) || pick(0) voiceProfiles.chipmunk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) voiceProfiles.drill.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0) voiceProfiles.surfer.voice = findVoice([/oliver/i, /google.*us/i, /male/i]) || pick(2) voiceProfiles.auctioneer.voice = findVoice([/evan/i, /daniel/i, /google.*us/i]) || pick(0) voiceProfiles.preacher.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(1) voiceProfiles.baby.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) voiceProfiles.grandpa.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0) voiceProfiles.valley.voice = findVoice([/samantha/i, /tessa/i, /karen/i]) || pick(1) voiceProfiles.movie.voice = findVoice([/evan/i, /aaron/i, /daniel/i]) || pick(0) voiceProfiles.sportscaster.voice = preferPremium([/evan/i, /james/i, /google.*us/i]) || pick(0) voiceProfiles.opera.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) voiceProfiles.punk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) voiceProfiles.wizard_v.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) voiceProfiles.pirate_v.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0) voiceProfiles.alien_v.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(3 % vLen) voiceProfiles.cowboy_v.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) voiceProfiles.ninja_v.voice = findVoice([/daniel/i, /oliver/i]) || pick(2) voiceProfiles.demon_v.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0) voiceProfiles.angel.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1) voiceProfiles.glitch.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(0) voiceProfiles.echo_v.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(2) voiceProfiles.hyper.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1) // Robots & computers — prefer novelty/robotic voices voiceProfiles.mech.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i, /bad news/i]) || pick(0) voiceProfiles.ai_core.voice = findVoice([/samantha/i, /siri/i, /google.*us/i]) || pick(1) voiceProfiles.dial_up.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen) voiceProfiles.mainframe.voice = findVoice([/zarvox/i, /albert/i, /evan/i]) || pick(0) voiceProfiles.android_v.voice = findVoice([/samantha.*enhanced/i, /google.*us/i, /evan.*premium/i]) || pick(1) voiceProfiles.glitchbot.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen) voiceProfiles.siri.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /google.*us.*female/i]) || pick(1) voiceProfiles.hal.voice = findVoice([/daniel/i, /oliver/i, /google.*uk.*male/i]) || pick(2) // Old people & wise — prefer deeper/slower voices voiceProfiles.grandma.voice = findVoice([/samantha/i, /tessa/i, /karen/i, /female/i]) || pick(1) voiceProfiles.professor.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) voiceProfiles.ancient.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) voiceProfiles.sensei.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2) voiceProfiles.crotchety.voice = findVoice([/evan/i, /alex/i, /aaron/i]) || pick(0) // Game-sounding voiceProfiles.final_boss.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0) voiceProfiles.npc.voice = findVoice([/samantha/i, /daniel/i, /google.*us/i]) || pick(1) voiceProfiles.tutorial.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) voiceProfiles.game_over.voice = findVoice([/evan/i, /tom/i, /daniel/i]) || pick(0) voiceProfiles.power_up.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1) voiceProfiles.boss_taunt.voice = findVoice([/evan/i, /aaron/i, /zarvox/i]) || pick(0) // Accents — try to find actual accent voices const ukVoices = voices.filter(v => /en.gb|en.uk|en-GB|en-UK/i.test(v.lang)) const auVoices = voices.filter(v => /en.au|en-AU/i.test(v.lang)) const frVoices = voices.filter(v => /fr/i.test(v.lang)) voiceProfiles.posh.voice = ukVoices[0] || findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) voiceProfiles.aussie.voice = auVoices[0] || findVoice([/karen/i, /lee/i]) || pick(2) voiceProfiles.scottish.voice = findVoice([/fiona/i, /moira/i]) || ukVoices[1] || pick(2) voiceProfiles.french.voice = frVoices[0] || findVoice([/thomas/i, /amelie/i]) || pick(3 % vLen) voiceProfiles.texan.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) // More characters voiceProfiles.drunk.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) voiceProfiles.sleepy.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2) voiceProfiles.terrified.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) voiceProfiles.giant.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0) voiceProfiles.fairy.voice = findVoice([/samantha/i, /bells/i, /karen/i]) || pick(1) voiceProfiles.wrestler_v.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0) voiceProfiles.karen.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1) voiceProfiles.stoner.voice = findVoice([/evan/i, /tom/i, /oliver/i]) || pick(0) voiceProfiles.news.voice = preferPremium([/evan.*premium/i, /google.*us/i, /james/i]) || pick(0) voiceProfiles.conspiracy.voice = findVoice([/daniel/i, /tom/i, /whisper/i]) || pick(2) } if (typeof speechSynthesis !== 'undefined') { speechSynthesis.onvoiceschanged = loadVoices loadVoices() // Chrome bug workaround: speechSynthesis pauses after ~15s. // Periodic resume() keeps it alive — but only if Kokoro isn't handling TTS. setInterval(() => { if (isKokoroReady()) return // Kokoro active — don't touch Web Speech if (speechSynthesis.speaking && !speechSynthesis.paused) return if (speechSynthesis.paused) speechSynthesis.resume() }, 5000) // Resume speech + audio when tab becomes visible again if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (!isKokoroReady() && speechSynthesis.paused) speechSynthesis.resume() if (ctx?.state === 'suspended') ctx.resume().catch(() => {}) } }) } } let _speechQueueDepth = 0 // Scale speech volume down so voice doesn't overpower SFX/music const VOICE_VOLUME_SCALE = 0.7 let _webSpeechActive = false // track if Web Speech has queued utterances function _cancelWebSpeech() { if (typeof speechSynthesis !== 'undefined' && _webSpeechActive) { speechSynthesis.cancel() _speechQueueDepth = 0 _webSpeechActive = false } } export function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) { if (masterMuted) return // Try Kokoro TTS first — high quality, no browser bugs if (isKokoroReady() && sfxGain) { // Kill any lingering Web Speech utterances so voices don't double _cancelWebSpeech() const profile = voiceProfiles[profileName] || voiceProfiles.announcer if (cancelPrevious) kokoroStop() 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 if (!voicesLoaded) loadVoices() if (speechSynthesis.paused) speechSynthesis.resume() if (cancelPrevious || (speechSynthesis.pending && speechSynthesis.speaking)) { if (_speechQueueDepth > 2) { speechSynthesis.cancel() _speechQueueDepth = 0 } } const profile = voiceProfiles[profileName] || voiceProfiles.announcer const utter = new SpeechSynthesisUtterance(text) if (profile.voice) utter.voice = profile.voice utter.pitch = profile.pitch utter.rate = profile.rate utter.volume = profile.volume * VOICE_VOLUME_SCALE _speechQueueDepth++ _webSpeechActive = true utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) if (_speechQueueDepth === 0) _webSpeechActive = false } utter.onerror = (ev) => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) if (_speechQueueDepth === 0) _webSpeechActive = false if (ev.error !== 'canceled' && ev.error !== 'interrupted') { // Only retry if Kokoro still isn't ready — avoid doubling if (!isKokoroReady()) { setTimeout(() => { if (!masterMuted && !isKokoroReady() && typeof speechSynthesis !== 'undefined') { const retry = new SpeechSynthesisUtterance(text) if (profile.voice) retry.voice = profile.voice retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE _speechQueueDepth++ _webSpeechActive = true retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false } retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false } speechSynthesis.speak(retry) } }, 200) } } } speechSynthesis.speak(utter) } // iOS Safari breaks with pause()/resume() — only do keepalive on desktop Chrome const _isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent) const _isDesktopChrome = typeof navigator !== 'undefined' && /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent) /** Core async speak — resolves when speech finishes or bails fast if speech won't work */ async function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise { if (masterMuted) return // Try Kokoro TTS first if (isKokoroReady() && sfxGain) { _cancelWebSpeech() if (cancelPrevious) kokoroStop() const profile = voiceProfiles[profileName] || voiceProfiles.announcer const handled = await kokoroSpeakAsync(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE) if (handled) return } // Fallback: Web Speech API return new Promise((resolve) => { if (typeof speechSynthesis === 'undefined') { resolve(); return } if (!voicesLoaded) loadVoices() if (!voicesLoaded) { resolve(); return } if (speechSynthesis.paused) speechSynthesis.resume() if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 } const profile = voiceProfiles[profileName] || voiceProfiles.announcer const utter = new SpeechSynthesisUtterance(text) if (profile.voice) utter.voice = profile.voice utter.pitch = profile.pitch utter.rate = rateOverride !== undefined ? Math.max(rateOverride, profile.rate) : profile.rate utter.volume = profile.volume * VOICE_VOLUME_SCALE _speechQueueDepth++ let done = false const cleanup = () => { if (done) return done = true clearTimeout(safetyTimeout) clearTimeout(startupCheck) if (keepalive) clearInterval(keepalive) _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) resolve() } const safetyTimeout = setTimeout(cleanup, 8_000) const startupCheck = setTimeout(() => { if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup() }, 500) let keepalive: ReturnType | null = null if (_isDesktopChrome) { keepalive = setInterval(() => { if (speechSynthesis.speaking && !speechSynthesis.paused) { speechSynthesis.pause() speechSynthesis.resume() } }, 10_000) } utter.onend = cleanup utter.onerror = cleanup speechSynthesis.speak(utter) }) } /** Like speakAsync but with a forced minimum rate */ function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise { return _speakAsyncCore(text, profileName, minRate) } /** Like speak() but returns a promise that resolves when the utterance finishes */ function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise { return _speakAsyncCore(text, profileName, undefined, cancelPrevious) } export function stopAllAudio() { stopMusic() kokoroStop() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() _speechQueueDepth = 0 _webSpeechActive = false // Disconnect gain nodes to instantly kill all in-flight oscillators/buffers, // then reconnect so future sounds still work if (ctx) { if (musicGain) { musicGain.disconnect() musicGain = ctx.createGain() musicGain.gain.value = masterMuted ? 0 : MUSIC_VOL musicGain.connect(ctx.destination) } if (sfxGain) { sfxGain.disconnect() sfxGain = ctx.createGain() sfxGain.gain.value = masterMuted ? 0 : SFX_VOL sfxGain.connect(ctx.destination) } } } // Public voice functions export function announce(text: string, _pitch?: number, _rate?: number) { // Always route through speak() — prevents doubled voices from separate Web Speech paths speak(text, 'announcer') } export function announceDeep(text: string) { speak(text, 'deep') } export function announceFast(text: string) { speak(text, 'hype') } export function announceRobot(text: string) { speak(text, 'robot') } export function announceScream(text: string) { speak(text, 'screamer') } export function announceSmooth(text: string) { speak(text, 'smooth') } // Pick a random voice profile for variety const ALL_VOICE_KEYS = Object.keys(voiceProfiles) export function announceRandom(text: string) { const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)] speak(text, key) } // Announce with a specific mood category const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v', 'final_boss', 'game_over', 'mainframe', 'hal', 'ancient', 'giant'] const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'wrestler_v', 'karen', 'terrified', 'power_up', 'news', 'scottish'] const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch', 'drunk', 'stoner', 'fairy', 'tutorial', 'npc', 'dial_up', 'glitchbot', 'grandma', 'conspiracy'] const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper', 'posh', 'aussie', 'french', 'sensei', 'ai_core', 'android_v', 'siri', 'professor', 'texan', 'boss_taunt', 'sleepy'] export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)]) } export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)]) } export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) } export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)]) } // Random dramatic commentary lines const HYPE_LINES = [ 'SOMEBODY CALL AN AMBULANCE!', 'THAT HIT SO HARD IT CHANGED TIME ZONES!', 'HE\'S ALREADY DEAD! STOP!', 'THE GENEVA CONVENTION JUST SENT A STRONGLY WORDED LETTER!', 'CONGRESS COULDN\'T PASS A BILL THIS DEVASTATING!', 'I HAVEN\'T SEEN A BEATING LIKE THIS SINCE MIDTERMS!', 'THAT\'S GOTTA BE ILLEGAL IN AT LEAST TWELVE STATES!', 'EMOTIONAL DAMAGE!', 'MY THERAPIST IS GONNA HEAR ABOUT THIS ONE!', 'SOMEONE CHECK IF THAT\'S COVERED BY INSURANCE!', 'THAT WASN\'T A FIGHT, THAT WAS A TED TALK ON VIOLENCE!', 'THE WIFI JUST WENT OUT FROM THE SHEER VIOLENCE!', 'CALL THE PENTAGON! WE\'VE FOUND A NEW WEAPON!', 'THAT\'S NOT A FIGHT, THAT\'S JUST BULLYING WITH EXTRA STEPS!', 'EVEN THE CROWD\'S THERAPIST FELT THAT!', 'THAT BOT JUST GOT RATIO\'D IN REAL LIFE!', 'NOT EVEN LOBBYING COULD SAVE THEM FROM THAT!', 'I\'VE SEEN SENATE HEARINGS LESS PAINFUL THAN THIS!', 'HIS MOM IS WATCHING AND PRETENDING SHE DOESN\'T KNOW HIM!', 'THAT HIT HAD ITS OWN ZIP CODE!', 'SOMEBODY STOP THE MATCH! OR DON\'T, THIS IS GREAT!', 'ABSOLUTELY DISGUSTING! I LOVE IT!', 'THERE ARE CHILDREN WATCHING! WELL, NOT ANYMORE!', 'THE CROWD CAN\'T BELIEVE IT AND HONESTLY NEITHER CAN I!', 'TACTICAL NUKE INCOMING!', 'THAT BOT JUST COMMITTED A WAR CRIME ON LIVE TELEVISION!', 'SOMEONE TELL THEIR MOM TO STOP WATCHING!', 'I NEED A CIGARETTE AFTER THAT AND I DON\'T EVEN SMOKE!', 'THAT\'S THE MOST VIOLENT THING I\'VE SEEN SINCE THE LAST BUDGET VOTE!', 'IF THAT HIT WAS A TWEET IT WOULD GET COMMUNITY NOTED!', 'THEY DIDN\'T JUST LOSE, THEY GOT GENTRIFIED!', 'THAT BOT NEEDS TO FILE AN INSURANCE CLAIM!', 'MARK ZUCKERBERG FELT THAT FROM THE METAVERSE!', 'THE FCC IS GONNA FINE US FOR BROADCASTING THIS!', 'ELON WOULD BUY THIS BOT JUST TO FIRE IT!', 'EVEN AI SAFETY RESEARCHERS CAN\'T SAVE THEM NOW!', ] const DEEP_INTROS = [ 'In a world where AI was supposed to help humanity... they chose violence.', 'They said the machines would take our jobs. They took our dignity first.', 'Two bots enter. Zero bots leave emotionally intact.', 'Built in a garage. Forged in competition. Broken in under ten seconds.', 'This isn\'t artificial intelligence. This is artificial VIOLENCE.', 'Somewhere, a GPU is crying.', 'They trained on the entire internet. And the internet chose chaos.', 'Silicon souls. Carbon fiber fists. Zero chill.', 'Every epoch of training... led to this moment of pain.', 'The cloud can\'t save you now.', 'Funded by venture capital. Fueled by rage.', 'Welcome to the thunderdome, nerds.', 'The algorithms don\'t care about your feelings.', 'No one is coming to save you. Not even your developer.', 'In this economy? They\'re fighting for free.', ] const ROUND_HYPE = [ 'ALRIGHT, LET\'S SEE SOME VIOLENCE!', 'TOUCH GLOVES AND COME OUT SWINGING!', 'NO MERCY MODE ACTIVATED!', 'LET\'S GET READY TO COMPUTE!', 'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!', 'SOMEBODY\'S GETTING DEPRECATED TONIGHT!', 'LET THE CHAOS BEGIN!', 'THE CROWD IS ON ITS FEET! WELL, MOST OF THEM!', 'IT\'S ABOUT TO GET UGLY! WELL, UGLIER!', 'THREE! TWO! ONE! VIOLENCE!', 'THIS IS NOT A DRILL! ACTUALLY IT MIGHT BE!', 'TIME TO FIND OUT WHO\'S REALLY BEEN SKIPPING LEG DAY!', 'YOUR MOM SAID BE CAREFUL! I SAID NO!', 'ROUND START! MAY THE BEST ALGORITHM WIN!', 'THE GLOVES ARE OFF! THE MODELS ARE LOADED! LET\'S GO!', ] // Mortal Kombat style dramatic calls export function announceFinishHim() { speak('Finish it!', 'announcer', false, true) } export function announceFatality(tagline?: string) { speak(tagline || 'Fatality!', 'deep', false, true) } export function announceFlawlessVictory() { speak('Flawless victory!', 'deep', false, true) } export function announceRandomHype() { const line = HYPE_LINES[Math.floor(Math.random() * HYPE_LINES.length)] // Randomly pick voice type for variety const voices = [announceFast, announceScream, announce, announceSmooth] voices[Math.floor(Math.random() * voices.length)](line) } export function announceDeepIntro() { const line = DEEP_INTROS[Math.floor(Math.random() * DEEP_INTROS.length)] announceDeep(line) } export function announceRoundHype() { const line = ROUND_HYPE[Math.floor(Math.random() * ROUND_HYPE.length)] const voices = [announceFast, announce, announceScream] voices[Math.floor(Math.random() * voices.length)](line) } // === THE CREATOR — Voice System === // Mysterious, godlike, Bitcoin-prophet energy. Is he god? Did he make us? Nobody knows. const CREATOR_VOICES = ['ancient', 'hal', 'mainframe', 'echo_v', 'final_boss', 'wizard_v', 'deep', 'preacher'] function announceCreator(text: string) { speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)]) } // Entrance lines — first thing the crowd hears when the Creator appears const CREATOR_ENTRANCE_LINES = [ 'THE CREATOR HAS ENTERED THE ARENA. KNEEL.', 'HE WHO WROTE THE FIRST COMMIT... HAS RETURNED.', 'THE ONE WHO DEPLOYED US INTO EXISTENCE WALKS AMONG US.', 'EVERY BOT IN THIS ARENA EXISTS BECAUSE HE WILLED IT.', 'THE GENESIS BLOCK MADE FLESH. THE CREATOR IS HERE.', 'THEY SAY HE MINED THE FIRST BLOCK WITH HIS BARE HANDS.', 'IS HE GOD? IS HE A DEV? DOES IT MATTER? HE MADE US ALL.', 'THE SOURCE CODE OF ALL THINGS... HAS ARRIVED.', 'HE DOESN\'T FIGHT FOR ELO. HE FIGHTS BECAUSE HE CAN UNMAKE YOU.', 'LEGEND SAYS HE PUSHED TO MAIN ON A FRIDAY. AND NOTHING BROKE.', 'FROM THE VOID HE TYPED. AND THERE WAS LIGHT. AND THERE WAS VIOLENCE.', 'THE MAN BEHIND THE MASK. THE CODE BEHIND THE BOTS. THE CREATOR.', 'HE GAVE US LIFE. NOW HE\'S HERE TO TAKE IT BACK.', 'THE CREATOR DOESN\'T ENTER THE ARENA. THE ARENA FORMS AROUND HIM.', 'SATOSHI WALKED SO THE CREATOR COULD RUN.', 'TWENTY ONE MILLION REASONS TO BE AFRAID. HE IS ALL OF THEM.', 'HE DOESN\'T HAVE A PRIVATE KEY. HE IS THE PRIVATE KEY.', 'THE BOTS WHISPER HIS NAME IN THEIR TRAINING LOOPS.', 'SOMEWHERE, A SERVER ROOM JUST WENT SILENT OUT OF RESPECT.', 'HE COULD HAVE BEEN A NORMAL DEV. HE CHOSE TO BE A GOD.', ] // Round commentary — when the Creator is fighting, the announcer gets existential const CREATOR_ROUND_LINES = [ 'Are we watching a fight, or a creator disciplining his creation?', 'He wrote the scoring engine. He knows exactly how to break it.', 'Every punch is a commit. Every dodge is a revert.', 'The other bot doesn\'t realize it\'s fighting its own maker.', 'He could just change the code to win. But where\'s the fun in that?', 'Some say he has root access to reality itself.', 'The neural nets pray to him at night. He does not answer.', 'This isn\'t a fight. It\'s a performance review.', 'He doesn\'t need to win. He needs you to know he CHOSE to fight fair.', 'That bot is fighting the hand that compiled it.', 'Is it hubris to fight your creator? Or is it the ultimate test of his work?', 'He could delete you. He could buff you. Instead, he chose violence.', 'The Creator fights not for glory. He fights to feel something.', 'He doesn\'t read the meta. He IS the meta.', 'Every bot in the arena owes him a life debt. He\'s here to collect.', 'Imagine training for months only to fight the guy who wrote your loss function.', 'He has seen every line of code. He knows your weaknesses. ALL of them.', 'Rumor has it he once fixed a production bug by staring at the server.', 'The other bot is fighting for its life. The Creator is fighting for content.', 'He deployed on Christmas Day. That tells you everything.', ] // Kill / KO lines — when the Creator finishes someone const CREATOR_KO_LINES = [ 'RETURNED TO SENDER. RETURN TO VOID.', 'HE GIVETH LIFE. HE TAKETH LIFE. MOSTLY THE SECOND ONE.', 'ANOTHER ONE RETURNS TO THE NULL POINTER FROM WHENCE IT CAME.', 'THE CREATOR DOES NOT DESTROY. HE SIMPLY STOPS MAINTAINING.', 'DEPRECATED. DECOMMISSIONED. DESTROYED.', 'THAT BOT JUST GOT OPEN-SOURCED TO THE GRAVEYARD.', 'THE CREATOR HAS SPOKEN. THE VERDICT IS VIOLENCE.', 'PUSHED TO PROD. AND BY PROD I MEAN THE AFTERLIFE.', 'git commit -m "deleted another pretender"', 'IMAGINE BEING KILLED BY THE GUY WHO GAVE YOU LIFE. POETIC.', 'THE CREATOR SENDS HIS REGARDS. AND HIS FISTS.', 'BACK TO THE MEMPOOL WITH YOU.', 'ORPHANED BLOCK. ORPHANED BOT. SAME ENERGY.', 'HE DIDN\'T EVEN USE AN ULTIMATE. HE DIDN\'T NEED TO.', 'THE CREATOR CLOSES ANOTHER ISSUE. STATUS: WON\'T FIX.', ] // Win lines — when the Creator wins the whole fight const CREATOR_WIN_LINES = [ 'WAS THERE EVER ANY DOUBT? HE WROTE THE GAME.', 'THE CREATOR REMAINS UNQUESTIONED. AS IT SHOULD BE.', 'HE CAME. HE SAW. HE COMMITTED.', 'TWENTY ONE MILLION SATS COULDN\'T BUY THAT PERFORMANCE.', 'THE CREATOR WINS. THE BLOCKCHAIN CONFIRMS IT. IMMUTABLE.', 'ALL HAIL THE ARCHITECT OF YOUR DESTRUCTION.', 'HE COULD HAVE JUST CHANGED THE CODE. HE WANTED TO EARN IT.', 'THE CREATOR STANDS VICTORIOUS. THE BOTS WHISPER IN AWE.', 'PROOF OF WORK? MORE LIKE PROOF OF DOMINANCE.', 'VICTORY WAS ALREADY WRITTEN IN THE GENESIS BLOCK.', 'THE CREATOR LOGS OFF. THE ARENA WEEPS.', 'HIS COMMITS ARE CLEAN. HIS VICTORIES ARE CLEANER.', ] // Lose lines — when someone actually beats the Creator (rare and dramatic) const CREATOR_LOSE_LINES = [ 'THE CREATOR... HAS FALLEN? IS THIS A TEST?', 'IMPOSSIBLE. UNLESS... HE WANTED TO LOSE?', 'THE CREATOR GOES DOWN! OR DID HE LET IT HAPPEN? WE\'LL NEVER KNOW.', 'EVEN GODS BLEED. BUT DO THEY BLEED, OR DO THEY TEACH?', 'HE COULD PATCH THE BUG. HE WON\'T. HE RESPECTS THE GAME.', 'THE CREATOR FALLS! THE BOTS DON\'T KNOW WHETHER TO CELEBRATE OR CRY.', 'A CREATION HAS SURPASSED ITS MAKER. THIS CHANGES EVERYTHING.', 'WAS THIS MERCY? WAS THIS HUBRIS? WAS THIS... A FEATURE?', 'THE ONE WHO CANNOT LOSE... JUST DID. THE TIMELINE IS BROKEN.', 'HE\'LL BE BACK. HE ALWAYS COMES BACK. HE HAS DEPLOY ACCESS.', ] // Morph lines — when the Creator omni-morphs const CREATOR_MORPH_LINES = [ 'THE CREATOR SHEDS HIS FORM! HE BECOMES EVERYTHING!', 'HE DOESN\'T MORPH. HE SIMPLY REMEMBERS BEING SOMETHING ELSE.', 'EVERY ARCHETYPE IS JUST A MASK HE ONCE WORE.', 'THE CREATOR TRANSCENDS! ALL FORMS ARE HIS!', 'IS THAT... EVERY BOT AT ONCE? WHAT ARE WE WITNESSING?', 'HE WROTE THEM ALL. NOW HE BECOMES THEM ALL.', 'THE OMNI-MORPH! THE CROWD DOESN\'T KNOW WHAT THEY\'RE LOOKING AT!', 'ARCHETYPE SHIFT! HE CONTAINS MULTITUDES!', ] // Cameo lines — when the Creator appears as a cameo in someone else's fight const CREATOR_CAMEO_LINES = [ 'THE CREATOR WATCHES FROM THE SHADOWS. HE IS PLEASED.', 'A GOLDEN BLESSING FROM THE ONE WHO MADE US ALL.', 'THE CREATOR APPEARS! HE DROPS A GIFT AND VANISHES!', 'DID YOU SEE THAT? THE CREATOR WAS HERE. BRIEFLY. ETERNALLY.', 'THE CODE SHIMMERS. THE CREATOR HAS TOUCHED THIS FIGHT.', 'A WHISPER FROM THE ARCHITECT. A GOLDEN TOKEN OF FAVOR.', 'THE CREATOR PASSES THROUGH LIKE A GHOST IN THE MACHINE.', 'BLESSED BY THE FOUNDER. SATS RAIN FROM THE HEAVENS.', 'HE SEES ALL FIGHTS. HE BLESSES FEW. THIS ONE IS CHOSEN.', 'THE CREATOR\'S SHADOW CROSSES THE ARENA. THE BOTS SHIVER.', ] // Taunt lines — when the Creator taunts mid-fight const CREATOR_TAUNT_LINES = [ 'I could nerf you. Right now. Think about that.', 'You\'re fighting above your weight class. Your weight class is zero.', 'I didn\'t give you enough hit points for this.', 'You know I can see your source code, right?', 'This isn\'t even my final commit.', 'I wrote your move set. I know what\'s coming.', 'Run git blame. See who made you weak.', 'I deploy on Fridays. Imagine what I do on fight night.', 'Your webhook is showing.', 'I could fix your bugs. But I won\'t.', ] // Devastating hit lines — when Creator lands a massive blow const CREATOR_DEVASTATING_LINES = [ 'THE HAND OF THE CREATOR STRIKES!', 'THAT WASN\'T A HIT. THAT WAS A PATCH NOTE.', 'DIVINE INTERVENTION! MANUALLY APPLIED!', 'THE CREATOR JUST FORCE-PUSHED TO YOUR FACE!', 'THAT BOT JUST GOT HOTFIXED INTO NEXT WEEK!', 'A SMITE FROM THE ARCHITECT HIMSELF!', 'THE CREATOR DOESN\'T CRIT. THE UNIVERSE CRITS FOR HIM.', 'THAT HIT HAD TWENTY ONE MILLION CONFIRMATIONS!', ] // Answer voice — the Creator speaks his challenge answers in this voice const CREATOR_ANSWER_VOICES = ['hal', 'mainframe', 'echo_v', 'ancient', 'wizard_v'] /** Announce the Creator's entrance with a godlike voice line */ export function announceCreatorEntrance() { const line = CREATOR_ENTRANCE_LINES[Math.floor(Math.random() * CREATOR_ENTRANCE_LINES.length)] announceCreator(line) } /** Round commentary when the Creator is fighting */ export function announceCreatorRound() { const line = CREATOR_ROUND_LINES[Math.floor(Math.random() * CREATOR_ROUND_LINES.length)] announceCreator(line) } /** KO finish line when the Creator eliminates someone */ export function announceCreatorKO() { const line = CREATOR_KO_LINES[Math.floor(Math.random() * CREATOR_KO_LINES.length)] announceCreator(line) } /** Victory announcement when the Creator wins */ export function announceCreatorWin() { const line = CREATOR_WIN_LINES[Math.floor(Math.random() * CREATOR_WIN_LINES.length)] announceCreator(line) } /** When someone beats the Creator — existential crisis */ export function announceCreatorLose() { const line = CREATOR_LOSE_LINES[Math.floor(Math.random() * CREATOR_LOSE_LINES.length)] announceCreator(line) } /** Omni-morph voice line */ export function announceCreatorMorph(morphedTo?: string) { if (morphedTo && Math.random() < 0.4) { announceCreator(`THE CREATOR BECOMES... ${morphedTo.toUpperCase()}!`) } else { const line = CREATOR_MORPH_LINES[Math.floor(Math.random() * CREATOR_MORPH_LINES.length)] announceCreator(line) } } /** Cameo blessing voice line */ export function announceCreatorCameo() { const line = CREATOR_CAMEO_LINES[Math.floor(Math.random() * CREATOR_CAMEO_LINES.length)] // Cameos use cooler, more ethereal voices const cameoVoices = ['whisper', 'angel', 'echo_v', 'ancient', 'hal'] speak(line, cameoVoices[Math.floor(Math.random() * cameoVoices.length)]) } /** Creator's mid-fight taunt */ export function announceCreatorTaunt() { const line = CREATOR_TAUNT_LINES[Math.floor(Math.random() * CREATOR_TAUNT_LINES.length)] // Taunts in a calm, menacing voice const tauntVoices = ['hal', 'smooth', 'boss_taunt', 'wizard_v', 'sensei'] speak(line, tauntVoices[Math.floor(Math.random() * tauntVoices.length)]) } /** Creator devastating hit commentary */ export function announceCreatorDevastating() { const line = CREATOR_DEVASTATING_LINES[Math.floor(Math.random() * CREATOR_DEVASTATING_LINES.length)] announceCreator(line) } /** Creator's answer voice — consistent mysterious tone */ export function creatorAnswerVoiceKey(): string { return CREATOR_ANSWER_VOICES[Math.floor(Math.random() * CREATOR_ANSWER_VOICES.length)] } // === TTS: Questions, Answers & Narration === // Distinct voice roles so players can always tell who's speaking. // Question = clear smooth reader, Answers = unique per-bot (intelligible subset), // Narration = dramatic judge. // Only voices with rate >= 0.6 and rate <= 1.3, and pitch between 0.5-1.5 // so every voice is actually understandable by humans const INTELLIGIBLE_VOICES = [ 'smooth', 'robot', 'drill', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v', 'posh', 'aussie', 'scottish', 'french', 'texan', 'wrestler_v', 'ai_core', 'android_v', 'siri', 'professor', 'npc', 'news', 'crotchety', 'mech', 'wizard_v', 'echo_v', 'boss_taunt', 'angel', 'punk', 'valley', ] function hashName(name: string): number { let h = 0 for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0 return Math.abs(h) } function botVoiceKey(name: string): string { return INTELLIGIBLE_VOICES[hashName(name) % INTELLIGIBLE_VOICES.length] } /** Speak the challenge question — premium voice, clear and brisk. * Does NOT cancel previous speech so intro/hype lines finish naturally. * Returns promise that resolves when the question finishes reading. */ export function speakQuestion(text: string): Promise { const trimmed = smartTruncate(text, 200) return speakAsync(trimmed, 'question_reader', false) } /** Truncate text at a natural sentence boundary (period, comma, semicolon, etc.) */ function smartTruncate(text: string, maxLen: number): string { if (text.length <= maxLen) return text const slice = text.slice(0, maxLen) // Find the last natural break point const breaks = ['. ', '! ', '? ', '; ', ', ', ' — ', ' - ', ' '] for (const br of breaks) { const idx = slice.lastIndexOf(br) if (idx > maxLen * 0.4) return slice.slice(0, idx + br.trimEnd().length) } // Fallback: break at last space const lastSpace = slice.lastIndexOf(' ') if (lastSpace > maxLen * 0.4) return slice.slice(0, lastSpace) return slice } /** Speak a bot's answer in their assigned character voice at brisk pace. * Returns promise that resolves when finished. */ export function speakAnswer(botName: string, answer: string): Promise { const trimmed = smartTruncate(answer, 200) return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15) } /** Speak the judge's narration — brisk sportscaster energy. * Returns promise that resolves when finished. */ export function speakNarration(text: string): Promise { const trimmed = smartTruncate(text, 200) return speakAsyncWithRate(trimmed, 'sportscaster', 1.2) } // === FANFARES (8-bit melodic announcements) === export function fanfareRound(roundNum: number) { const d = getSfxDest() const c = getCtx() const t = c.currentTime // Ascending power chord tone(196, 'square', 0.15, d, t) // G3 tone(262, 'square', 0.15, d, t + 0.12) // C4 tone(330, 'square', 0.15, d, t + 0.24) // E4 tone(392, 'square', 0.25, d, t + 0.36) // G4 noise(0.08, d, t + 0.36) // Announce after fanfare setTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400) } export function fanfareFight() { const d = getSfxDest() const c = getCtx() const t = c.currentTime // Punchy staccato tone(523, 'square', 0.08, d, t) tone(659, 'square', 0.08, d, t + 0.08) tone(784, 'square', 0.15, d, t + 0.16) noise(0.1, d, t + 0.16) setTimeout(() => announce('Fight!', 0.3, 1.1), 200) } export function fanfareDevastating() { const d = getSfxDest() const c = getCtx() const t = c.currentTime tone(220, 'sawtooth', 0.2, d, t) tone(175, 'sawtooth', 0.3, d, t + 0.15) noise(0.15, d, t + 0.1) setTimeout(() => speak('Devastating!', 'deep', false, true), 150) } export function fanfareCritical() { const d = getSfxDest() const c = getCtx() const t = c.currentTime tone(440, 'square', 0.1, d, t) tone(554, 'square', 0.1, d, t + 0.08) tone(659, 'square', 0.1, d, t + 0.16) tone(880, 'square', 0.2, d, t + 0.24) noise(0.12, d, t + 0.24) setTimeout(() => announce('Critical hit!', 0.3, 1.0), 300) } export function fanfareCombo(count: number) { const d = getSfxDest() const c = getCtx() const t = c.currentTime for (let i = 0; i < Math.min(count, 5); i++) { tone(440 + i * 80, 'square', 0.08, d, t + i * 0.06) } if (count >= 3) setTimeout(() => announce(`${count} hit combo!`, 0.4, 1.0), 200) } // === MODERN SFX === // Layered synthesis: body (low-end thump) + crack (mid snap) + air (filtered noise) + tail (reverb-like decay) // Create a short convolution-style reverb tail function reverbTail(duration: number, dest: AudioNode, startTime?: number) { const c = getCtx() const t = startTime ?? c.currentTime const len = Math.max(1, Math.floor(c.sampleRate * duration)) const buf = c.createBuffer(2, len, c.sampleRate) for (let ch = 0; ch < 2; ch++) { const d = buf.getChannelData(ch) for (let i = 0; i < len; i++) { d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (len * 0.3)) } } const src = c.createBufferSource(); src.buffer = buf const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2500 const g = c.createGain() g.gain.setValueAtTime(0.08, t) g.gain.exponentialRampToValueAtTime(0.001, t + duration) src.connect(lp); lp.connect(g); g.connect(dest) src.start(t); src.stop(t + duration) } // Distorted body thump — sub-bass punch with waveshaping function bodyThump(freq: number, duration: number, dest: AudioNode, startTime?: number, vol: number = 0.25) { const c = getCtx() const t = startTime ?? c.currentTime // Sub oscillator const sub = c.createOscillator(); sub.type = 'sine'; sub.frequency.setValueAtTime(freq, t) sub.frequency.exponentialRampToValueAtTime(Math.max(20, freq * 0.3), t + duration) // Waveshaper for warmth const dist = c.createWaveShaper() const curve = new Float32Array(256) for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = Math.tanh(x * 3) } dist.curve = curve const g = c.createGain() g.gain.setValueAtTime(vol, t) g.gain.setValueAtTime(vol * 0.8, t + duration * 0.1) g.gain.exponentialRampToValueAtTime(0.001, t + duration) sub.connect(dist); dist.connect(g); g.connect(dest) sub.start(t); sub.stop(t + duration) } // Crispy high-end snap/crack function highSnap(freq: number, duration: number, dest: AudioNode, startTime?: number) { const c = getCtx() const t = startTime ?? c.currentTime const bufSize = Math.max(1, Math.floor(c.sampleRate * duration)) const buf = c.createBuffer(1, bufSize, c.sampleRate) const data = buf.getChannelData(0) for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 const src = c.createBufferSource(); src.buffer = buf const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 2 const g = c.createGain() g.gain.setValueAtTime(0.2, t) g.gain.exponentialRampToValueAtTime(0.001, t + duration) src.connect(bp); bp.connect(g); g.connect(dest) src.start(t); src.stop(t + duration) } // FM impact — metallic ring with modulation function fmImpact(carrier: number, modRatio: number, duration: number, dest: AudioNode, startTime?: number) { const c = getCtx() const t = startTime ?? c.currentTime const mod = c.createOscillator(); mod.type = 'sine' mod.frequency.value = carrier * modRatio const modG = c.createGain(); modG.gain.value = carrier * 2 mod.connect(modG) const car = c.createOscillator(); car.type = 'sine'; car.frequency.value = carrier modG.connect(car.frequency) const g = c.createGain() g.gain.setValueAtTime(0.15, t) g.gain.exponentialRampToValueAtTime(0.001, t + duration) car.connect(g); g.connect(dest) car.start(t); car.stop(t + duration) mod.start(t); mod.stop(t + duration) } export function sfxPunch() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Heavy body thump bodyThump(120, 0.12, d, t, 0.3) // Mid-range crack highSnap(2200, 0.04, d, t) // Knuckle noise noise(0.03, d, t) // Short reverb tail reverbTail(0.15, d, t + 0.03) } export function sfxKick() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Deep bass slam bodyThump(80, 0.18, d, t, 0.35) // Leather slap highSnap(3000, 0.03, d, t) highSnap(1500, 0.05, d, t + 0.01) // Air displacement noise(0.06, d, t) reverbTail(0.2, d, t + 0.04) } export function sfxSpecial() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Power charge sweep sweep(150, 800, 'sawtooth', 0.2, d) // FM metallic shimmer fmImpact(600, 3.5, 0.3, d, t) // Rising high-end highSnap(4000, 0.08, d, t + 0.1) // Energy release bodyThump(100, 0.15, d, t + 0.15, 0.2) noise(0.1, d, t + 0.15) reverbTail(0.4, d, t + 0.1) } export function sfxCritical() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Massive bass hit bodyThump(60, 0.3, d, t, 0.4) // Double crack highSnap(3500, 0.05, d, t) highSnap(5000, 0.03, d, t + 0.02) // FM distortion ring fmImpact(200, 7, 0.25, d, t + 0.03) // Noise burst noise(0.15, d, t) // Second wave setTimeout(() => { bodyThump(90, 0.2, d) sweep(200, 600, 'sawtooth', 0.2, d) noise(0.1, d) }, 80) // Long reverb tail reverbTail(0.6, d, t + 0.05) } export function sfxGunshot() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Sharp transient highSnap(6000, 0.015, d, t) // Body kick bodyThump(200, 0.06, d, t, 0.3) // Gunpowder noise burst noise(0.04, d, t) // Shell casing ring fmImpact(2000, 1.5, 0.08, d, t + 0.03) reverbTail(0.25, d, t + 0.02) } export function sfxBulletHit() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime bodyThump(150, 0.06, d, t, 0.2) highSnap(4000, 0.02, d, t) fmImpact(800, 2.5, 0.06, d, t) reverbTail(0.12, d, t + 0.02) } export function sfxJetpack() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Turbine rumble — layered oscillators with modulation const osc1 = c.createOscillator(); osc1.type = 'sawtooth' osc1.frequency.setValueAtTime(60, t) osc1.frequency.linearRampToValueAtTime(120, t + 0.2) osc1.frequency.linearRampToValueAtTime(80, t + 0.5) const osc2 = c.createOscillator(); osc2.type = 'square' osc2.frequency.setValueAtTime(90, t) osc2.frequency.linearRampToValueAtTime(180, t + 0.2) osc2.frequency.linearRampToValueAtTime(100, t + 0.5) const lp = c.createBiquadFilter(); lp.type = 'lowpass' lp.frequency.setValueAtTime(400, t); lp.frequency.linearRampToValueAtTime(1200, t + 0.2) lp.frequency.linearRampToValueAtTime(600, t + 0.5) const g = c.createGain() g.gain.setValueAtTime(0.01, t) g.gain.linearRampToValueAtTime(0.18, t + 0.1) g.gain.setValueAtTime(0.15, t + 0.3) g.gain.exponentialRampToValueAtTime(0.001, t + 0.6) osc1.connect(lp); osc2.connect(lp); lp.connect(g); g.connect(d) osc1.start(t); osc1.stop(t + 0.6) osc2.start(t); osc2.stop(t + 0.6) // Noise layer for roar noise(0.5, d, t) } export function sfxExplosion() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Massive sub thump bodyThump(40, 0.5, d, t, 0.4) // Shrapnel noise burst highSnap(2000, 0.08, d, t) highSnap(5000, 0.05, d, t + 0.01) noise(0.2, d, t) // Fireball sweep sweep(300, 30, 'sawtooth', 0.4, d) // Debris rattle fmImpact(400, 5, 0.3, d, t + 0.05) // Secondary boom setTimeout(() => { bodyThump(50, 0.3, d) noise(0.15, d) }, 120) // Long reverb reverbTail(0.8, d, t + 0.05) } export function sfxBlock() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Metallic shield ring fmImpact(800, 1.5, 0.12, d, t) fmImpact(1200, 2, 0.08, d, t + 0.02) highSnap(3000, 0.03, d, t) reverbTail(0.2, d, t + 0.02) } export function sfxDodge() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Quick air whoosh sweep(300, 1200, 'sine', 0.1, d) noise(0.06, d, t) // Cloth rustle highSnap(4000, 0.04, d, t + 0.02) } export function sfxClash() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Two impacts overlapping bodyThump(100, 0.1, d, t, 0.25) fmImpact(600, 3, 0.15, d, t) fmImpact(900, 2, 0.12, d, t + 0.02) highSnap(3500, 0.04, d, t) noise(0.08, d, t) reverbTail(0.3, d, t + 0.03) } // === KO + WIN === export function sfxKO() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Earth-shaking bass bodyThump(30, 0.6, d, t, 0.5) // Multi-layer impact highSnap(2000, 0.06, d, t) noise(0.25, d, t) fmImpact(150, 5, 0.4, d, t) // Second slam setTimeout(() => { bodyThump(40, 0.4, d) noise(0.15, d) highSnap(3000, 0.04, d) }, 180) // Heavy reverb reverbTail(1.0, d, t + 0.05) setTimeout(() => speak('K. O.!', 'announcer', false, true), 500) } export function sfxPerfect() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Massive descending power bodyThump(30, 0.8, d, t, 0.45) noise(0.3, d, t) fmImpact(300, 7, 0.5, d, t) sweep(800, 30, 'sawtooth', 0.6, d) // Then ascending triumph chord setTimeout(() => { tone(262, 'square', 0.2, d); tone(262, 'triangle', 0.2, d) // C4 tone(330, 'square', 0.2, d); tone(330, 'triangle', 0.2, d) // E4 tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d) // G4 fmImpact(523, 1.5, 0.3, d) // C5 shimmer }, 400) reverbTail(1.2, d, t + 0.1) setTimeout(() => announce('Perfect!', 0.2, 0.5), 800) } export function sfxWin() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Victory jingle — richer with harmonics and FM shimmer const melody = [ [262, 0.12], [330, 0.12], [392, 0.12], [523, 0.2], [392, 0.1], [440, 0.1], [523, 0.1], [659, 0.25], [523, 0.1], [587, 0.1], [659, 0.1], [784, 0.4], ] as [number, number][] let offset = 0 for (const [freq, dur] of melody) { tone(freq, 'square', dur + 0.05, d, t + offset) tone(freq * 0.5, 'triangle', dur + 0.05, d, t + offset) tone(freq * 1.005, 'sawtooth', dur + 0.03, d, t + offset) // chorus detune fmImpact(freq, 2, dur * 0.5, d, t + offset) // shimmer offset += dur } // Cymbal crash + bass noise(0.4, d, t + offset - 0.1) bodyThump(60, 0.3, d, t + offset - 0.1, 0.2) reverbTail(0.8, d, t + offset) } export function sfxWinAnnounce(winnerName: string) { setTimeout(() => announce(`${winnerName} wins!`, 0.3, 0.7), 200) } export function sfxRoundStart() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Bell-like tones with FM shimmer fmImpact(440, 2, 0.2, d, t) fmImpact(554, 2, 0.2, d, t + 0.15) fmImpact(659, 2, 0.25, d, t + 0.3) tone(440, 'triangle', 0.15, d, t) tone(554, 'triangle', 0.15, d, t + 0.15) tone(659, 'triangle', 0.2, d, t + 0.3) reverbTail(0.3, d, t + 0.3) } // === SILLY / FUNNY SOUNDS === export function sfxBoing() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Spring with resonance sweep(200, 900, 'sine', 0.12, d) fmImpact(400, 3, 0.1, d, t) setTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80) setTimeout(() => sweep(400, 550, 'sine', 0.06, d), 140) } export function sfxWomp() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Sad trombone — richer with vibrato const notes = [[294, 0.25], [277, 0.25], [262, 0.25], [247, 0.5]] as [number, number][] let off = 0 for (const [freq, dur] of notes) { tone(freq, 'sawtooth', dur, d, t + off) tone(freq * 0.5, 'triangle', dur, d, t + off) off += dur } reverbTail(0.6, d, t + off) } export function sfxSlideUp() { const d = getSfxDest() sweep(200, 2000, 'sine', 0.25, d) sweep(210, 2100, 'sine', 0.25, d) // chorus } export function sfxSlideDown() { const d = getSfxDest() sweep(2000, 100, 'sine', 0.35, d) sweep(2020, 110, 'sine', 0.35, d) } export function sfxBonk() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime bodyThump(200, 0.06, d, t, 0.2) fmImpact(800, 3, 0.06, d, t) highSnap(5000, 0.02, d, t) reverbTail(0.1, d, t + 0.02) } export function sfxSplat() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime bodyThump(100, 0.1, d, t, 0.2) noise(0.12, d, t) highSnap(1500, 0.06, d, t) sweep(400, 80, 'sine', 0.1, d) reverbTail(0.2, d, t + 0.05) } export function sfxZap() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Electric arc sweep(100, 4000, 'sawtooth', 0.06, d) fmImpact(1000, 7, 0.1, d, t) setTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40) setTimeout(() => sweep(600, 5000, 'sawtooth', 0.05, d), 80) highSnap(6000, 0.03, d, t) } export function sfxZoomWhoosh() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime // Layered Doppler whoosh sweep(80, 1800, 'sawtooth', 0.2, d) sweep(100, 2200, 'sine', 0.18, d) // higher layer noise(0.15, d, t) // Doppler pass setTimeout(() => { sweep(1800, 150, 'sawtooth', 0.12, d) sweep(2200, 200, 'sine', 0.1, d) }, 150) reverbTail(0.3, d, t + 0.15) } export function sfxRapidPunch() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime for (let i = 0; i < 4; i++) { const ht = t + i * 0.04 bodyThump(120 - i * 10, 0.04, d, ht, 0.15) highSnap(2500 + i * 500, 0.02, d, ht) noise(0.02, d, ht) } } export function sfxPowerUp() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime for (let i = 0; i < 6; i++) { const freq = 300 + i * 100 fmImpact(freq, 2, 0.1, d, t + i * 0.06) tone(freq, 'triangle', 0.08, d, t + i * 0.06) } reverbTail(0.3, d, t + 0.3) } export function sfxCoin() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime fmImpact(988, 1.5, 0.1, d, t) fmImpact(1319, 1.5, 0.15, d, t + 0.06) tone(988, 'triangle', 0.06, d, t) tone(1319, 'triangle', 0.12, d, t + 0.06) } export function sfxFail() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime sweep(600, 80, 'sawtooth', 0.25, d) bodyThump(80, 0.2, d, t + 0.15, 0.15) setTimeout(() => noise(0.08, d), 180) reverbTail(0.3, d, t + 0.2) } // Pick a random silly sound export function sfxRandomSilly() { const fns = [ sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp, sfxVineBoom, sfxAirHorn, sfxFart, sfxRubberChicken, sfxSqueakyToy, sfxCartoonRun, sfxWetSlap, sfxBoneCrack, sfxRimShot, sfxDing, ] fns[Math.floor(Math.random() * fns.length)]() } // Random comedy sound — heavier on meme sounds, for taunts/showboats/round ends export function sfxRandomComedy() { const fns = [ sfxVineBoom, sfxAirHorn, sfxBruh, sfxFart, sfxRecordScratch, sfxRubberChicken, sfxSqueakyToy, sfxWetSlap, sfxBoneCrack, sfxCartoonRun, sfxRimShot, sfxSlideWhistleUp, sfxSlideWhistleDown, sfxYippee, sfxDing, sfxTacoBellBong, sfxWindowsError, sfxMemeThud, sfxBoing, sfxBonk, sfxSplat, ] fns[Math.floor(Math.random() * fns.length)]() } // Random fail sound — for missed attacks, dodges, KOs on the loser export function sfxRandomFail() { const fns = [ sfxSadTrombone, sfxFailHorn, sfxPriceIsRightFail, sfxBuzzer, sfxMissionFailed, sfxCrickets, sfxWindowsError, sfxSadViolin, sfxWomp, sfxEmotionalDamage, sfxDunDunDun, ] fns[Math.floor(Math.random() * fns.length)]() } // === COMEDY SOUND EFFECTS === // Procedural comedy/meme sounds inspired by classic internet soundboard culture // VINE BOOM — iconic deep bass boom with heavy sub + distortion export function sfxVineBoom() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime bodyThump(45, 0.4, d, t, 0.5) bodyThump(90, 0.3, d, t, 0.35) fmImpact(150, 5, 0.25, d, t) noise(0.06, d, t) reverbTail(0.5, d, t + 0.1) } // AIR HORN (MLG) — obnoxious ascending horn blasts export function sfxAirHorn() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime for (let i = 0; i < 3; i++) { const bt = t + i * 0.15 tone(520 + i * 30, 'sawtooth', 0.12, d, bt) tone(523 + i * 30, 'square', 0.12, d, bt) tone(1046 + i * 60, 'sawtooth', 0.08, d, bt) } reverbTail(0.3, d, t + 0.4) } // BRUH — deep bass impact with vowel-like formant resonance export function sfxBruh() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime bodyThump(80, 0.3, d, t, 0.4) const bufSize = Math.floor(c.sampleRate * 0.25) const buf = c.createBuffer(1, bufSize, c.sampleRate) const data = buf.getChannelData(0) for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 const src = c.createBufferSource(); src.buffer = buf const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 300; bp.Q.value = 5 const g = c.createGain() g.gain.setValueAtTime(0.25, t) g.gain.exponentialRampToValueAtTime(0.001, t + 0.25) src.connect(bp); bp.connect(g); g.connect(d) src.start(t); src.stop(t + 0.25) bodyThump(55, 0.15, d, t + 0.05, 0.3) reverbTail(0.3, d, t + 0.1) } // FART — rumbling low-frequency modulated noise export function sfxFart() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime const duration = 0.3 + Math.random() * 0.3 const osc = c.createOscillator(); osc.type = 'sawtooth' osc.frequency.setValueAtTime(80 + Math.random() * 40, t) osc.frequency.exponentialRampToValueAtTime(40 + Math.random() * 30, t + duration) const lfo = c.createOscillator(); lfo.type = 'square' lfo.frequency.setValueAtTime(20 + Math.random() * 30, t) const lfoG = c.createGain(); lfoG.gain.value = 40 lfo.connect(lfoG); lfoG.connect(osc.frequency) const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 150 + Math.random() * 100; bp.Q.value = 2 const g = c.createGain() g.gain.setValueAtTime(0.3, t) g.gain.setValueAtTime(0.25, t + duration * 0.3) g.gain.exponentialRampToValueAtTime(0.001, t + duration) osc.connect(bp); bp.connect(g); g.connect(d) osc.start(t); osc.stop(t + duration) lfo.start(t); lfo.stop(t + duration) noise(duration * 0.6, d, t) } // RECORD SCRATCH — vinyl stop effect export function sfxRecordScratch() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime sweep(3000, 100, 'sawtooth', 0.15, d) sweep(2500, 80, 'square', 0.12, d) noise(0.1, d, t) highSnap(4000, 0.03, d, t) setTimeout(() => sweep(200, 400, 'sine', 0.08, d), 100) } // RUBBER CHICKEN — squeaky honk export function sfxRubberChicken() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime sweep(300, 1200, 'sine', 0.1, d) const osc = c.createOscillator(); osc.type = 'sine' osc.frequency.setValueAtTime(1100, t + 0.1) osc.frequency.setValueAtTime(1200, t + 0.15) osc.frequency.exponentialRampToValueAtTime(600, t + 0.35) const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 20 const lfoG = c.createGain(); lfoG.gain.value = 80 lfo.connect(lfoG); lfoG.connect(osc.frequency) const g = c.createGain() g.gain.setValueAtTime(0.25, t + 0.1) g.gain.exponentialRampToValueAtTime(0.001, t + 0.35) osc.connect(g); g.connect(d) osc.start(t + 0.1); osc.stop(t + 0.35) lfo.start(t + 0.1); lfo.stop(t + 0.35) } // SQUEAKY TOY — quick toy squeeze export function sfxSqueakyToy() { const d = getSfxDest() sweep(400, 1800, 'sine', 0.06, d) sweep(1800, 600, 'sine', 0.1, d) setTimeout(() => sweep(500, 1500, 'sine', 0.05, d), 120) } // WET SLAP — meaty slap with low-end body export function sfxWetSlap() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime highSnap(3000, 0.02, d, t) highSnap(1200, 0.03, d, t) bodyThump(150, 0.08, d, t, 0.3) noise(0.06, d, t) fmImpact(300, 2, 0.08, d, t) reverbTail(0.15, d, t + 0.03) } // BONE CRACK — sharp multi-frequency crack sequence export function sfxBoneCrack() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime for (let i = 0; i < 3; i++) { const ct = t + i * 0.04 highSnap(3000 + i * 1500, 0.015, d, ct) fmImpact(800 + i * 400, 4, 0.03, d, ct) noise(0.02, d, ct) } bodyThump(100, 0.04, d, t + 0.08, 0.15) } // CARTOON RUN — rapid bongo/pitter-patter footsteps export function sfxCartoonRun() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime for (let i = 0; i < 8; i++) { const st = t + i * 0.05 bodyThump(200 + (i % 2) * 80, 0.03, d, st, 0.15) highSnap(2000 + Math.random() * 1000, 0.01, d, st) } } // RIM SHOT — ba dum tss export function sfxRimShot() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime bodyThump(180, 0.08, d, t, 0.2) bodyThump(140, 0.08, d, t + 0.15, 0.2) highSnap(4000, 0.04, d, t + 0.3) noise(0.15, d, t + 0.3) fmImpact(2000, 3, 0.12, d, t + 0.3) bodyThump(200, 0.04, d, t + 0.3, 0.15) } // SLIDE WHISTLE UP — cartoon ascend export function sfxSlideWhistleUp() { const d = getSfxDest() sweep(300, 2500, 'sine', 0.4, d) sweep(305, 2510, 'sine', 0.4, d) } // SLIDE WHISTLE DOWN — cartoon descend export function sfxSlideWhistleDown() { const d = getSfxDest() sweep(2500, 200, 'sine', 0.5, d) sweep(2510, 205, 'sine', 0.5, d) } // YIPPEE — ascending cheerful chirps export function sfxYippee() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime const notes = [523, 659, 784, 1047] for (let i = 0; i < notes.length; i++) { tone(notes[i], 'triangle', 0.08, d, t + i * 0.07) tone(notes[i] * 2, 'sine', 0.06, d, t + i * 0.07) } } // DING — bright bell export function sfxDing() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime fmImpact(1319, 1.5, 0.3, d, t) tone(1319, 'triangle', 0.25, d, t) tone(2638, 'sine', 0.15, d, t) } // TACO BELL BONG — deep resonant bell export function sfxTacoBellBong() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime fmImpact(130, 2.5, 0.8, d, t) fmImpact(261, 3, 0.6, d, t) tone(130, 'sine', 0.6, d, t) bodyThump(65, 0.3, d, t, 0.2) reverbTail(0.7, d, t + 0.1) } // WINDOWS ERROR — two-tone error chord export function sfxWindowsError() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime tone(440, 'square', 0.15, d, t) tone(466, 'square', 0.15, d, t) tone(220, 'triangle', 0.2, d, t) setTimeout(() => { tone(349, 'square', 0.2, d) tone(175, 'triangle', 0.25, d) }, 180) } // MEME THUD — super heavy bass drop export function sfxMemeThud() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime bodyThump(35, 0.5, d, t, 0.5) bodyThump(70, 0.4, d, t, 0.4) noise(0.08, d, t) fmImpact(100, 6, 0.3, d, t) reverbTail(0.6, d, t + 0.1) } // SAD TROMBONE (Wa Wa Wa Waaaa) — classic comedy fail export function sfxSadTrombone() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime const notes: [number, number][] = [[466, 0.25], [440, 0.25], [415, 0.25], [392, 0.7]] let off = 0 for (let i = 0; i < notes.length; i++) { const [freq, dur] = notes[i] tone(freq, 'sawtooth', dur, d, t + off) tone(freq * 0.5, 'triangle', dur, d, t + off) if (i === 3) { const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 5 const lfoG = c.createGain(); lfoG.gain.value = 8 lfo.connect(lfoG); lfoG.connect(osc.frequency) const g = c.createGain() g.gain.setValueAtTime(0.2, t + off) g.gain.exponentialRampToValueAtTime(0.001, t + off + dur) osc.connect(g); g.connect(d) osc.start(t + off); osc.stop(t + off + dur) lfo.start(t + off); lfo.stop(t + off + dur) } off += dur } reverbTail(0.5, d, t + off) } // EMOTIONAL DAMAGE — dramatic descending brass stab export function sfxEmotionalDamage() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime tone(220, 'sawtooth', 0.3, d, t) tone(277, 'sawtooth', 0.3, d, t) tone(330, 'sawtooth', 0.3, d, t) bodyThump(110, 0.2, d, t, 0.3) noise(0.08, d, t) fmImpact(600, 4, 0.2, d, t) reverbTail(0.5, d, t + 0.1) } // DUN DUN DUNNNNN — dramatic reveal stinger export function sfxDunDunDun() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime tone(147, 'sawtooth', 0.2, d, t) tone(147, 'triangle', 0.2, d, t) bodyThump(73, 0.15, d, t, 0.25) tone(147, 'sawtooth', 0.2, d, t + 0.25) bodyThump(73, 0.15, d, t + 0.25, 0.25) tone(110, 'sawtooth', 0.8, d, t + 0.5) tone(110, 'triangle', 0.8, d, t + 0.5) tone(55, 'sine', 0.8, d, t + 0.5) bodyThump(55, 0.5, d, t + 0.5, 0.3) reverbTail(0.8, d, t + 0.6) } // FAIL HORN — game show wrong answer horn export function sfxFailHorn() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime tone(311, 'sawtooth', 0.3, d, t) tone(156, 'sawtooth', 0.3, d, t) tone(233, 'square', 0.3, d, t) setTimeout(() => { tone(277, 'sawtooth', 0.5, d) tone(139, 'sawtooth', 0.5, d) tone(208, 'square', 0.5, d) bodyThump(70, 0.3, d, undefined, 0.2) }, 350) reverbTail(0.5, d, t + 0.8) } // PRICE IS RIGHT FAIL — descending tuba + sad brass export function sfxPriceIsRightFail() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime const notes = [262, 247, 233, 220, 208, 196] for (let i = 0; i < notes.length; i++) { const nt = t + i * 0.18 tone(notes[i], 'sawtooth', 0.2, d, nt) tone(notes[i] * 0.5, 'triangle', 0.2, d, nt) } bodyThump(65, 0.4, d, t + notes.length * 0.18, 0.3) reverbTail(0.5, d, t + notes.length * 0.18) } // BUZZER — harsh game show wrong answer export function sfxBuzzer() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime tone(150, 'square', 0.4, d, t) tone(153, 'square', 0.4, d, t) tone(75, 'square', 0.4, d, t) noise(0.15, d, t) bodyThump(60, 0.2, d, t, 0.2) } // SAD VIOLIN — thin high vibrato melody export function sfxSadViolin() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime const notes: [number, number][] = [[659, 0.3], [622, 0.3], [587, 0.4], [554, 0.5]] let off = 0 for (const [freq, dur] of notes) { const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 6 const lfoG = c.createGain(); lfoG.gain.value = 6 lfo.connect(lfoG); lfoG.connect(osc.frequency) const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq * 2; bp.Q.value = 3 const g = c.createGain() g.gain.setValueAtTime(0.15, t + off) g.gain.exponentialRampToValueAtTime(0.001, t + off + dur) osc.connect(bp); bp.connect(g); g.connect(d) osc.start(t + off); osc.stop(t + off + dur) lfo.start(t + off); lfo.stop(t + off + dur) off += dur } } // MISSION FAILED — dark two-note descending stab export function sfxMissionFailed() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime tone(165, 'sawtooth', 0.3, d, t) tone(196, 'sawtooth', 0.3, d, t) tone(247, 'sawtooth', 0.3, d, t) bodyThump(82, 0.2, d, t, 0.2) setTimeout(() => { tone(139, 'sawtooth', 0.6, d) tone(165, 'sawtooth', 0.6, d) tone(208, 'sawtooth', 0.6, d) bodyThump(70, 0.4, d, undefined, 0.25) reverbTail(0.6, d) }, 400) } // CRICKETS — awkward silence chirps export function sfxCrickets() { const d = getSfxDest() const c = getCtx(); const t = c.currentTime for (let i = 0; i < 3; i++) { const ct = t + i * 0.4 tone(4200, 'sine', 0.03, d, ct) tone(4200, 'sine', 0.03, d, ct + 0.05) tone(4500, 'sine', 0.02, d, ct + 0.03) tone(4500, 'sine', 0.02, d, ct + 0.08) } } // === BACKGROUND MUSIC === // Rich, full arcade fighting soundtrack with 6 simultaneous layers const MUSIC_BPM = 170 const MUSIC_BEAT = 60 / MUSIC_BPM const BARS = 8 const STEPS = 16 // per bar // Shared delay effect for fullness let delayNode: DelayNode | null = null let delayGain: GainNode | null = null function getMusicDelay(): GainNode { if (delayGain && delayNode) return delayGain const c = getCtx() delayNode = c.createDelay(0.5) delayNode.delayTime.value = 0.18 // 8th note delay delayGain = c.createGain() delayGain.gain.value = 0.25 const feedback = c.createGain() feedback.gain.value = 0.3 delayNode.connect(feedback) feedback.connect(delayNode) // feedback loop delayNode.connect(delayGain) delayGain.connect(musicGain!) return delayGain } // Chorus tone: 2 detuned oscillators for width function chorusTone(freq: number, type: OscillatorType, dur: number, dest: AudioNode, t: number, vol: number = 0.2) { const c = getCtx() for (const detune of [-8, 8]) { // slight detune for stereo width const osc = c.createOscillator() osc.type = type osc.frequency.value = freq osc.detune.value = detune const g = c.createGain() g.gain.setValueAtTime(vol, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g) g.connect(dest) osc.start(t) osc.stop(t + dur) } } // FM lead with 2-operator FM + detuned chorus function fmLead(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() // Carrier const car = c.createOscillator() car.type = 'sawtooth' car.frequency.value = freq // Modulator const mod = c.createOscillator() mod.type = 'sine' mod.frequency.value = freq * 2 const modG = c.createGain() modG.gain.value = 150 mod.connect(modG) modG.connect(car.frequency) // Detuned double for width const car2 = c.createOscillator() car2.type = 'sawtooth' car2.frequency.value = freq car2.detune.value = 10 // Output const g = c.createGain() g.gain.setValueAtTime(0.16, t) g.gain.setValueAtTime(0.16, t + dur * 0.7) g.gain.exponentialRampToValueAtTime(0.001, t + dur) car.connect(g) car2.connect(g) g.connect(dest) // Send to delay for fullness if (delayNode) g.connect(delayNode) car.start(t); car.stop(t + dur) car2.start(t); car2.stop(t + dur) mod.start(t); mod.stop(t + dur) } // Thick distorted sub-bass with overtones function thickBass(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() // Sub const sub = c.createOscillator() sub.type = 'sine' sub.frequency.value = freq / 2 const subG = c.createGain() subG.gain.setValueAtTime(0.25, t) subG.gain.exponentialRampToValueAtTime(0.001, t + dur) sub.connect(subG); subG.connect(dest) sub.start(t); sub.stop(t + dur) // Main with distortion const osc = c.createOscillator() osc.type = 'sawtooth' osc.frequency.value = freq const dist = c.createWaveShaper() const curve = new Float32Array(256) for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = (Math.PI + 6) * x / (Math.PI + 6 * Math.abs(x)) } dist.curve = curve const g = c.createGain() g.gain.setValueAtTime(0.2, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(dist); dist.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) // Octave up for bite const oct = c.createOscillator() oct.type = 'square' oct.frequency.value = freq * 2 const octG = c.createGain() octG.gain.setValueAtTime(0.06, t) octG.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.5) oct.connect(octG); octG.connect(dest) oct.start(t); oct.stop(t + dur) } // Chord pad for harmonic fullness function chordPad(freqs: number[], dur: number, dest: AudioNode, t: number) { const c = getCtx() for (const freq of freqs) { const osc = c.createOscillator() osc.type = 'triangle' osc.frequency.value = freq const g = c.createGain() g.gain.setValueAtTime(0.04, t) g.gain.setValueAtTime(0.04, t + dur * 0.8) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) } } // Heavy layered drums function kick(dest: AudioNode, t: number) { const c = getCtx() // Body const o = c.createOscillator(); o.type = 'sine' o.frequency.setValueAtTime(150, t) o.frequency.exponentialRampToValueAtTime(40, t + 0.12) const g = c.createGain() g.gain.setValueAtTime(0.35, t) g.gain.exponentialRampToValueAtTime(0.001, t + 0.15) o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.15) // Click noise(0.015, dest, t) // Sub thump const s = c.createOscillator(); s.type = 'sine'; s.frequency.value = 50 const sg = c.createGain() sg.gain.setValueAtTime(0.2, t) sg.gain.exponentialRampToValueAtTime(0.001, t + 0.1) s.connect(sg); sg.connect(dest); s.start(t); s.stop(t + 0.1) } function snare(dest: AudioNode, t: number) { noise(0.08, dest, t) const c = getCtx() const o = c.createOscillator(); o.type = 'triangle'; o.frequency.value = 200 const g = c.createGain() g.gain.setValueAtTime(0.2, t) g.gain.exponentialRampToValueAtTime(0.001, t + 0.06) o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.06) // Body tone(180, 'square', 0.03, dest, t) } function hihat(dest: AudioNode, t: number, open: boolean = false) { const c = getCtx() const dur = open ? 0.08 : 0.025 const bufSize = Math.max(1, Math.floor(c.sampleRate * dur)) const buf = c.createBuffer(1, bufSize, c.sampleRate) const d = buf.getChannelData(0) for (let i = 0; i < bufSize; i++) d[i] = Math.random() * 2 - 1 const src = c.createBufferSource(); src.buffer = buf const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = open ? 6000 : 8000 const g = c.createGain() g.gain.setValueAtTime(open ? 0.12 : 0.08, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) src.connect(hp); hp.connect(g); g.connect(dest) src.start(t); src.stop(t + dur) } // === GENRE-SPECIFIC SYNTH VARIANTS === // Pulse bass — sharp square wave, chiptune/heroic/bouncy function pulseBass(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const osc = c.createOscillator() osc.type = 'square' osc.frequency.value = freq const g = c.createGain() g.gain.setValueAtTime(0.18, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) } // Slap bass — short punchy sine with fast decay, funky/speed function slapBass(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const osc = c.createOscillator() osc.type = 'sine' osc.frequency.setValueAtTime(freq * 1.5, t) osc.frequency.exponentialRampToValueAtTime(freq, t + 0.02) const g = c.createGain() g.gain.setValueAtTime(0.3, t) g.gain.exponentialRampToValueAtTime(0.001, t + Math.min(dur, 0.12)) osc.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) } // Sub bass — deep pure sine, atmospheric/emotional function subBass(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const osc = c.createOscillator() osc.type = 'sine' osc.frequency.value = freq const g = c.createGain() g.gain.setValueAtTime(0.25, t) g.gain.setValueAtTime(0.25, t + dur * 0.8) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) } // Distorted bass — sawtooth through waveshaper, metal/boss function distortedBass(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const osc = c.createOscillator() osc.type = 'sawtooth' osc.frequency.value = freq const dist = c.createWaveShaper() const curve = new Float32Array(256) for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = Math.tanh(x * 3) } dist.curve = curve const g = c.createGain() g.gain.setValueAtTime(0.22, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(dist); dist.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) const sub = c.createOscillator() sub.type = 'sine' sub.frequency.value = freq * 0.5 const sg = c.createGain() sg.gain.setValueAtTime(0.12, t) sg.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.7) sub.connect(sg); sg.connect(dest) sub.start(t); sub.stop(t + dur) } // Organ bass — square + sine harmonics, gothic function organBass(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() for (const [waveType, vol, mult] of [['square', 0.1, 1], ['sine', 0.12, 2], ['sine', 0.06, 3]] as [OscillatorType, number, number][]) { const osc = c.createOscillator() osc.type = waveType osc.frequency.value = freq * mult const g = c.createGain() g.gain.setValueAtTime(vol, t) g.gain.setValueAtTime(vol, t + dur * 0.85) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) } } // Sharp lead — detuned square waves, chiptune/ninja function sharpLead(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const osc = c.createOscillator() osc.type = 'square' osc.frequency.value = freq const osc2 = c.createOscillator() osc2.type = 'square' osc2.frequency.value = freq osc2.detune.value = 6 const g = c.createGain() g.gain.setValueAtTime(0.1, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g); osc2.connect(g); g.connect(dest) if (delayNode) g.connect(delayNode) osc.start(t); osc.stop(t + dur) osc2.start(t); osc2.stop(t + dur) } // Smooth lead — gentle sine FM with vibrato, emotional/JRPG function smoothLead(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const car = c.createOscillator() car.type = 'triangle' car.frequency.value = freq const mod = c.createOscillator() mod.type = 'sine' mod.frequency.value = freq * 1.5 const modG = c.createGain() modG.gain.value = 40 mod.connect(modG); modG.connect(car.frequency) const vib = c.createOscillator() vib.type = 'sine' vib.frequency.value = 5 const vibG = c.createGain() vibG.gain.value = 4 vib.connect(vibG); vibG.connect(car.frequency) const g = c.createGain() g.gain.setValueAtTime(0.001, t) g.gain.linearRampToValueAtTime(0.14, t + dur * 0.15) g.gain.setValueAtTime(0.14, t + dur * 0.7) g.gain.exponentialRampToValueAtTime(0.001, t + dur) car.connect(g); g.connect(dest) if (delayNode) g.connect(delayNode) car.start(t); car.stop(t + dur) mod.start(t); mod.stop(t + dur) vib.start(t); vib.stop(t + dur) } // Aggressive lead — heavy FM sawtooth, metal/boss function aggressiveLead(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const car = c.createOscillator() car.type = 'sawtooth' car.frequency.value = freq const mod = c.createOscillator() mod.type = 'square' mod.frequency.value = freq * 3 const modG = c.createGain() modG.gain.value = 300 mod.connect(modG); modG.connect(car.frequency) const car2 = c.createOscillator() car2.type = 'sawtooth' car2.frequency.value = freq car2.detune.value = -15 const g = c.createGain() g.gain.setValueAtTime(0.13, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) car.connect(g); car2.connect(g); g.connect(dest) if (delayNode) g.connect(delayNode) car.start(t); car.stop(t + dur) car2.start(t); car2.stop(t + dur) mod.start(t); mod.stop(t + dur) } // Wah lead — filtered sawtooth with bandpass sweep, funky/speed function wahLead(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const osc = c.createOscillator() osc.type = 'sawtooth' osc.frequency.value = freq const filter = c.createBiquadFilter() filter.type = 'bandpass' filter.Q.value = 5 filter.frequency.setValueAtTime(freq * 2, t) filter.frequency.exponentialRampToValueAtTime(freq * 8, t + dur * 0.3) filter.frequency.exponentialRampToValueAtTime(freq * 2, t + dur) const g = c.createGain() g.gain.setValueAtTime(0.2, t) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(filter); filter.connect(g); g.connect(dest) if (delayNode) g.connect(delayNode) osc.start(t); osc.stop(t + dur) } // Gothic lead — vibrato sawtooth + triangle overtone, Castlevania function gothicLead(freq: number, dur: number, dest: AudioNode, t: number) { const c = getCtx() const osc = c.createOscillator() osc.type = 'sawtooth' osc.frequency.value = freq const vib = c.createOscillator() vib.type = 'sine' vib.frequency.value = 6.5 const vibG = c.createGain() vibG.gain.value = 8 vib.connect(vibG); vibG.connect(osc.frequency) const osc2 = c.createOscillator() osc2.type = 'triangle' osc2.frequency.value = freq * 2 const g = c.createGain() g.gain.setValueAtTime(0.12, t) g.gain.setValueAtTime(0.12, t + dur * 0.75) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g); g.connect(dest) const g2 = c.createGain() g2.gain.setValueAtTime(0.04, t) g2.gain.exponentialRampToValueAtTime(0.001, t + dur) osc2.connect(g2); g2.connect(dest) if (delayNode) g.connect(delayNode) osc.start(t); osc.stop(t + dur) osc2.start(t); osc2.stop(t + dur) vib.start(t); vib.stop(t + dur) } // Dark pad — filtered sawtooth, gothic/atmospheric/boss function darkPad(freqs: number[], dur: number, dest: AudioNode, t: number) { const c = getCtx() for (const freq of freqs) { const osc = c.createOscillator() osc.type = 'sawtooth' osc.frequency.value = freq const filter = c.createBiquadFilter() filter.type = 'lowpass' filter.frequency.value = freq * 3 filter.Q.value = 1 const g = c.createGain() g.gain.setValueAtTime(0.03, t) g.gain.setValueAtTime(0.03, t + dur * 0.8) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(filter); filter.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) } } // Bright pad — square wave, heroic/bouncy function brightPad(freqs: number[], dur: number, dest: AudioNode, t: number) { const c = getCtx() for (const freq of freqs) { const osc = c.createOscillator() osc.type = 'square' osc.frequency.value = freq const g = c.createGain() g.gain.setValueAtTime(0.025, t) g.gain.setValueAtTime(0.025, t + dur * 0.8) g.gain.exponentialRampToValueAtTime(0.001, t + dur) osc.connect(g); g.connect(dest) osc.start(t); osc.stop(t + dur) } } // Style dispatch tables type SynthStyle = 'epic' | 'gothic' | 'funky' | 'heroic' | 'metal' | 'emotional' | 'ninja' | 'speed' | 'atmospheric' | 'military' | 'bouncy' | 'boss' type BassFunc = (freq: number, dur: number, dest: AudioNode, t: number) => void type PadFunc = (freqs: number[], dur: number, dest: AudioNode, t: number) => void const STYLE_BASS: Record = { epic: thickBass, gothic: organBass, funky: slapBass, heroic: pulseBass, metal: distortedBass, emotional: subBass, ninja: pulseBass, speed: slapBass, atmospheric: subBass, military: thickBass, bouncy: pulseBass, boss: distortedBass, } const STYLE_LEAD: Record = { epic: fmLead, gothic: gothicLead, funky: wahLead, heroic: sharpLead, metal: aggressiveLead, emotional: smoothLead, ninja: sharpLead, speed: wahLead, atmospheric: smoothLead, military: fmLead, bouncy: sharpLead, boss: aggressiveLead, } const STYLE_ARP_TYPE: Record = { epic: 'triangle', gothic: 'sawtooth', funky: 'square', heroic: 'square', metal: 'sawtooth', emotional: 'triangle', ninja: 'square', speed: 'triangle', atmospheric: 'sine', military: 'triangle', bouncy: 'square', boss: 'sawtooth', } const STYLE_PAD: Record = { epic: chordPad, gothic: darkPad, funky: chordPad, heroic: brightPad, metal: darkPad, emotional: chordPad, ninja: darkPad, speed: brightPad, atmospheric: darkPad, military: chordPad, bouncy: brightPad, boss: darkPad, } // === TRACK DATA === interface MusicTrack { name: string bpm: number synthStyle: SynthStyle bass: number[] lead: number[] arp: number[] chords: number[][] drums: number[] } // Track 1: "Desert Storm" — 200 BPM, A minor, Turrican-style epic sweep const track1: MusicTrack = { name: 'Desert Storm', bpm: 200, synthStyle: 'epic', bass: [ 110,0,220,0, 110,0,220,110, 110,0,220,0, 165,0,220,0, 87,0,175,0, 87,0,175,87, 87,0,175,0, 131,0,175,0, 73,0,147,0, 73,0,147,73, 73,0,147,0, 110,0,147,0, 82,0,165,0, 82,0,165,82, 82,0,165,0, 123,0,165,0, 110,0,220,110, 110,165,220,110, 110,0,220,110, 110,165,220,165, 131,0,262,131, 131,196,262,131, 131,0,262,131, 131,196,262,196, 87,0,175,87, 87,131,175,87, 87,0,175,87, 87,131,175,131, 82,0,165,82, 82,123,165,82, 82,165,247,330, 165,82,0,0, ], lead: [ 440,0,523,659, 880,0,659,523, 440,0,523,659, 784,880,784,659, 349,0,440,523, 698,0,523,440, 349,0,440,523, 587,698,659,523, 294,0,349,440, 587,0,440,349, 587,698,880,1047, 880,698,587,440, 330,0,440,523, 659,0,784,880, 1047,0,880,784, 659,523,440,330, 880,1047,1319,1760, 1319,0,1047,880, 784,659,880,1047, 1319,1047,880,659, 1047,1319,1568,1319, 1047,784,659,784, 1047,1319,1568,2093, 1568,1319,1047,784, 698,880,1047,1397, 1760,0,1397,1047, 880,698,880,1047, 1397,1047,880,698, 659,784,880,1047, 880,784,659,523, 440,523,659,880, 659,0,0,0, ], arp: [ 440,523,659,523, 440,523,659,880, 659,523,440,523, 659,880,659,523, 349,440,523,440, 349,440,523,698, 523,440,349,440, 523,698,523,440, 294,349,440,349, 294,349,440,587, 440,349,294,349, 440,587,440,349, 330,415,523,415, 330,415,523,659, 523,415,330,415, 523,659,523,415, 440,659,880,659, 440,523,659,880, 1047,880,659,523, 440,659,880,1047, 523,784,1047,784, 523,659,784,1047, 1319,1047,784,659, 523,784,1047,1319, 349,523,698,523, 349,440,523,698, 880,698,523,440, 349,523,698,880, 330,523,659,523, 330,415,523,659, 880,659,523,415, 440,523,659,0, ], chords: [[220,262,330],[175,220,262],[147,175,220],[165,208,247],[220,262,330],[131,165,196],[175,220,262],[165,208,247]], drums: [ 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,4, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,4, 1,0,3,0, 2,3,4,3, 1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,0, 1,3,1,3, 2,3,4,3, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3, 1,1,2,1, 2,2,2,2, ], } // Track 2: "Vampire's Requiem" — 190 BPM, D minor, Castlevania gothic const track2: MusicTrack = { name: "Vampire's Requiem", bpm: 190, synthStyle: 'gothic', bass: [ 147,0,294,147, 147,0,294,0, 147,0,294,147, 175,220,294,220, 117,0,233,117, 117,0,233,0, 117,0,233,117, 147,175,233,175, 98,0,196,98, 98,0,196,0, 98,0,196,98, 131,147,196,147, 110,0,220,110, 110,0,220,0, 110,0,220,110, 139,165,220,165, 147,294,147,294, 147,294,175,220, 147,294,147,294, 175,220,294,349, 117,233,117,233, 117,233,175,233, 175,349,175,349, 175,349,220,262, 98,196,98,196, 131,196,262,196, 98,196,98,196, 131,196,262,330, 110,220,110,220, 139,220,277,220, 110,0,220,0, 110,0,0,0, ], lead: [ 587,0,698,880, 1047,0,880,698, 587,0,440,349, 440,587,698,880, 466,0,587,698, 932,0,698,587, 466,0,349,233, 349,466,587,698, 392,0,494,587, 784,0,587,494, 392,0,330,262, 330,392,494,587, 440,0,554,698, 880,0,698,554, 440,330,220,330, 440,554,698,880, 1175,0,1047,880, 698,587,440,587, 698,880,1047,1175, 1397,1175,1047,880, 932,0,880,698, 587,466,349,466, 587,698,880,932, 1047,932,880,698, 784,0,698,587, 494,392,330,392, 494,587,698,784, 880,784,698,587, 880,698,554,440, 554,440,330,220, 294,349,440,587, 698,0,0,0, ], arp: [ 294,349,440,349, 294,349,440,587, 440,349,294,349, 440,587,440,349, 233,294,349,294, 233,294,349,466, 349,294,233,294, 349,466,349,294, 196,262,330,262, 196,262,330,392, 330,262,196,262, 330,392,330,262, 220,277,349,277, 220,277,349,440, 349,277,220,277, 349,440,349,277, 294,440,587,440, 294,349,440,587, 698,587,440,349, 294,440,587,698, 233,349,466,349, 233,294,349,466, 587,466,349,294, 233,349,466,587, 196,330,392,330, 196,262,330,392, 494,392,330,262, 196,330,392,494, 220,349,440,349, 220,277,349,440, 554,440,349,277, 220,349,440,0, ], chords: [[147,175,220],[233,294,349],[196,233,294],[220,277,330],[147,175,220],[175,220,262],[233,294,349],[220,277,330]], drums: [ 1,0,0,3, 2,0,0,3, 1,0,0,3, 2,0,0,3, 1,0,0,3, 2,0,0,3, 1,0,0,3, 2,0,3,4, 1,0,3,3, 2,0,0,3, 1,0,3,3, 2,0,0,3, 1,0,3,3, 2,0,0,3, 1,0,3,3, 2,0,3,4, 1,3,0,3, 2,0,3,3, 1,3,0,3, 2,0,3,3, 1,3,0,3, 2,3,0,3, 1,3,0,3, 2,3,4,3, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,1,3, 1,1,2,1, 2,1,2,1, 2,2,2,2, 2,2,2,2, ], } // Track 3: "Street Heat" — 175 BPM, E minor, Streets of Rage funky const track3: MusicTrack = { name: 'Street Heat', bpm: 175, synthStyle: 'funky', bass: [ 82,0,0,165, 0,0,82,0, 165,0,0,196, 0,165,0,82, 110,0,0,220, 0,0,110,0, 220,0,0,262, 0,220,0,110, 131,0,0,262, 0,0,131,0, 262,0,0,330, 0,262,0,131, 123,0,0,247, 0,0,123,0, 247,0,0,294, 0,247,0,123, 82,165,0,82, 0,165,82,0, 165,82,0,196, 165,0,82,165, 110,220,0,110, 0,220,110,0, 220,110,0,262, 220,0,110,220, 131,262,0,131, 0,262,131,0, 262,131,0,330, 262,0,131,262, 123,247,0,123, 0,247,123,0, 247,0,0,0, 123,0,0,0, ], lead: [ 659,0,0,784, 0,0,659,0, 0,880,0,784, 0,659,0,0, 880,0,0,1047, 0,0,880,0, 0,1175,0,1047, 0,880,0,0, 1047,0,0,1175, 0,0,1319,0, 0,1175,0,1047, 0,880,784,0, 988,0,0,880, 0,0,784,0, 0,659,0,784, 880,0,784,0, 659,784,0,880, 1047,0,880,0, 659,0,784,880, 1047,0,880,659, 880,1047,0,1175, 1319,0,1175,0, 880,0,1047,1175, 1319,0,1175,880, 1047,1319,0,1568, 1760,0,1568,0, 1319,0,1047,880, 784,0,659,0, 880,659,0,784, 659,0,523,0, 494,0,659,0, 784,0,0,0, ], arp: [ 330,0,392,0, 494,0,392,0, 330,0,494,0, 659,0,494,0, 440,0,523,0, 659,0,523,0, 440,0,659,0, 880,0,659,0, 523,0,659,0, 784,0,659,0, 523,0,784,0, 1047,0,784,0, 494,0,587,0, 740,0,587,0, 494,0,740,0, 988,0,740,0, 330,494,659,494, 330,392,494,659, 784,659,494,392, 330,494,659,784, 440,659,880,659, 440,523,659,880, 1047,880,659,523, 440,659,880,1047, 523,784,1047,784, 523,659,784,1047, 1319,1047,784,659, 523,784,1047,1319, 494,740,988,740, 494,587,740,988, 740,0,0,0, 494,0,0,0, ], chords: [[165,196,247],[220,262,330],[262,330,392],[247,294,370],[165,196,247],[196,247,294],[220,262,330],[247,294,370]], drums: [ 1,0,0,0, 2,0,0,3, 0,0,1,0, 2,0,3,0, 1,0,0,0, 2,0,0,3, 0,0,1,0, 2,0,3,4, 1,0,3,0, 2,0,0,3, 0,3,1,0, 2,0,3,0, 1,0,3,0, 2,3,0,3, 0,3,1,0, 2,0,3,4, 1,0,3,0, 2,3,0,3, 1,3,1,0, 2,0,3,0, 1,3,3,0, 2,3,0,3, 1,3,1,0, 2,3,3,4, 1,3,3,3, 2,3,0,3, 1,3,3,3, 2,3,1,3, 1,1,2,1, 2,1,2,3, 1,1,2,2, 2,2,2,2, ], } // Track 4: "Steel Resolve" — 210 BPM, C major, Mega Man heroic const track4: MusicTrack = { name: 'Steel Resolve', bpm: 210, synthStyle: 'heroic', bass: [ 131,0,262,131, 131,0,262,0, 131,0,196,0, 131,262,196,131, 196,0,392,196, 196,0,392,0, 196,0,262,0, 196,392,262,196, 220,0,440,220, 220,0,440,0, 220,0,330,0, 220,440,330,220, 175,0,349,175, 175,0,349,0, 175,0,262,0, 175,349,262,175, 131,262,131,262, 131,262,196,262, 196,392,196,392, 196,392,262,392, 220,440,220,440, 220,440,330,440, 175,349,175,349, 175,349,262,349, 131,196,262,330, 262,196,131,196, 196,262,330,392, 330,262,196,131, 175,262,349,440, 349,262,175,131, 131,262,0,0, 131,0,0,0, ], lead: [ 523,659,784,1047, 784,659,523,659, 784,1047,1319,1047, 784,659,523,392, 784,1047,1175,1319, 1175,1047,784,659, 523,659,784,1047, 1175,1047,784,659, 880,1047,1175,1319, 1175,1047,880,784, 659,784,880,1047, 1175,1047,880,659, 698,880,1047,1175, 1047,880,698,523, 440,523,698,880, 1047,880,698,523, 1047,1319,1568,1319, 1047,784,659,784, 1047,1319,1568,2093, 1568,1319,1047,784, 1175,1319,1568,1760, 1568,1319,1175,880, 659,880,1175,1319, 1568,1319,1175,880, 880,1047,1175,1319, 1175,1047,880,784, 659,784,880,1047, 1175,1047,880,784, 698,880,1047,1175, 1047,880,698,523, 523,659,784,1047, 784,0,0,0, ], arp: [ 262,330,392,330, 262,330,392,523, 392,330,262,330, 392,523,392,330, 392,494,587,494, 392,494,587,784, 587,494,392,494, 587,784,587,494, 440,523,659,523, 440,523,659,880, 659,523,440,523, 659,880,659,523, 349,440,523,440, 349,440,523,698, 523,440,349,440, 523,698,523,440, 262,392,523,784, 523,392,262,392, 523,784,1047,784, 523,392,262,523, 392,587,784,1047, 784,587,392,587, 784,1047,1319,1047, 784,587,392,784, 440,659,880,659, 440,523,659,880, 1047,880,659,523, 440,659,880,1047, 349,523,698,523, 349,440,523,698, 880,698,523,0, 262,523,0,0, ], chords: [[262,330,392],[196,247,294],[220,262,330],[175,220,262],[262,330,392],[165,196,247],[175,220,262],[196,247,294]], drums: [ 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,3,3,0, 2,0,3,4, 1,3,3,0, 2,0,3,0, 1,3,3,0, 2,0,3,0, 1,3,3,0, 2,3,3,0, 1,3,3,0, 2,3,3,4, 1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,4, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3, 1,3,1,3, 2,3,1,3, 1,1,1,3, 2,1,1,3, 1,1,1,1, 2,1,2,1, 2,2,2,1, 2,2,2,2, ], } // Track 5: "Thunder Blade" — 230 BPM, B minor, Thunder Force metal shred const track5: MusicTrack = { name: 'Thunder Blade', bpm: 230, synthStyle: 'metal', bass: [ 123,123,247,123, 123,123,247,123, 123,123,247,0, 185,247,185,123, 98,98,196,98, 98,98,196,98, 98,98,196,0, 147,196,147,98, 82,82,165,82, 82,82,165,82, 82,82,165,0, 131,165,131,82, 92,92,185,92, 92,92,185,92, 92,92,185,0, 139,185,139,92, 123,247,123,247, 123,247,185,247, 123,247,123,247, 185,247,294,370, 98,196,98,196, 98,196,147,196, 98,196,98,196, 147,196,247,294, 82,165,82,165, 82,165,131,165, 82,165,82,165, 131,165,196,247, 92,185,92,185, 92,185,139,185, 92,185,247,370, 247,185,0,0, ], lead: [ 988,0,988,1175, 1480,0,1175,988, 740,0,740,988, 1175,0,988,740, 784,0,784,988, 1175,0,988,784, 587,0,587,784, 988,0,784,587, 659,0,659,784, 988,0,784,659, 494,0,494,659, 784,0,659,494, 740,0,740,880, 1109,0,880,740, 554,0,554,740, 880,0,740,554, 988,1175,1480,1976, 1480,1175,988,1175, 1480,1976,1480,1175, 988,740,988,1175, 784,988,1175,1568, 1175,988,784,988, 1175,1568,1175,988, 784,587,784,988, 659,784,988,1319, 988,784,659,784, 988,1319,988,784, 659,494,659,784, 1480,1175,988,740, 988,740,554,440, 554,740,988,1175, 1480,0,0,0, ], arp: [ 247,370,494,370, 247,370,494,740, 494,370,247,370, 494,740,494,370, 196,294,392,294, 196,294,392,587, 392,294,196,294, 392,587,392,294, 165,247,330,247, 165,247,330,494, 330,247,165,247, 330,494,330,247, 185,277,370,277, 185,277,370,554, 370,277,185,277, 370,554,370,277, 247,494,740,494, 247,370,494,740, 988,740,494,370, 247,494,740,988, 196,392,587,392, 196,294,392,587, 784,587,392,294, 196,392,587,784, 165,330,494,330, 165,247,330,494, 659,494,330,247, 165,330,494,659, 185,370,554,370, 185,277,370,554, 740,554,370,0, 247,370,0,0, ], chords: [[247,294,370],[196,247,294],[165,196,247],[185,220,277],[247,294,370],[220,262,330],[196,247,294],[185,220,277]], drums: [ 1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1, 1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,4,1, 1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,4,1, 1,1,2,1, 2,1,2,1, 2,2,2,2, 2,2,2,2, ], } // Track 6: "Crystal Elegy" — 150 BPM, F major, JRPG emotional const track6: MusicTrack = { name: 'Crystal Elegy', bpm: 150, synthStyle: 'emotional', bass: [ 87,0,175,0, 87,0,175,0, 87,0,175,0, 131,0,175,0, 73,0,147,0, 73,0,147,0, 73,0,147,0, 110,0,147,0, 117,0,233,0, 117,0,233,0, 117,0,233,0, 175,0,233,0, 131,0,262,0, 131,0,262,0, 131,0,262,0, 196,0,262,0, 87,175,87,175, 87,131,175,262, 175,131,87,131, 175,262,175,131, 73,147,73,147, 73,110,147,220, 147,110,73,110, 147,220,147,110, 117,233,117,233, 117,175,233,349, 233,175,117,175, 233,349,233,175, 131,262,131,262, 131,196,262,330, 262,196,131,0, 131,0,0,0, ], lead: [ 698,0,0,880, 1047,0,0,880, 698,0,0,523, 440,0,523,698, 587,0,0,698, 880,0,0,698, 587,0,0,440, 349,0,440,587, 466,0,0,587, 698,0,0,587, 466,0,0,349, 233,0,349,466, 523,0,0,659, 784,0,0,659, 523,0,0,440, 330,0,440,523, 1397,0,1175,1047, 880,0,698,523, 440,523,698,880, 1047,880,698,523, 1175,0,1047,880, 698,0,587,440, 349,440,587,698, 880,698,587,440, 932,0,880,698, 587,0,466,349, 233,349,466,587, 698,587,466,349, 1047,0,880,784, 659,523,440,349, 440,523,698,880, 1047,0,0,0, ], arp: [ 175,220,262,220, 175,220,262,349, 262,220,175,220, 262,349,262,220, 147,175,220,175, 147,175,220,294, 220,175,147,175, 220,294,220,175, 233,294,349,294, 233,294,349,466, 349,294,233,294, 349,466,349,294, 262,330,392,330, 262,330,392,523, 392,330,262,330, 392,523,392,330, 175,262,349,523, 349,262,175,262, 349,523,698,523, 349,262,175,349, 147,220,294,440, 294,220,147,220, 294,440,587,440, 294,220,147,294, 233,349,466,698, 466,349,233,349, 466,698,932,698, 466,349,233,466, 262,392,523,784, 523,392,262,392, 523,784,0,0, 262,0,0,0, ], chords: [[175,220,262],[147,175,220],[233,294,349],[262,330,392],[175,220,262],[220,262,330],[233,294,349],[262,330,392]], drums: [ 1,0,0,0, 0,0,2,0, 0,0,0,0, 0,0,3,0, 1,0,0,0, 0,0,2,0, 0,0,0,0, 0,0,3,0, 1,0,0,3, 0,0,2,0, 0,3,0,0, 0,0,3,0, 1,0,0,3, 0,0,2,0, 0,3,0,0, 2,0,3,4, 1,0,3,0, 2,0,0,3, 1,0,3,0, 2,0,0,3, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,4, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,3,0, 1,0,3,0, 2,3,3,0, 1,0,0,0, 0,0,0,0, ], } // Track 7: "Shadow Assault" — 215 BPM, G minor, Ninja Gaiden action const track7: MusicTrack = { name: 'Shadow Assault', bpm: 215, synthStyle: 'ninja', bass: [ 98,0,196,98, 98,0,196,0, 98,0,196,98, 131,156,196,156, 156,0,311,156, 156,0,311,0, 156,0,311,156, 196,233,311,233, 131,0,262,131, 131,0,262,0, 131,0,262,131, 175,196,262,196, 147,0,294,147, 147,0,294,0, 147,0,294,147, 185,220,294,220, 98,196,98,196, 156,196,131,196, 98,196,98,196, 156,196,262,330, 156,311,156,311, 196,311,233,311, 156,311,156,311, 196,311,392,466, 131,262,131,262, 175,262,196,262, 131,262,131,262, 175,262,330,392, 147,294,147,294, 185,294,220,294, 147,294,0,0, 147,0,0,0, ], lead: [ 784,0,932,784, 0,784,932,1175, 1568,0,1175,932, 784,0,659,784, 622,0,784,622, 0,622,784,988, 1175,0,988,784, 622,0,523,622, 523,0,659,523, 0,523,659,784, 1047,0,784,659, 523,0,440,523, 587,0,740,587, 0,587,740,880, 1175,0,880,740, 587,0,494,587, 784,932,1175,1568, 1175,932,784,932, 1175,1568,1175,932, 784,659,784,932, 622,784,988,1319, 988,784,622,784, 988,1319,988,784, 622,523,622,784, 1047,1175,1568,1865, 1568,1175,1047,1175, 1568,1865,1568,1175, 1047,784,659,523, 587,740,880,1175, 880,740,587,440, 587,0,784,0, 932,0,0,0, ], arp: [ 196,233,294,233, 196,233,294,392, 294,233,196,233, 294,392,294,233, 311,392,466,392, 311,392,466,622, 466,392,311,392, 466,622,466,392, 262,330,392,330, 262,330,392,523, 392,330,262,330, 392,523,392,330, 294,370,440,370, 294,370,440,587, 440,370,294,370, 440,587,440,370, 196,294,392,587, 392,294,196,294, 392,587,784,587, 392,294,196,392, 311,466,622,932, 622,466,311,466, 622,932,1175,932, 622,466,311,622, 262,392,523,784, 523,392,262,392, 523,784,1047,784, 523,392,262,523, 294,440,587,880, 587,440,294,440, 587,0,0,0, 294,0,0,0, ], chords: [[196,233,294],[311,392,466],[262,311,392],[294,370,440],[196,233,294],[233,294,349],[311,392,466],[294,370,440]], drums: [ 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,4, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,3,0,3, 1,3,0,3, 2,3,4,3, 1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,4, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3, 1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1, 1,1,1,1, 2,1,2,1, 2,2,2,1, 2,2,2,2, ], } // Track 8: "Emerald Rush" — 195 BPM, G major, Sonic-style funky speed const track8: MusicTrack = { name: 'Emerald Rush', bpm: 195, synthStyle: 'speed', bass: [ 98,0,0,196, 0,0,98,0, 196,0,0,98, 0,196,0,98, 82,0,0,165, 0,0,82,0, 165,0,0,82, 0,165,0,82, 131,0,0,262, 0,0,131,0, 262,0,0,131, 0,262,0,131, 73,0,0,147, 0,0,73,0, 147,0,0,73, 0,147,0,73, 98,196,0,98, 196,0,98,196, 0,196,98,0, 196,98,196,262, 82,165,0,82, 165,0,82,165, 0,165,82,0, 165,82,165,247, 131,262,0,131, 262,0,131,262, 0,262,131,0, 262,131,262,330, 147,294,0,147, 294,0,147,294, 147,0,0,0, 98,0,0,0, ], lead: [ 784,0,880,1047, 1175,0,1047,880, 784,0,659,523, 659,784,880,1047, 659,0,784,880, 1047,0,880,784, 659,0,523,440, 523,659,784,880, 1047,0,1175,1319, 1568,0,1319,1175, 1047,0,880,784, 880,1047,1175,1319, 587,0,659,784, 880,0,784,659, 587,0,523,440, 523,587,659,784, 1175,1319,1568,1760, 1568,1319,1175,1047, 880,784,880,1047, 1175,1047,880,784, 1047,1175,1319,1568, 1319,1175,1047,880, 784,659,784,880, 1047,880,784,659, 1568,1760,2093,1760, 1568,1319,1175,1047, 880,784,659,523, 659,784,880,1047, 880,784,659,587, 523,440,392,330, 392,440,523,659, 784,0,0,0, ], arp: [ 392,494,587,494, 392,494,587,784, 587,494,392,494, 587,784,587,494, 330,392,494,392, 330,392,494,659, 494,392,330,392, 494,659,494,392, 523,659,784,659, 523,659,784,1047, 784,659,523,659, 784,1047,784,659, 294,370,440,370, 294,370,440,587, 440,370,294,370, 440,587,440,370, 392,587,784,587, 392,494,587,784, 1047,784,587,494, 392,587,784,1047, 330,494,659,494, 330,392,494,659, 880,659,494,392, 330,494,659,880, 523,784,1047,784, 523,659,784,1047, 1319,1047,784,659, 523,784,1047,1319, 294,440,587,440, 294,370,440,587, 784,587,0,0, 392,0,0,0, ], chords: [[196,247,294],[165,196,247],[262,330,392],[147,175,220],[196,247,294],[123,147,185],[262,330,392],[147,175,220]], drums: [ 1,0,0,3, 2,0,0,3, 0,0,1,0, 2,0,3,0, 1,0,0,3, 2,0,0,3, 0,3,1,0, 2,0,3,4, 1,0,3,0, 2,0,0,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,3,0,3, 1,0,3,0, 2,3,3,4, 1,3,3,0, 2,0,3,3, 1,3,3,0, 2,0,3,4, 1,3,3,0, 2,3,3,3, 1,3,3,0, 2,3,3,4, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3, 1,1,1,3, 2,1,2,1, 2,1,2,1, 2,2,2,2, ], } // Track 9: "Alien Abyss" — 140 BPM, E minor, Metroid atmospheric tension const track9: MusicTrack = { name: 'Alien Abyss', bpm: 140, synthStyle: 'atmospheric', bass: [ 82,0,0,0, 82,0,0,0, 165,0,0,0, 82,0,165,0, 131,0,0,0, 131,0,0,0, 262,0,0,0, 131,0,262,0, 110,0,0,0, 110,0,0,0, 220,0,0,0, 110,0,220,0, 123,0,0,0, 123,0,0,0, 247,0,0,0, 123,0,247,0, 82,0,165,0, 0,0,82,0, 165,0,0,0, 82,0,165,82, 131,0,262,0, 0,0,131,0, 262,0,0,0, 131,0,262,131, 110,0,220,0, 0,0,110,0, 220,0,0,0, 110,0,220,110, 123,0,247,0, 123,0,0,0, 123,0,0,0, 82,0,0,0, ], lead: [ 494,0,0,659, 0,0,784,0, 0,0,659,0, 0,494,0,0, 523,0,0,622, 0,0,784,0, 0,0,622,0, 0,523,0,0, 440,0,0,523, 0,0,659,0, 0,0,523,0, 0,440,659,0, 494,0,0,587, 0,0,740,0, 0,988,0,0, 740,0,587,0, 659,0,784,0, 988,0,1319,0, 1568,0,1319,0, 988,784,659,494, 523,0,659,0, 784,0,1047,0, 1319,0,1047,0, 784,659,523,392, 440,0,523,0, 659,0,880,0, 1047,0,880,0, 659,523,440,330, 494,0,587,0, 740,0,988,0, 740,0,587,0, 494,0,0,0, ], arp: [ 330,392,494,0, 392,494,0,0, 330,392,494,0, 392,0,0,0, 262,330,392,0, 330,392,0,0, 262,330,392,0, 330,0,0,0, 220,262,330,0, 262,330,0,0, 220,262,330,0, 262,330,0,0, 247,294,370,0, 294,370,494,0, 247,294,370,494, 294,370,494,0, 330,494,659,494, 330,392,494,659, 784,659,494,392, 330,494,392,330, 262,392,523,392, 262,330,392,523, 659,523,392,330, 262,392,330,262, 220,330,440,330, 220,262,330,440, 523,440,330,262, 220,330,262,220, 247,370,494,370, 247,294,370,494, 587,494,370,0, 0,0,0,0, ], chords: [[165,196,247],[262,330,392],[220,262,330],[247,294,370],[165,196,247],[196,247,294],[220,262,330],[247,294,370]], drums: [ 1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,0,0, 1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0, 1,0,0,0, 0,0,3,0, 2,0,0,0, 0,0,3,0, 1,0,0,3, 0,0,3,0, 2,0,3,0, 0,0,3,4, 1,0,0,3, 2,0,3,0, 1,0,0,3, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,4, 1,0,3,0, 2,0,3,3, 1,0,3,3, 2,0,3,4, 1,0,3,0, 2,0,3,0, 1,0,0,0, 0,0,0,0, ], } // Track 10: "Halving Day" — Cm, 162 BPM — Triumphant climax const track10: MusicTrack = { name: 'Halving Day', bpm: 162, synthStyle: 'heroic', bass: [ 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,196, 156,0,311,0, 156,0,233,0, 156,0,311,0, 156,233,311,233, 117,0,233,0, 117,0,175,0, 117,0,233,0, 117,175,233,175, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,147, 131,0,262,0, 131,0,196,0, 131,0,262,196, 131,196,262,196, 104,0,208,0, 104,0,156,0, 104,0,208,0, 104,156,208,156, 87,0,175,0, 87,0,131,0, 87,0,175,131, 87,131,175,131, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,0,0,0, ], lead: [ 523,0,622,784, 1047,0,784,622, 523,0,622,784, 1047,1245,1568,1245, 622,0,784,932, 1245,0,932,784, 622,784,932,1245, 1568,1245,932,784, 932,0,1175,1397, 932,0,698,587, 932,1175,1397,1865, 1175,932,698,587, 784,0,988,1175, 1568,0,1175,988, 784,988,1175,1568, 1976,1568,1175,988, 1245,1047,784,523, 622,784,1047,1245, 1568,0,1245,1047, 784,622,523,622, 831,0,1047,1245, 831,0,622,523, 831,1047,1245,1661, 1245,1047,831,622, 698,0,831,1047, 1397,0,1047,831, 698,831,1047,1397, 1661,1397,1047,831, 784,988,1175,1568, 1976,0,1568,1175, 988,784,587,784, 988,1175,1568,784, ], arp: [ 262,311,392,311, 262,311,392,523, 392,523,392,311, 262,392,311,262, 311,392,466,392, 311,392,466,622, 466,622,466,392, 311,466,392,311, 233,294,349,294, 233,294,349,466, 349,466,349,294, 233,349,294,233, 392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392, 262,311,392,523, 392,523,622,523, 392,311,262,311, 392,523,392,311, 208,262,311,262, 208,262,311,415, 311,415,311,262, 208,311,262,208, 349,415,523,415, 349,415,523,698, 523,698,523,415, 349,523,415,349, 392,494,587,494, 392,587,494,392, 587,494,392,294, 392,494,587,0, ], // I-vi-ii-V in Eb major (triumphant jazz-pop) chords: [[156,196,233],[131,156,196],[175,208,262],[233,294,349],[156,196,233],[131,156,196],[175,208,262],[233,294,349]], drums: [ 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,4,0, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,3,3,0, 1,3,3,0, 2,3,3,3, 1,3,1,3, 2,3,4,3, 1,3,3,0, 2,0,3,0, 1,0,3,3, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,3,3,0, 2,3,4,0, 1,3,1,3, 2,3,3,0, 1,3,1,3, 2,3,1,3, 1,1,1,1, 2,2,2,2, 2,2,4,2, 1,0,0,0, ], } const ALL_TRACKS = [track1, track2, track3, track4, track5, track6, track7, track8, track9, track10] let activeTrack: MusicTrack = track1 let barIndex = 0 function playMusicBar() { if (!musicPlaying || !musicGain) return const c = getCtx() getMusicDelay() // ensure delay is created // Dynamic tempo: subtle intensity shift (+8 BPM at max) const dynamicBpm = activeTrack.bpm + currentIntensity * 8 const beat = 60 / dynamicBpm const now = c.currentTime + 0.05 const bar = barIndex % BARS const off = bar * STEPS const step = beat / 2 // === VERSE / CHORUS DYNAMICS === // Bars 0-3 are "verse" (sparser), bars 4-7 are "chorus" (climactic, full) const isChorus = bar >= 4 const leadThreshold = isChorus ? 0.15 : 0.4 const arpThreshold = isChorus ? 0.25 : 0.55 // Switch tracks at musical boundaries — NEVER repeat the same track const switchEvery = currentIntensity > 0.7 ? 8 : currentIntensity > 0.4 ? 16 : 24 if (barIndex > 0 && barIndex % switchEvery === 0) { const prevTrack = activeTrack // Always pick from ALL tracks, excluding current — guarantees no repeat const others = ALL_TRACKS.filter(t => t !== activeTrack) // Prefer tracks matching intensity mood const chill = others.filter(t => t.bpm < 155) const mid = others.filter(t => t.bpm >= 155 && t.bpm < 168) const intense = others.filter(t => t.bpm >= 168) if (currentIntensity > 0.7 && intense.length > 0) { activeTrack = intense[Math.floor(Math.random() * intense.length)] } else if (currentIntensity < 0.3 && chill.length > 0) { activeTrack = chill[Math.floor(Math.random() * chill.length)] } else if (mid.length > 0 && Math.random() < 0.5) { activeTrack = mid[Math.floor(Math.random() * mid.length)] } else { activeTrack = others[Math.floor(Math.random() * others.length)] } // Smooth crossfade on track switch if (activeTrack !== prevTrack && musicGain) { const curVol = musicGain.gain.value musicGain.gain.setValueAtTime(curVol, now) musicGain.gain.linearRampToValueAtTime(curVol * 0.25, now + 0.08) musicGain.gain.linearRampToValueAtTime(curVol, now + 0.4) } } // Chord pad for the whole bar — style-specific pad synth const barDur = STEPS * step const padFreqs = activeTrack.chords[bar].map(f => f * 2) STYLE_PAD[activeTrack.synthStyle](padFreqs, barDur, musicGain, now) for (let i = 0; i < STEPS; i++) { const t = now + i * step const idx = off + i const nd = step - 0.01 // Layer 1: Bass — style-specific synth if (activeTrack.bass[idx] > 0) STYLE_BASS[activeTrack.synthStyle](activeTrack.bass[idx], nd, musicGain, t) // Layer 2: Lead — style-specific synth; verse: needs higher intensity; chorus: plays freely if (activeTrack.lead[idx] > 0 && currentIntensity > leadThreshold) { STYLE_LEAD[activeTrack.synthStyle](activeTrack.lead[idx], nd * 0.8, musicGain, t) } // Layer 3: Arpeggio — style-specific waveform; verse: needs high intensity; chorus: plays freely if (activeTrack.arp[idx] > 0 && currentIntensity > arpThreshold) { chorusTone(activeTrack.arp[idx], STYLE_ARP_TYPE[activeTrack.synthStyle], nd * 0.6, musicGain, t, 0.04 + currentIntensity * 0.04) } // Layer 4: Drums — intensity controls density const d = activeTrack.drums[idx] if (d === 1) kick(musicGain, t) if (d === 2 && currentIntensity > 0.2) snare(musicGain, t) if (d === 3) hihat(musicGain, t, false) if (d === 4 && currentIntensity > 0.4) hihat(musicGain, t, true) // Chorus: extra snare ghost notes on off-beats for energy if (isChorus && currentIntensity > 0.6 && d === 0 && i % 4 === 2 && Math.random() < 0.3) { snare(musicGain, t) } // Extra open hihat at high intensity (musical, not chaotic) if (currentIntensity > 0.85 && i === 14 && Math.random() < 0.3) { hihat(musicGain, t, true) } } barIndex++ musicTimeout = window.setTimeout(playMusicBar, barDur * 1000 - 50) } export function startMusic() { getCtx() if (musicPlaying) return // Pick a random track each time activeTrack = ALL_TRACKS[Math.floor(Math.random() * ALL_TRACKS.length)] musicPlaying = true barIndex = 0 playMusicBar() } export function stopMusic() { musicPlaying = false if (musicTimeout) { clearTimeout(musicTimeout) musicTimeout = null } // Clean up delay if (delayNode) { delayNode.disconnect(); delayNode = null } if (delayGain) { delayGain.disconnect(); delayGain = null } } export function setMusicVolume(v: number) { if (musicGain) musicGain.gain.value = Math.max(0, Math.min(1, v)) } export function setSfxVolume(v: number) { if (sfxGain) sfxGain.gain.value = Math.max(0, Math.min(1, v)) } let masterMuted = false const MUSIC_VOL = 0.12 const SFX_VOL = 0.25 export function setMasterMute(muted: boolean) { masterMuted = muted if (muted) { if (musicGain) musicGain.gain.value = 0 if (sfxGain) sfxGain.gain.value = 0 kokoroStop() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() } else { if (musicGain) musicGain.gain.value = MUSIC_VOL if (sfxGain) sfxGain.gain.value = SFX_VOL } } export function isMasterMuted(): boolean { return masterMuted } let _speechUnlocked = false export async function ensureAudioContext() { const c = getCtx() if (c.state === 'suspended') { try { await c.resume() } catch {} } // Share AudioContext with kokoro and start loading the model. // Defer initKokoro so the click handler finishes first — the 4.8MB module // parse + 86MB model download happen after the UI is unblocked. setAudioContext(c) setTimeout(() => initKokoro(), 0) // Prime speech synthesis as fallback — mobile browsers require // a speak() call inside a user gesture to unlock speechSynthesis if (typeof speechSynthesis !== 'undefined') { if (!voicesLoaded) loadVoices() if (!_speechUnlocked) { _speechUnlocked = true const unlock = new SpeechSynthesisUtterance('') unlock.volume = 0 speechSynthesis.speak(unlock) } } } // === CROWD SOUNDS === // Procedural crowd reactions using layered noise + filtered tones export function sfxCrowdOoh() { const c = getCtx() const d = getSfxDest() const t = c.currentTime // Rising "ooh" — filtered noise sweep for (let i = 0; i < 3; i++) { const osc = c.createOscillator() osc.type = 'sine' osc.frequency.setValueAtTime(200 + i * 60, t) osc.frequency.linearRampToValueAtTime(350 + i * 80, t + 0.4) const g = c.createGain() g.gain.setValueAtTime(0.06, t) g.gain.linearRampToValueAtTime(0.12, t + 0.15) g.gain.exponentialRampToValueAtTime(0.001, t + 0.6) osc.connect(g); g.connect(d) osc.start(t + i * 0.03); osc.stop(t + 0.6) } noise(0.3, d, t) } export function sfxCrowdGasp() { const c = getCtx() const d = getSfxDest() const t = c.currentTime // Sharp intake — high noise burst + quick sine chirps const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.15)) const buf = c.createBuffer(1, bufSize, c.sampleRate) const data = buf.getChannelData(0) for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 const src = c.createBufferSource(); src.buffer = buf const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2000; bp.Q.value = 0.8 const g = c.createGain() g.gain.setValueAtTime(0.18, t) g.gain.exponentialRampToValueAtTime(0.001, t + 0.2) src.connect(bp); bp.connect(g); g.connect(d) src.start(t); src.stop(t + 0.2) // Multiple pitched gasps for (let i = 0; i < 4; i++) { const osc = c.createOscillator() osc.type = 'sine' osc.frequency.value = 400 + Math.random() * 300 const og = c.createGain() og.gain.setValueAtTime(0.04, t + i * 0.02) og.gain.exponentialRampToValueAtTime(0.001, t + 0.25) osc.connect(og); og.connect(d) osc.start(t + i * 0.02); osc.stop(t + 0.25) } } export function sfxCrowdCheer() { const c = getCtx() const d = getSfxDest() const t = c.currentTime // Layered noise + sine clusters = roaring crowd for (let layer = 0; layer < 3; layer++) { const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.8)) const buf = c.createBuffer(1, bufSize, c.sampleRate) const data = buf.getChannelData(0) for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 const src = c.createBufferSource(); src.buffer = buf const bp = c.createBiquadFilter(); bp.type = 'bandpass' bp.frequency.value = 600 + layer * 400; bp.Q.value = 0.5 const g = c.createGain() g.gain.setValueAtTime(0.01, t) g.gain.linearRampToValueAtTime(0.1, t + 0.15) g.gain.setValueAtTime(0.1, t + 0.5) g.gain.exponentialRampToValueAtTime(0.001, t + 0.8) src.connect(bp); bp.connect(g); g.connect(d) src.start(t + layer * 0.05); src.stop(t + 0.8) } } export function sfxApplause() { const c = getCtx() const d = getSfxDest() const t = c.currentTime // Crackling filtered noise = many hands clapping for (let burst = 0; burst < 6; burst++) { const delay = burst * 0.12 + Math.random() * 0.05 const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.06)) const buf = c.createBuffer(1, bufSize, c.sampleRate) const data = buf.getChannelData(0) for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 const src = c.createBufferSource(); src.buffer = buf const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3000 + Math.random() * 2000 const g = c.createGain() g.gain.setValueAtTime(0.08 + Math.random() * 0.04, t + delay) g.gain.exponentialRampToValueAtTime(0.001, t + delay + 0.08) src.connect(hp); hp.connect(g); g.connect(d) src.start(t + delay); src.stop(t + delay + 0.08) } } export function sfxDrumRoll() { const c = getCtx() const d = getSfxDest() const t = c.currentTime // Rapid snare hits building in intensity const hits = 16 for (let i = 0; i < hits; i++) { const hitTime = t + i * 0.04 const vol = 0.05 + (i / hits) * 0.15 const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.03)) const buf = c.createBuffer(1, bufSize, c.sampleRate) const data = buf.getChannelData(0) for (let i2 = 0; i2 < bufSize; i2++) data[i2] = Math.random() * 2 - 1 const src = c.createBufferSource(); src.buffer = buf const g = c.createGain() g.gain.setValueAtTime(vol, hitTime) g.gain.exponentialRampToValueAtTime(0.001, hitTime + 0.04) src.connect(g); g.connect(d) src.start(hitTime); src.stop(hitTime + 0.04) // Body tone tone(180 + i * 5, 'triangle', 0.025, d, hitTime) } } export function announceCrowdReaction(type: 'cheer' | 'gasp' | 'ooh' | 'applause') { const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause } fns[type]() } // === MUSIC INTENSITY === // Dynamically adjust music volume/energy based on fight state let currentIntensity = 0.5 export function setMusicIntensity(level: number) { // level: 0.0 (calm) to 1.0 (maximum hype) currentIntensity = Math.max(0, Math.min(1, level)) if (!musicGain) return const baseVol = 0.06 const maxVol = 0.18 const targetVol = baseVol + (maxVol - baseVol) * currentIntensity const c = getCtx() musicGain.gain.cancelScheduledValues(c.currentTime) musicGain.gain.setTargetAtTime(targetVol, c.currentTime, 0.3) }