// Procedural 8-bit sound system + announcer voice using Web Audio + Speech Synthesis 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. setInterval(() => { 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 (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 function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) { if (typeof speechSynthesis === 'undefined') return if (masterMuted) return if (!voicesLoaded) loadVoices() // Chrome bug: speechSynthesis can get stuck. Nudge it. if (speechSynthesis.paused) speechSynthesis.resume() // Flush if queue is getting deep — max 2 queued to prevent buildup 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++ utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } utter.onerror = (ev) => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) // Retry once on non-cancel errors (interrupted = browser killed it, not us) if (ev.error !== 'canceled' && ev.error !== 'interrupted') { setTimeout(() => { if (!masterMuted && 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++ retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } 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 */ function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise { return new Promise((resolve) => { if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return } if (!voicesLoaded) loadVoices() // No voices available = speech won't work, bail immediately 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() } // Hard safety cap — never block longer than 8s const safetyTimeout = setTimeout(cleanup, 8_000) // Fast bail: if speech hasn't started within 500ms, it's not going to work (mobile/no gesture) const startupCheck = setTimeout(() => { if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup() }, 500) // Desktop Chrome keepalive: periodic pause/resume prevents Chrome's 15s silent cutoff // Do NOT do this on iOS — it permanently kills speech on Safari 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() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() _speechQueueDepth = 0 // 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) { if (masterMuted) return if (pitch !== undefined || rate !== undefined) { if (typeof speechSynthesis === 'undefined') return if (!voicesLoaded) loadVoices() const utter = new SpeechSynthesisUtterance(text) const profile = voiceProfiles.announcer if (profile.voice) utter.voice = profile.voice utter.pitch = pitch ?? 1.0 utter.rate = rate ?? 0.8 utter.volume = VOICE_VOLUME_SCALE _speechQueueDepth++ utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } speechSynthesis.speak(utter) } else { 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] fns[Math.floor(Math.random() * fns.length)]() } // === 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) } // === TRACK DATA === // Each track has: bass, lead, arp patterns (128 steps = 8 bars x 16 steps), chords (8 bars), drums (128 steps), bpm interface MusicTrack { name: string bpm: number bass: number[] lead: number[] arp: number[] chords: number[][] drums: number[] } // === COMPOSED TRACKS — Turrican-style emotionally charged fighting music === // Hand-composed melodies with sweeping arcs, driving bass, shimmering arps // Track 1: "Genesis Block" — Am, 155 BPM — Epic sweeping main theme const track1: MusicTrack = { name: 'Genesis Block', bpm: 155, bass: [ 110,0,220,0, 110,0,165,0, 110,0,220,0, 110,165,220,165, 175,0,349,0, 175,0,262,0, 175,0,349,0, 175,262,349,262, 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,196, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,0, 110,0,220,0, 110,0,165,0, 110,0,220,165, 110,165,220,165, 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,220,294,220, 82,0,165,0, 82,0,123,0, 82,0,165,123, 82,123,165,123, 110,0,220,0, 110,0,165,0, 110,0,220,0, 110,0,0,0, ], lead: [ 659,0,587,523, 494,0,523,587, 659,0,784,880, 784,659,587,523, 698,0,659,587, 523,0,587,659, 698,0,880,1047, 880,698,659,587, 523,0,659,784, 880,0,784,659, 1047,0,988,880, 784,659,523,659, 784,0,880,988, 1175,0,988,880, 784,0,659,587, 523,494,440,0, 659,0,784,880, 988,0,880,784, 659,0,587,659, 784,880,784,659, 587,0,698,880, 1047,0,880,698, 1175,0,1047,880, 698,587,698,880, 659,0,831,988, 1319,0,1175,988, 831,988,1319,831, 988,1319,1661,1319, 880,0,784,659, 523,0,659,784, 880,0,1319,1047, 880,659,523,440, ], arp: [ 440,523,659,523, 440,523,659,880, 659,880,659,523, 440,659,523,440, 349,440,523,440, 349,440,523,698, 523,698,523,440, 349,523,440,349, 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 196,247,294,247, 196,247,294,392, 294,392,294,247, 196,294,247,196, 440,523,659,880, 659,880,1047,880, 659,523,440,523, 659,880,659,523, 294,349,440,349, 294,349,440,587, 440,587,440,349, 294,440,349,294, 330,415,494,415, 330,415,494,659, 494,659,494,415, 330,494,415,330, 440,523,659,523, 440,659,523,440, 659,523,440,330, 440,523,659,0, ], chords: [[220,262,330],[175,220,262],[262,330,392],[196,247,294],[220,262,330],[294,349,440],[165,208,247],[220,262,330]], drums: [ 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,4,0, 1,3,0,3, 2,0,3,3, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,3,3,0, 1,3,0,3, 2,3,4,3, 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,4,0, 1,3,1,3, 2,3,3,0, 1,3,1,3, 2,3,3,3, 1,3,1,3, 2,3,1,3, 2,2,2,2, 1,0,0,0, ], } // Track 2: "Lightning Strike" — Em, 165 BPM — Fast driving energy const track2: MusicTrack = { name: 'Lightning Strike', bpm: 165, bass: [ 82,0,165,0, 82,0,123,0, 82,0,165,0, 82,123,165,123, 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,196, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,147, 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,220,294,220, 82,0,165,0, 82,0,123,0, 82,0,165,123, 82,123,165,123, 110,0,220,0, 110,0,165,0, 110,0,220,0, 110,165,220,165, 123,0,247,0, 123,0,185,0, 123,0,247,185, 123,185,247,185, 82,0,165,0, 82,0,123,0, 82,0,165,0, 82,0,0,0, ], lead: [ 494,0,659,784, 988,0,784,659, 494,0,659,988, 784,659,494,392, 523,0,659,784, 1047,0,784,659, 523,0,659,784, 1047,784,659,523, 784,0,988,1175, 784,0,587,494, 784,0,988,1175, 1319,1175,988,784, 587,0,740,880, 1175,0,880,740, 587,0,740,880, 1175,880,740,587, 659,740,784,880, 988,0,880,784, 659,740,784,988, 1319,988,784,659, 880,0,1047,1319, 880,0,659,523, 880,0,1047,1319, 1175,1047,880,659, 988,0,1245,1480, 988,0,740,622, 988,1245,1480,1976, 1480,1245,988,740, 659,0,784,988, 1319,0,988,784, 659,784,988,1319, 1568,1319,988,659, ], arp: [ 330,392,494,392, 330,392,494,659, 494,659,494,392, 330,494,392,330, 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392, 294,370,440,370, 294,370,440,587, 440,587,440,370, 294,440,370,294, 330,392,494,659, 494,659,784,659, 494,392,330,392, 494,659,494,392, 440,523,659,523, 440,523,659,880, 659,880,659,523, 440,659,523,440, 494,622,740,622, 494,622,740,988, 740,988,740,622, 494,740,622,494, 330,392,494,392, 330,494,392,330, 494,392,330,247, 330,392,494,0, ], chords: [[165,196,247],[131,165,196],[196,247,294],[147,185,220],[165,196,247],[220,262,330],[247,311,370],[165,196,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,4,0, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,3,3,3, 2,3,4,3, 1,0,3,0, 2,0,3,0, 1,3,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,4,0, 1,3,3,0, 2,3,3,3, 1,3,1,3, 2,3,3,3, 1,3,1,3, 2,3,1,3, 2,2,2,2, 1,0,0,0, ], } // Track 3: "Cypherpunk" — Dm, 148 BPM — Dark mysterious atmosphere const track3: MusicTrack = { name: 'Cypherpunk', bpm: 148, bass: [ 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,220,294,220, 117,0,233,0, 117,0,175,0, 117,0,233,0, 117,175,233,175, 175,0,349,0, 175,0,262,0, 175,0,349,0, 175,262,349,262, 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,196, 147,0,294,0, 147,0,220,0, 147,0,294,220, 147,220,294,220, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,147, 110,0,220,0, 110,0,165,0, 110,0,220,165, 110,165,220,165, 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,0,0,0, ], lead: [ 587,0,698,0, 880,0,698,587, 523,0,587,698, 880,698,587,523, 466,0,587,698, 932,0,698,587, 466,0,587,698, 932,880,698,587, 698,0,880,1047, 698,0,523,440, 698,0,880,1047, 1175,1047,880,698, 523,0,659,784, 1047,0,784,659, 523,0,659,784, 1047,932,784,659, 587,698,880,1175, 1397,0,1175,880, 698,0,587,440, 587,698,880,1175, 784,0,932,1175, 784,0,587,466, 784,932,1175,1568, 1175,932,784,587, 880,0,1109,1319, 880,0,659,554, 880,1109,1319,1760, 1319,1109,880,659, 587,0,698,880, 1175,0,880,698, 587,0,698,880, 1175,880,698,587, ], arp: [ 294,349,440,349, 294,349,440,587, 440,587,440,349, 294,440,349,294, 233,294,349,294, 233,294,349,466, 349,466,349,294, 233,349,294,233, 349,440,523,440, 349,440,523,698, 523,698,523,440, 349,523,440,349, 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 294,349,440,587, 440,587,698,587, 440,349,294,349, 440,587,440,349, 196,233,294,233, 196,233,294,392, 294,392,294,233, 196,294,233,196, 220,277,330,277, 220,277,330,440, 330,440,330,277, 220,330,277,220, 294,349,440,349, 294,440,349,294, 440,349,294,220, 294,349,440,0, ], chords: [[147,175,220],[233,294,349],[175,220,262],[131,165,196],[147,175,220],[196,233,294],[220,277,330],[147,175,220]], drums: [ 1,0,0,3, 0,0,2,0, 1,0,0,3, 0,0,2,0, 1,0,0,3, 0,0,2,0, 1,0,3,3, 2,0,0,0, 1,0,0,3, 0,0,2,0, 1,0,3,3, 2,0,0,0, 1,0,0,3, 0,0,2,0, 1,0,0,3, 2,0,4,0, 1,0,3,3, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,4,0, 1,0,3,0, 2,0,3,3, 1,3,3,0, 2,3,3,3, 1,3,1,3, 2,3,1,3, 2,2,4,2, 1,0,0,0, ], } // Track 4: "Hash Storm" — Cm, 170 BPM — Intense boss battle energy const track4: MusicTrack = { name: 'Hash Storm', bpm: 170, bass: [ 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,196, 104,0,208,0, 104,0,156,0, 104,0,208,0, 104,156,208,156, 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, 131,0,262,0, 131,0,196,0, 131,0,262,196, 131,196,262,196, 175,0,349,0, 175,0,262,0, 175,0,349,0, 175,262,349,262, 98,0,196,0, 98,0,147,0, 98,0,196,147, 98,147,196,147, 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,0,0,0, ], lead: [ 784,0,622,523, 392,0,523,622, 784,0,1047,1245, 1047,784,622,523, 831,0,622,523, 415,0,523,622, 831,0,1047,1245, 1047,831,622,523, 622,0,784,932, 1245,0,932,784, 622,0,784,932, 1245,932,784,622, 932,0,1175,1397, 932,0,698,587, 932,1175,1397,1865, 1397,1175,932,698, 523,622,784,1047, 1245,0,1047,784, 622,523,622,784, 1047,1245,1568,1245, 698,0,831,1047, 1397,0,1047,831, 698,0,831,1047, 1397,1047,831,698, 784,0,988,1175, 1568,0,1175,988, 784,988,1175,1568, 1976,1568,1175,988, 1047,0,784,622, 523,0,622,784, 1047,0,1245,1568, 1047,784,622,523, ], arp: [ 262,311,392,311, 262,311,392,523, 392,523,392,311, 262,392,311,262, 208,262,311,262, 208,262,311,415, 311,415,311,262, 208,311,262,208, 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, 262,311,392,523, 392,523,622,523, 392,311,262,311, 392,523,392,311, 349,415,523,415, 349,415,523,698, 523,698,523,415, 349,523,415,349, 392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392, 262,311,392,311, 262,392,311,262, 392,311,262,196, 262,311,392,0, ], chords: [[262,311,392],[208,262,311],[156,196,233],[233,294,349],[262,311,392],[175,208,262],[196,247,294],[262,311,392]], drums: [ 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,4,0, 1,3,1,3, 2,0,3,3, 1,3,0,3, 2,3,3,0, 1,3,1,3, 2,3,3,0, 1,3,3,3, 2,3,4,3, 1,3,1,3, 2,3,3,0, 1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,3, 1,3,1,3, 2,3,4,0, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,1,3, 1,1,1,1, 2,2,2,2, 2,2,2,2, 1,0,0,0, ], } // Track 5: "Proof of Work" — Am, 145 BPM — Grinding determination const track5: MusicTrack = { name: 'Proof of Work', bpm: 145, bass: [ 110,0,220,0, 110,0,165,0, 110,0,220,0, 110,165,220,0, 82,0,165,0, 82,0,123,0, 82,0,165,0, 82,123,165,0, 87,0,175,0, 87,0,131,0, 87,0,175,0, 87,131,175,0, 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,0, 110,0,220,0, 110,0,165,0, 110,0,220,165, 110,165,220,0, 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,220,294,0, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,0, 110,0,220,0, 110,0,165,0, 110,0,220,0, 110,0,0,0, ], lead: [ 440,0,523,659, 880,0,659,523, 440,0,659,880, 1047,880,659,523, 659,0,784,988, 659,0,494,392, 659,0,784,988, 1319,988,784,659, 698,0,880,1047, 698,0,523,440, 698,880,1047,1397, 1047,880,698,523, 659,0,784,1047, 659,0,523,392, 659,784,1047,1319, 1047,784,659,523, 880,0,1047,1319, 880,0,659,523, 880,1047,1319,1760, 1319,1047,880,659, 587,0,698,880, 1175,0,880,698, 587,698,880,1175, 1397,1175,880,698, 784,0,988,1175, 784,0,587,494, 784,988,1175,1568, 1175,988,784,587, 880,0,659,523, 440,0,523,659, 880,0,1047,1319, 880,659,523,440, ], arp: [ 440,523,659,523, 440,523,659,880, 659,880,659,523, 440,659,523,440, 330,392,494,392, 330,392,494,659, 494,659,494,392, 330,494,392,330, 349,440,523,440, 349,440,523,698, 523,698,523,440, 349,523,440,349, 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 440,523,659,880, 659,880,1047,880, 659,523,440,523, 659,880,659,523, 294,349,440,349, 294,349,440,587, 440,587,440,349, 294,440,349,294, 392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392, 440,523,659,523, 440,659,523,440, 659,523,440,330, 440,523,659,0, ], chords: [[220,262,330],[165,196,247],[175,220,262],[131,165,196],[220,262,330],[147,175,220],[196,247,294],[220,262,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,3,0, 2,0,0,0, 1,0,0,3, 2,0,3,0, 1,0,0,3, 2,0,0,3, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,4,0, 1,0,3,0, 2,0,3,0, 1,0,0,3, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,4,0, 1,0,3,0, 2,0,3,3, 1,0,3,3, 2,3,3,3, 1,3,1,3, 2,3,1,3, 2,2,4,0, 1,0,0,0, ], } // Track 6: "Satoshi's Dream" — Gm, 140 BPM — Beautiful melancholic melody const track6: MusicTrack = { name: "Satoshi's Dream", bpm: 140, bass: [ 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,0, 156,0,311,0, 156,0,233,0, 156,0,311,0, 156,233,311,0, 117,0,233,0, 117,0,175,0, 117,0,233,0, 117,175,233,0, 87,0,175,0, 87,0,131,0, 87,0,175,0, 87,131,175,0, 98,0,196,0, 98,0,147,0, 98,0,196,147, 98,147,196,0, 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,0, 147,0,294,0, 147,0,220,0, 147,0,294,220, 147,220,294,0, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,0,0,0, ], lead: [ 587,0,784,932, 1175,0,932,784, 587,0,466,587, 784,932,1175,932, 622,0,784,932, 1245,0,932,784, 622,0,784,932, 1245,1175,932,784, 932,0,1175,1397, 932,0,698,587, 466,0,587,698, 932,1175,1397,1175, 698,0,880,1047, 698,0,523,440, 698,880,1047,1397, 1047,880,698,523, 784,932,1175,1568, 1175,0,932,784, 587,0,466,392, 587,784,932,1175, 523,0,622,784, 1047,0,784,622, 523,622,784,1047, 1245,1047,784,622, 587,0,740,880, 1175,0,880,740, 587,740,880,1175, 1480,1175,880,740, 784,0,587,466, 392,0,466,587, 784,0,932,1175, 784,587,466,392, ], arp: [ 196,233,294,233, 196,233,294,392, 294,392,294,233, 196,294,233,196, 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, 349,440,523,440, 349,440,523,698, 523,698,523,440, 349,523,440,349, 196,233,294,392, 294,392,466,392, 294,233,196,233, 294,392,294,233, 262,311,392,311, 262,311,392,523, 392,523,392,311, 262,392,311,262, 294,370,440,370, 294,370,440,587, 440,587,440,370, 294,440,370,294, 196,233,294,233, 196,294,233,196, 294,233,196,147, 196,233,294,0, ], chords: [[196,233,294],[156,196,233],[233,294,349],[175,220,262],[196,233,294],[131,156,196],[147,185,220],[196,233,294]], drums: [ 1,0,0,0, 2,0,0,3, 1,0,0,0, 2,0,0,0, 1,0,0,3, 2,0,0,0, 1,0,0,3, 2,0,0,0, 1,0,0,3, 2,0,0,0, 1,0,3,0, 2,0,0,3, 1,0,0,3, 2,0,0,3, 1,0,3,0, 2,0,4,0, 1,0,3,0, 2,0,0,3, 1,0,0,3, 2,0,0,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,4,0, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,3,3, 1,0,3,3, 2,0,3,3, 2,0,4,0, 1,0,0,0, ], } // Track 7: "Block Height" — Em, 160 BPM — Rising momentum const track7: MusicTrack = { name: 'Block Height', bpm: 160, bass: [ 82,0,165,0, 82,0,123,0, 82,0,165,0, 82,123,165,0, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,0, 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,220,294,0, 131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,0, 82,0,165,0, 82,0,123,0, 82,0,165,123, 82,123,165,0, 110,0,220,0, 110,0,165,0, 110,0,220,0, 110,165,220,0, 123,0,247,0, 123,0,185,0, 123,0,247,185, 123,185,247,0, 82,0,165,0, 82,0,123,0, 82,0,165,0, 82,0,0,0, ], lead: [ 659,0,784,988, 659,0,494,392, 659,784,988,1319, 988,784,659,494, 784,0,988,1175, 784,0,587,494, 784,988,1175,1568, 1175,988,784,587, 587,0,740,880, 1175,0,880,740, 587,740,880,1175, 1480,1175,880,740, 523,0,659,784, 1047,0,784,659, 523,659,784,1047, 1319,1047,784,659, 659,784,988,1319, 1568,0,1319,988, 784,0,659,784, 988,1319,1568,1319, 880,0,1047,1319, 880,0,659,523, 880,1047,1319,1760, 1319,1047,880,659, 988,0,1245,1480, 988,0,740,622, 988,1245,1480,1976, 1480,1245,988,740, 1319,0,988,784, 659,0,784,988, 1319,0,1568,1976, 1319,988,784,659, ], arp: [ 330,392,494,392, 330,392,494,659, 494,659,494,392, 330,494,392,330, 392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392, 294,370,440,370, 294,370,440,587, 440,587,440,370, 294,440,370,294, 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 330,392,494,659, 494,659,784,659, 494,392,330,392, 494,659,494,392, 440,523,659,523, 440,523,659,880, 659,880,659,523, 440,659,523,440, 494,622,740,622, 494,622,740,988, 740,988,740,622, 494,740,622,494, 330,392,494,392, 330,494,392,330, 494,392,330,247, 330,392,494,0, ], chords: [[165,196,247],[196,247,294],[147,185,220],[131,165,196],[165,196,247],[220,262,330],[247,311,370],[165,196,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,4,0, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,3,3,3, 2,3,4,0, 1,0,3,0, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,4,0, 1,3,3,0, 2,3,3,3, 1,3,1,3, 2,3,3,3, 1,3,1,3, 2,3,1,3, 2,2,4,0, 1,0,0,0, ], } // Track 8: "Zero Conf" — Fm, 175 BPM — Urgent racing energy const track8: MusicTrack = { name: 'Zero Conf', bpm: 175, bass: [ 87,0,175,0, 87,0,131,0, 87,0,175,0, 87,131,175,131, 139,0,277,0, 139,0,208,0, 139,0,277,0, 139,208,277,208, 104,0,208,0, 104,0,156,0, 104,0,208,0, 104,156,208,156, 156,0,311,0, 156,0,233,0, 156,0,311,0, 156,233,311,233, 87,0,175,0, 87,0,131,0, 87,0,175,131, 87,131,175,131, 117,0,233,0, 117,0,175,0, 117,0,233,0, 117,175,233,175, 131,0,262,0, 131,0,196,0, 131,0,262,196, 131,196,262,196, 87,0,175,0, 87,0,131,0, 87,0,175,0, 87,0,0,0, ], lead: [ 698,0,831,1047, 698,0,523,415, 698,831,1047,1397, 1047,831,698,523, 554,0,698,831, 1109,0,831,698, 554,698,831,1109, 1397,1109,831,698, 831,0,1047,1245, 831,0,622,523, 831,1047,1245,1661, 1245,1047,831,622, 622,0,784,932, 1245,0,932,784, 622,784,932,1245, 1568,1245,932,784, 1047,831,698,523, 698,831,1047,1397, 1661,0,1397,1047, 831,698,523,415, 932,0,1109,1397, 932,0,698,554, 932,1109,1397,1865, 1397,1109,932,698, 523,0,659,784, 1047,0,784,659, 523,659,784,1047, 1319,1047,784,659, 698,831,1047,1397, 1661,0,1397,1047, 831,0,698,831, 1047,698,415,349, ], arp: [ 349,415,523,415, 349,415,523,698, 523,698,523,415, 349,523,415,349, 277,349,415,349, 277,349,415,554, 415,554,415,349, 277,415,349,277, 415,523,622,523, 415,523,622,831, 622,831,622,523, 415,622,523,415, 311,392,466,392, 311,392,466,622, 466,622,466,392, 311,466,392,311, 349,415,523,698, 523,698,831,698, 523,415,349,415, 523,698,523,415, 466,554,698,554, 466,554,698,932, 698,932,698,554, 466,698,554,466, 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 349,415,523,415, 349,523,415,349, 523,415,349,262, 349,415,523,0, ], chords: [[175,208,262],[139,175,208],[208,262,311],[156,196,233],[175,208,262],[233,277,349],[131,165,196],[175,208,262]], drums: [ 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,4,0, 1,3,1,3, 2,3,3,0, 1,3,0,3, 2,0,3,3, 1,3,1,3, 2,0,3,3, 1,3,3,3, 2,3,4,3, 1,3,1,3, 2,3,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,0, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,3,3, 1,1,1,1, 2,2,2,2, 2,2,4,2, 1,0,0,0, ], } // Track 9: "Mempool" — Dm, 152 BPM — Dark controlled chaos const track9: MusicTrack = { name: 'Mempool', bpm: 152, bass: [ 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,220,294,220, 98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,147, 117,0,233,0, 117,0,175,0, 117,0,233,0, 117,175,233,175, 110,0,220,0, 110,0,165,0, 110,0,220,0, 110,165,220,165, 147,0,294,0, 147,0,220,0, 147,0,294,220, 147,220,294,220, 87,0,175,0, 87,0,131,0, 87,0,175,0, 87,131,175,131, 131,0,262,0, 131,0,196,0, 131,0,262,196, 131,196,262,196, 147,0,294,0, 147,0,220,0, 147,0,294,0, 147,0,0,0, ], lead: [ 587,698,880,587, 698,880,1175,880, 698,587,440,587, 698,880,1175,1397, 784,0,932,1175, 784,0,587,466, 784,932,1175,1568, 1175,932,784,587, 932,0,1175,1397, 932,0,698,587, 466,587,698,932, 1175,1397,1865,1397, 880,0,1109,1319, 880,0,659,554, 880,1109,1319,1760, 1319,1109,880,659, 587,0,880,1175, 1397,0,1175,880, 698,587,698,880, 1175,1397,1760,1397, 698,0,880,1047, 698,0,523,440, 698,880,1047,1397, 1760,1397,1047,880, 523,659,784,1047, 1319,0,1047,784, 659,523,659,784, 1047,1319,1568,1319, 1175,0,880,698, 587,0,698,880, 1175,0,1397,1760, 1175,880,698,587, ], arp: [ 294,349,440,349, 294,349,440,587, 440,587,440,349, 294,440,349,294, 196,233,294,233, 196,233,294,392, 294,392,294,233, 196,294,233,196, 233,294,349,294, 233,294,349,466, 349,466,349,294, 233,349,294,233, 220,277,330,277, 220,277,330,440, 330,440,330,277, 220,330,277,220, 294,349,440,587, 440,587,698,587, 440,349,294,349, 440,587,440,349, 349,440,523,440, 349,440,523,698, 523,698,523,440, 349,523,440,349, 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 294,349,440,349, 294,440,349,294, 440,349,294,220, 294,349,440,0, ], chords: [[147,175,220],[196,233,294],[233,294,349],[220,277,330],[147,175,220],[175,220,262],[131,165,196],[147,175,220]], drums: [ 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,3,0, 1,0,3,3, 2,0,3,0, 1,3,3,0, 2,0,3,3, 1,0,3,0, 2,3,3,0, 1,3,3,3, 2,3,4,3, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,4,0, 1,3,3,0, 2,3,3,3, 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,1,3, 2,2,4,0, 1,0,0,0, ], } // Track 10: "Halving Day" — Cm, 162 BPM — Triumphant climax const track10: MusicTrack = { name: 'Halving Day', bpm: 162, 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, ], chords: [[262,311,392],[156,196,233],[233,294,349],[196,247,294],[262,311,392],[208,262,311],[175,208,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,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 (+10 BPM at max) const dynamicBpm = activeTrack.bpm + currentIntensity * 10 const beat = 60 / dynamicBpm const now = c.currentTime + 0.05 const bar = barIndex % BARS const off = bar * STEPS const step = beat / 2 // Switch tracks at musical boundaries (full 8-bar cycles) const chillTracks = ALL_TRACKS.filter(t => t.bpm < 160) const midTracks = ALL_TRACKS.filter(t => t.bpm >= 160 && t.bpm < 175) const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 175) const switchEvery = currentIntensity > 0.7 ? 8 : currentIntensity > 0.4 ? 16 : 24 if (barIndex > 0 && barIndex % switchEvery === 0) { const pickFrom = (pool: MusicTrack[]) => { const others = pool.filter(t => t !== activeTrack) return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : pool[0] } const prevTrack = activeTrack if (currentIntensity > 0.7) { activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS) } else if (currentIntensity < 0.3) { activeTrack = pickFrom(chillTracks.length > 0 ? chillTracks : ALL_TRACKS) } else if (Math.random() < 0.5) { activeTrack = pickFrom(midTracks.length > 0 ? midTracks : ALL_TRACKS) } else { // Full random for maximum variety activeTrack = pickFrom(ALL_TRACKS) } // 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 const barDur = STEPS * step chordPad(activeTrack.chords[bar].map(f => f * 2), 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 — always plays but gets thicker with intensity if (activeTrack.bass[idx] > 0) thickBass(activeTrack.bass[idx], nd, musicGain, t) // Layer 2: FM lead — fades in above 0.3 intensity if (activeTrack.lead[idx] > 0 && currentIntensity > 0.3) { fmLead(activeTrack.lead[idx], nd * 0.8, musicGain, t) } // Layer 3: Arpeggio — fades in above 0.5 intensity if (activeTrack.arp[idx] > 0 && currentIntensity > 0.5) { chorusTone(activeTrack.arp[idx], 'triangle', nd * 0.6, musicGain, t, 0.04 + currentIntensity * 0.04) } // Layer 4: Drums — always, but 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) // 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 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 {} } // Prime speech synthesis on user gesture — 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) }