Files
botfights/frontend/src/game/sounds.ts
T

2003 lines
82 KiB
TypeScript
Raw Normal View History

// 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
// Lazily create AudioContext + gain nodes on first use.
// Chrome may warn about autoplay policy but the context will resume once ensureAudioContext()
// is called from a user gesture. All SFX calls before that are queued/silent.
function getCtx(): AudioContext {
if (!ctx) {
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)
}
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<string, VoiceProfile> = {
// 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
// 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.7, rate: 0.7, 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)]
// 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)
}
let _speechQueueDepth = 0
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()
// Only flush if queue is getting deep — allows voices to overlap naturally
if (cancelPrevious || (speechSynthesis.pending && speechSynthesis.speaking)) {
if (_speechQueueDepth > 3) {
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
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
speechSynthesis.speak(utter)
}
export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
_speechQueueDepth = 0
}
// 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 = 1.0
_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)
}
// === 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[]
}
// Track 1: "Neon Fury" — Cm, relentless, 225bpm
const track1: MusicTrack = {
name: 'Neon Fury', bpm: 225,
bass: [
131,165,196,131, 165,196,220,196, 131,196,220,262, 220,196,165,131,
208,262,311,208, 262,311,349,311, 196,262,330,262, 311,262,208,196,
156,208,262,311, 262,208,156,208, 196,262,330,392, 330,262,196,262,
196,247,294,330, 294,247,196,247, 262,330,392,440, 392,330,262,196,
131,131,165,196, 220,262,220,196, 208,208,262,311, 349,311,262,208,
156,156,208,262, 311,349,311,262, 196,196,262,330, 392,440,392,330,
131,196,262,330, 392,330,262,196, 208,311,415,466, 415,311,262,208,
196,262,330,392, 440,523,440,392, 262,330,440,523, 587,523,440,330,
],
lead: [
523,622,784,622, 523,622,784,1047, 784,622,523,622, 784,1047,784,622,
831,784,622,523, 622,784,831,1047, 1175,1047,831,784, 622,784,831,1047,
622,784,831,1047, 831,784,622,784, 831,1047,1175,1319, 1175,1047,831,784,
784,831,1047,1319, 1175,1047,831,784, 1047,1319,1568,1760, 1568,1319,1047,831,
523,622,784,1047, 784,622,523,466, 622,784,1047,1319, 1047,784,622,523,
831,784,622,784, 831,1047,1175,1319, 1175,1047,831,784, 622,784,1047,1175,
784,1047,1319,1568, 1319,1047,784,622, 1047,1319,1568,1760, 1568,1319,1047,784,
1047,1319,1568,1760, 2093,1760,1568,1319, 1568,1760,2093,1760, 1568,1319,1047,831,
],
arp: [
262,330,392,523, 392,330,262,330, 392,523,662,523, 392,330,262,330,
415,523,622,831, 622,523,415,523, 622,831,1047,831, 622,523,415,523,
311,392,466,622, 466,392,311,392, 466,622,831,622, 466,392,311,392,
392,494,587,784, 587,494,392,494, 587,784,1047,784, 587,494,392,494,
262,392,523,784, 523,392,262,392, 415,523,622,831, 831,622,523,415,
311,466,622,831, 622,466,311,466, 392,587,784,1047, 1047,784,587,392,
262,523,784,1047, 1319,1047,784,523, 415,622,831,1175, 1175,831,622,415,
392,784,1047,1319, 1568,1319,1047,784, 523,1047,1319,1568, 1760,1568,1319,1047,
],
chords: [[131,156,196],[208,262,311],[156,196,233],[196,247,294],[131,156,196],[156,196,233],[208,262,311],[196,247,294]],
drums: [
1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3, 1,3,1,3, 2,3,1,1, 1,1,4,3, 2,1,1,3,
1,3,1,3, 2,1,1,3, 1,1,4,1, 2,1,1,1, 1,1,1,3, 2,1,4,1, 2,1,1,1, 2,2,1,1,
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,2,1,
1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,1, 2,2,2,1, 2,2,2,2, 2,2,2,2,
],
}
// Track 2: "Dark Circuit" — Em, relentless grind, 235bpm
const track2: MusicTrack = {
name: 'Dark Circuit', bpm: 235,
bass: [
82,110,82,110, 82,110,147,110, 110,147,110,147, 110,82,110,82,
98,131,98,131, 98,131,165,131, 131,165,131,165, 131,98,131,98,
82,82,110,147, 165,147,110,82, 98,98,131,165, 196,165,131,98,
110,147,165,196, 165,147,110,82, 131,165,196,220, 196,165,131,98,
82,110,147,196, 147,110,82,110, 98,131,165,220, 165,131,98,131,
82,110,147,165, 196,165,147,110, 98,131,165,196, 220,196,165,131,
82,147,196,220, 247,220,196,147, 98,165,220,247, 294,247,220,165,
110,147,196,247, 294,330,294,247, 82,110,196,247, 330,294,247,196,
],
lead: [
659,784,880,784, 659,784,880,1047, 784,880,1047,880, 784,659,784,880,
784,880,1047,880, 784,880,1047,1175, 1047,1175,1319,1175, 1047,880,784,880,
659,784,880,1047, 880,784,659,784, 880,1047,1175,1319, 1175,1047,880,784,
880,1047,1175,1319, 1175,1047,880,784, 1047,1175,1319,1568, 1319,1175,1047,880,
659,784,880,1047, 1175,1047,880,784, 784,880,1047,1175, 1319,1175,1047,880,
880,1047,1175,1047, 880,784,880,1047, 1047,1175,1319,1175, 1047,880,1047,1175,
659,880,1175,1568, 1175,880,659,880, 784,1047,1319,1760, 1319,1047,784,1047,
880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 1760,1568,1319,1047,
],
arp: [
330,392,494,659, 494,392,330,392, 494,659,880,659, 494,392,330,392,
392,494,587,784, 587,494,392,494, 587,784,1047,784, 587,494,392,494,
330,494,659,880, 1175,880,659,494, 392,587,784,1047, 1319,1047,784,587,
494,659,880,1175, 1175,880,659,494, 587,784,1047,1319, 1319,1047,784,587,
330,392,494,659, 880,659,494,392, 392,494,587,784, 1047,784,587,494,
330,494,659,880, 659,494,330,494, 392,587,784,1047, 784,587,392,587,
330,659,880,1175, 1568,1175,880,659, 392,784,1047,1319, 1568,1319,1047,784,
494,880,1175,1568, 1760,1568,1175,880, 330,880,1175,1760, 2093,1760,1175,880,
],
chords: [[165,196,247],[196,247,294],[131,165,196],[147,175,220],[165,196,247],[131,165,196],[196,247,294],[147,175,220]],
drums: [
1,3,1,3, 2,3,1,1, 1,3,1,3, 2,1,1,3, 1,1,1,3, 2,3,1,1, 1,1,4,1, 2,1,1,1,
1,3,1,1, 2,1,1,3, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1,
1,1,1,3, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1,
1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,2, 2,2,2,1, 2,2,2,2, 2,2,2,2,
],
}
// Track 3: "Pixel Blitz" — F major, frantic energy, 245bpm
const track3: MusicTrack = {
name: 'Pixel Blitz', bpm: 245,
bass: [
175,220,262,175, 220,262,330,262, 233,294,349,233, 294,349,440,349,
208,262,330,208, 262,330,392,330, 262,330,392,262, 330,392,440,392,
175,175,220,262, 330,262,220,175, 233,233,294,349, 440,349,294,233,
208,208,262,330, 392,330,262,208, 262,330,392,440, 523,440,392,330,
175,220,262,330, 392,330,262,220, 233,294,349,440, 523,440,349,294,
208,262,330,392, 440,392,330,262, 262,330,392,440, 523,587,523,440,
175,262,349,440, 523,440,349,262, 233,349,440,523, 587,523,440,349,
208,330,440,523, 587,523,440,330, 175,349,440,523, 587,698,587,523,
],
lead: [
698,784,880,784, 698,784,880,1047, 880,1047,1175,1047, 880,784,698,784,
784,880,1047,880, 784,880,1047,1175, 1047,1175,1319,1175, 1047,880,784,880,
698,784,880,1047, 1175,1047,880,784, 880,1047,1175,1319, 1568,1319,1175,1047,
1047,1175,1319,1568, 1319,1175,1047,880, 1175,1319,1568,1760, 1568,1319,1175,1047,
698,880,1047,1319, 1047,880,698,880, 784,1047,1175,1568, 1175,1047,784,1047,
880,1047,1175,1047, 880,784,880,1047, 1175,1319,1568,1319, 1175,1047,1175,1319,
698,880,1175,1568, 1760,1568,1175,880, 784,1047,1319,1760, 2093,1760,1319,1047,
880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 2349,2093,1760,1319,
],
arp: [
349,440,523,698, 523,440,349,440, 523,698,932,698, 523,440,349,440,
466,587,698,932, 698,587,466,587, 698,932,1175,932, 698,587,466,587,
415,523,659,880, 1175,880,659,523, 523,659,784,1047, 1319,1047,784,659,
523,659,784,1047, 1319,1047,784,659, 698,880,1047,1319, 1568,1319,1047,880,
349,523,698,932, 1175,932,698,523, 466,698,932,1175, 1568,1175,932,698,
415,659,880,1175, 1568,1175,880,659, 523,784,1047,1319, 1760,1319,1047,784,
349,698,1047,1319, 1568,1319,1047,698, 466,932,1175,1568, 1760,1568,1175,932,
523,1047,1319,1760, 2093,1760,1319,1047, 698,1175,1568,2093, 2349,2093,1568,1175,
],
chords: [[175,220,262],[233,294,349],[208,262,330],[262,330,392],[175,220,262],[208,262,330],[233,294,349],[262,330,392]],
drums: [
1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1, 1,1,1,3, 2,1,1,1, 1,1,4,1, 2,1,1,1,
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1,
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1,
1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,2, 2,2,2,2, 2,2,2,2, 2,2,2,2,
],
}
// Track 4: "Skull Crusher" — Am, insane thrash, 260bpm
const track4: MusicTrack = {
name: 'Skull Crusher', bpm: 260,
bass: [
110,131,110,131, 110,131,165,131, 131,165,131,165, 131,110,131,110,
98,110,98,131, 131,165,131,110, 110,131,165,196, 165,131,110,131,
110,110,131,165, 196,220,196,165, 98,98,131,165, 196,220,196,165,
110,131,165,196, 220,247,220,196, 98,131,165,196, 220,247,294,247,
110,131,165,196, 220,196,165,131, 98,110,131,165, 196,165,131,110,
110,110,131,165, 196,220,247,220, 98,98,131,165, 196,247,294,247,
110,131,196,247, 294,330,294,247, 98,131,196,247, 330,349,330,247,
110,165,220,294, 330,349,330,294, 110,165,247,330, 392,349,330,247,
],
lead: [
880,1047,880,1047, 1175,1047,880,1047, 1047,1175,1047,1175, 1319,1175,1047,1175,
784,880,784,880, 1047,880,784,880, 880,1047,880,1047, 1175,1047,880,1047,
880,1047,1175,1319, 1175,1047,880,1047, 1047,1175,1319,1568, 1319,1175,1047,1175,
1175,1319,1568,1760, 1568,1319,1175,1047, 1319,1568,1760,2093, 1760,1568,1319,1175,
880,1047,1175,1319, 1568,1319,1175,1047, 784,880,1047,1175, 1319,1175,1047,880,
880,1047,1319,1568, 1319,1047,880,1047, 1047,1175,1568,1760, 1568,1175,1047,1175,
880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 2349,2093,1760,1319,
1175,1568,2093,2349, 2093,1760,1568,1175, 880,1319,1760,2349, 2637,2349,1760,1319,
],
arp: [
220,262,330,440, 587,440,330,262, 196,247,294,392, 523,392,294,247,
220,330,440,587, 784,587,440,330, 196,294,392,523, 698,523,392,294,
220,330,440,587, 880,587,440,330, 196,294,392,523, 784,523,392,294,
220,440,587,880, 1175,880,587,440, 196,392,523,784, 1047,784,523,392,
220,262,330,440, 587,440,330,262, 196,247,294,392, 523,392,294,247,
220,330,440,587, 880,587,440,330, 196,294,392,523, 784,523,392,294,
220,440,880,1175, 1568,1175,880,440, 196,392,784,1047, 1568,1047,784,392,
220,440,880,1568, 2093,1568,880,440, 220,880,1175,1760, 2349,1760,1175,880,
],
chords: [[110,131,165],[98,131,147],[131,165,196],[147,175,220],[110,131,165],[131,165,196],[98,131,147],[147,175,220]],
drums: [
1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1,
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1,
1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1,
1,1,1,1, 2,2,4,2, 1,1,1,1, 2,2,4,2, 2,2,2,2, 2,2,2,2, 2,2,2,2, 2,2,2,2,
],
}
// === TRACK GENERATOR ===
// Genre types for distinct melodic DNA per style
type Genre = 'funk' | 'hiphop' | 'rock' | 'metal' | 'chiptune' | 'jazz' | 'electronic' | 'latin' | 'reggae'
// Genre-specific bass patterns (each genre has its own feel)
const GENRE_BASS: Record<Genre, number[][]> = {
funk: [
[0,0,0,4, 0,0,2,0, 3,0,0,5, 0,3,0,2], // syncopated slap bass
[0,0,4,0, 2,0,0,4, 0,3,0,5, 3,0,2,0],
[0,2,0,4, 0,0,5,0, 3,0,4,0, 2,0,0,4],
[0,0,0,0, 4,0,0,2, 0,0,3,0, 5,0,4,0],
],
hiphop: [
[0,0,0,0, 0,0,0,0, 3,0,0,0, 0,0,0,0], // deep sparse hits
[0,0,0,0, 2,0,0,0, 0,0,0,0, 4,0,0,0],
[0,0,0,2, 0,0,0,0, 0,0,3,0, 0,0,0,0],
[0,0,0,0, 0,0,2,0, 0,0,0,0, 0,0,4,2],
],
rock: [
[0,0,0,0, 2,2,0,0, 4,4,0,0, 2,2,0,0], // power chord root pumps
[0,0,2,2, 0,0,4,4, 0,0,5,5, 4,4,2,2],
[0,0,0,0, 0,0,0,0, 4,4,4,4, 2,2,0,0],
[0,2,4,2, 0,2,4,5, 4,2,0,2, 4,5,7,5],
],
metal: [
[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0], // tremolo root
[0,0,0,1, 0,0,0,1, 0,0,0,1, 0,0,0,1], // palm mute gallop
[0,0,3,0, 0,0,5,0, 0,0,3,0, 0,0,1,0], // staccato riff
[0,1,0,3, 0,1,0,5, 0,1,0,3, 5,3,1,0], // thrash riff
],
chiptune: [
[0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0], // classic bouncy
[0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2],
[0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0],
[0,0,4,7, 4,0,0,4, 3,3,7,10, 7,3,0,3],
],
jazz: [
[0,2,4,5, 7,5,4,2, 0,3,5,7, 9,7,5,3], // walking bass
[0,4,7,4, 2,5,9,5, 4,7,11,7, 5,4,2,0],
[0,1,2,3, 4,5,4,3, 2,3,4,5, 7,5,4,2], // chromatic walk
[0,3,7,3, 5,2,7,4, 0,4,7,4, 9,7,4,2],
],
electronic: [
[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0], // pulsing single note
[0,0,0,0, 4,4,4,4, 0,0,0,0, 7,7,7,7], // alternating octaves
[0,0,4,0, 0,0,7,0, 0,0,4,0, 0,0,2,0],
[0,4,0,7, 0,4,0,9, 0,4,0,7, 0,9,0,4],
],
latin: [
[0,0,0,4, 0,0,2,0, 0,0,0,4, 0,2,0,0], // tresillo bass
[0,0,0,3, 0,0,5,0, 0,0,0,3, 0,5,0,0],
[0,4,0,0, 7,0,0,4, 0,5,0,0, 7,0,4,0],
[0,0,4,0, 0,7,0,4, 0,0,5,0, 0,7,0,5],
],
reggae: [
[0,0,0,0, 0,0,0,0, 4,0,0,0, 0,0,0,0], // one-drop bass
[0,0,0,0, 0,0,0,0, 0,0,0,0, 4,0,2,0],
[0,0,0,0, 4,0,0,0, 0,0,0,0, 2,0,0,0],
[0,0,0,0, 0,0,4,0, 0,0,0,0, 0,0,2,0],
],
}
// Genre-specific lead patterns
const GENRE_LEAD: Record<Genre, number[][]> = {
funk: [
[0,0,4,0, 0,7,0,4, 0,0,9,0, 0,7,0,0], // wah-wah stabs
[4,0,0,7, 0,0,4,0, 9,0,0,7, 0,0,4,0],
[0,7,0,0, 4,0,0,7, 0,9,0,0, 7,0,4,0],
[0,0,0,4, 7,0,0,0, 0,0,0,9, 7,4,0,0],
],
hiphop: [
[7,0,0,0, 4,0,0,0, 7,0,0,0, 9,0,0,0], // looping melodic hook
[4,0,7,0, 0,0,4,0, 9,0,7,0, 0,0,4,0],
[0,0,7,0, 0,0,9,0, 0,0,7,0, 0,0,4,0],
[7,4,0,0, 9,7,0,0, 7,4,0,0, 2,4,0,0],
],
rock: [
[0,2,4,7, 9,7,4,2, 0,2,4,7, 11,9,7,4], // pentatonic licks
[7,9,11,9, 7,4,2,4, 7,9,11,14, 11,9,7,4],
[0,4,7,0, 4,7,11,7, 4,0,4,7, 11,14,11,7],
[14,11,9,7, 4,2,0,2, 4,7,9,11, 14,11,9,7],
],
metal: [
[0,0,0,0, 12,11,0,0, 0,0,0,0, 7,5,0,0], // shred bursts
[0,0,12,14, 16,14,12,0, 0,0,7,9, 11,9,7,0],
[14,0,12,0, 11,0,9,0, 7,0,5,0, 4,0,2,0], // descending shred
[0,2,4,7, 0,2,4,9, 0,2,4,11, 12,11,9,7], // ascending runs
],
chiptune: [
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,-2],
[0,4,7,11, 9,7,4,2, 0,4,9,11, 14,11,9,7],
[7,9,11,14, 11,9,7,4, 9,11,14,16, 14,11,9,7],
[0,2,4,2, 7,4,2,0, 4,7,9,11, 9,7,4,2],
],
jazz: [
[0,2,4,5, 7,9,11,9, 7,5,4,2, 0,2,4,7], // bebop lines
[4,5,7,9, 11,9,7,5, 4,2,0,2, 4,7,9,11],
[0,1,2,4, 5,7,9,7, 5,4,2,1, 0,4,7,11], // chromatic approach
[7,9,11,12, 14,12,11,9, 7,5,4,5, 7,9,11,14],
],
electronic: [
[0,0,7,0, 0,0,7,0, 0,0,9,0, 0,0,7,0], // filter sweep feel
[0,4,7,4, 0,4,9,4, 0,4,11,4, 0,4,9,4],
[7,7,7,7, 9,9,9,9, 11,11,11,11, 9,9,9,9], // pulsing
[0,7,0,9, 0,11,0,9, 0,7,0,4, 0,7,0,9],
],
latin: [
[0,4,7,0, 4,7,9,7, 4,0,4,7, 9,7,4,0], // salsa piano
[7,0,9,0, 7,0,4,0, 7,0,9,0, 11,0,9,0],
[0,2,4,7, 0,2,4,9, 0,2,4,7, 9,7,4,2],
[4,7,9,4, 7,9,11,7, 9,11,14,9, 11,9,7,4],
],
reggae: [
[0,0,4,0, 0,0,4,0, 0,0,7,0, 0,0,4,0], // skank chops
[0,0,7,0, 0,0,7,0, 0,0,9,0, 0,0,7,0],
[0,0,4,7, 0,0,4,7, 0,0,4,9, 0,0,4,7],
[0,0,0,4, 0,0,0,7, 0,0,0,4, 0,0,0,2],
],
}
// Genre-specific arp patterns
const GENRE_ARP: Record<Genre, number[][]> = {
funk: [
[0,4,7,0, 0,4,7,0, 0,4,9,0, 0,4,7,0],
[0,0,4,0, 7,0,4,0, 0,0,9,0, 7,0,4,0],
],
hiphop: [
[0,0,0,0, 7,0,0,0, 0,0,0,0, 4,0,0,0], // sparse melodic
[0,0,7,0, 0,0,0,0, 0,0,4,0, 0,0,0,0],
],
rock: [
[0,4,7,4, 0,4,7,4, 0,4,9,4, 0,4,7,4], // power chord arp
[0,7,4,7, 0,7,4,7, 0,9,4,9, 0,7,4,7],
],
metal: [
[0,0,7,0, 0,0,7,0, 0,0,7,0, 12,0,7,0], // tremolo picking
[0,7,12,7, 0,7,12,7, 0,7,14,7, 0,7,12,7],
],
chiptune: [
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,2],
[0,4,7,11, 7,4,0,4, 7,11,14,11, 7,4,0,4],
[0,7,4,11, 7,14,11,7, 4,11,7,14, 11,4,7,0],
],
jazz: [
[0,4,7,9, 11,9,7,4, 0,4,7,11, 14,11,7,4],
[0,2,4,7, 9,7,4,2, 0,4,9,11, 9,7,4,0],
],
electronic: [
[0,4,7,4, 0,4,7,4, 0,4,7,4, 0,4,7,4], // relentless arp
[0,7,4,11, 0,7,4,11, 0,9,4,11, 0,7,4,11],
[0,4,7,11, 14,11,7,4, 0,4,7,11, 14,11,7,4],
],
latin: [
[0,4,7,0, 4,7,9,7, 4,0,4,7, 9,7,4,0],
[0,7,4,7, 0,9,4,9, 0,7,4,7, 0,4,7,4],
],
reggae: [
[0,0,4,0, 0,0,7,0, 0,0,4,0, 0,0,2,0],
[0,0,7,0, 0,0,4,0, 0,0,7,0, 0,0,9,0],
],
}
// Genre-specific chord progressions (scale degrees for triads)
const GENRE_CHORDS: Record<Genre, number[]> = {
funk: [0, 3, 0, 5, 0, 3, 5, 4], // i-iv-i-v groovy
hiphop: [0, 3, 4, 3, 0, 5, 4, 3], // dark minor loops
rock: [0, 5, 3, 4, 0, 5, 3, 4], // I-V-IV power progression
metal: [0, 1, 5, 4, 0, 1, 3, 0], // i-bII-v chromatic
chiptune: [0, 3, 2, 4, 0, 5, 3, 4], // classic game
jazz: [0, 3, 5, 1, 4, 2, 5, 4], // ii-V-I movement
electronic:[0, 4, 5, 4, 0, 3, 5, 3], // trance progression
latin: [0, 3, 4, 5, 0, 3, 4, 5], // son montuno
reggae: [0, 4, 0, 4, 0, 5, 0, 4], // one chord vibes
}
function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number, genre: Genre = 'chiptune'): MusicTrack {
let s = seed
const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff }
const allNotes: number[] = []
for (let oct = -1; oct < 6; oct++) {
for (const semi of scaleIntervals) {
allNotes.push(Math.round(root * Math.pow(2, oct + semi / 12)))
}
}
const sLen = scaleIntervals.length
const note = (degree: number) => {
const idx = Math.max(0, Math.min(allNotes.length - 1, degree + sLen))
return allNotes[idx]
}
// Pick genre-specific patterns
const bassPool = GENRE_BASS[genre]
const leadPool = GENRE_LEAD[genre]
const arpPool = GENRE_ARP[genre]
const chordDegs = GENRE_CHORDS[genre]
// Bass
const bass: number[] = []
for (let bar = 0; bar < BARS; bar++) {
const prog = bassPool[(bar + Math.floor(rng() * bassPool.length)) % bassPool.length]
const lift = genre === 'metal' ? 0 : Math.floor(bar / 3)
for (let step = 0; step < STEPS; step++) {
const d = prog[step % prog.length]
// Genre-specific spice
if (genre === 'funk' && d === 0 && rng() < 0.15) {
bass.push(note(Math.floor(rng() * 5) + lift)) // ghost notes
} else if (genre === 'metal' && d === 0) {
bass.push(note(0 + lift)) // palm mute on root
} else {
bass.push(note(d + lift))
}
}
}
// Lead
const lead: number[] = []
for (let bar = 0; bar < BARS; bar++) {
const shape = leadPool[(bar + Math.floor(rng() * leadPool.length)) % leadPool.length]
const octShift = sLen + (bar >= 4 ? sLen : 0)
for (let step = 0; step < STEPS; step++) {
const degree = shape[step % shape.length] + octShift
const vary = rng() < 0.2 ? Math.floor(rng() * 3) - 1 : 0
// Genre-specific: hiphop/reggae have silent steps
if ((genre === 'hiphop' || genre === 'reggae') && shape[step % shape.length] === 0) {
lead.push(0)
} else {
lead.push(note(Math.max(sLen, degree + vary)))
}
}
}
// Arp
const arp: number[] = []
for (let bar = 0; bar < BARS; bar++) {
const pat = arpPool[(bar + Math.floor(rng() * arpPool.length)) % arpPool.length]
const shift = Math.floor(sLen * 0.5) + Math.floor(bar / 3)
for (let step = 0; step < STEPS; step++) {
const d = pat[step % pat.length]
if (d === 0 && (genre === 'hiphop' || genre === 'reggae' || genre === 'funk')) {
arp.push(0)
} else {
arp.push(note(d + shift))
}
}
}
// Chords
const chords: number[][] = []
for (const d of chordDegs) {
if (genre === 'jazz') {
chords.push([note(d), note(d + 2), note(d + 4), note(d + 6)]) // 7th chords
} else if (genre === 'rock' || genre === 'metal') {
chords.push([note(d), note(d + 4)]) // power chords (root + 5th)
} else {
chords.push([note(d), note(d + 2), note(d + 4)])
}
}
// Drums with genre-specific fills
const drums: number[] = []
for (let bar = 0; bar < BARS; bar++) {
for (let step = 0; step < STEPS; step++) {
let d = drumStyle[step % drumStyle.length]
// Build: fills in bars 6-7
if (bar >= 6 && step >= 12 && d === 3) d = 2
if (bar >= 7 && step >= 14) d = d === 3 ? 2 : d
// Genre-specific density
if (genre === 'metal' && d === 0 && rng() < 0.4) d = 3 // double bass fills
if (genre === 'rock' && bar >= 4 && d === 0 && rng() < 0.25) d = 3
if (genre === 'funk' && d === 0 && rng() < 0.3) d = 3 // ghost note hats
if (genre === 'hiphop' && d === 0 && rng() < 0.1) d = 3 // sparse hats
if ((genre === 'chiptune' || genre === 'electronic' || genre === 'jazz' || genre === 'latin') && bar >= 4 && d === 0 && rng() < 0.2) d = 3
drums.push(d)
}
}
return { name, bpm, bass, lead, arp, chords, drums }
}
// Drum pattern presets
const DRUMS_HEAVY = [1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3]
const DRUMS_GROOVE = [1,0,3,1, 2,0,3,0, 1,0,3,1, 2,0,3,3]
const DRUMS_CHILL = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,0,0,3]
const DRUMS_FRANTIC = [1,1,1,3, 2,1,1,1, 1,1,4,1, 2,1,1,1]
const DRUMS_MARCH = [1,0,1,0, 2,0,1,0, 1,0,1,0, 2,0,4,0]
const DRUMS_SWING = [1,0,3,0, 2,3,0,3, 1,0,3,1, 2,0,3,0]
const DRUMS_HALFTIME = [1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0]
const DRUMS_DNB = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,3,1,3]
// Genre-specific drum patterns
const DRUMS_FUNK = [1,0,3,0, 2,3,0,3, 1,3,0,1, 2,0,3,0] // syncopated ghost notes
const DRUMS_HIPHOP = [1,0,0,3, 0,0,2,0, 1,0,3,0, 0,0,2,3] // boom bap
const DRUMS_ROCK = [1,3,2,3, 1,3,2,3, 1,3,2,3, 1,3,2,4] // driving backbeat
const DRUMS_REGGAE = [0,0,3,0, 2,0,3,0, 0,0,3,0, 2,0,3,0] // one drop
const DRUMS_BREAK = [1,0,1,3, 2,0,0,1, 0,3,1,0, 2,3,0,1] // chopped breakbeat
const DRUMS_LATIN = [1,0,0,1, 0,0,1,0, 1,0,0,1, 0,1,0,0] // tresillo
const DRUMS_SHUFFLE = [1,3,0,3, 2,0,3,3, 1,3,0,3, 2,3,4,3] // swung triplet feel
// Scale presets (semitone intervals)
const SCALE_MINOR = [0,2,3,5,7,8,10]
const SCALE_MAJOR = [0,2,4,5,7,9,11]
const SCALE_DORIAN = [0,2,3,5,7,9,10]
const SCALE_BLUES = [0,3,5,6,7,10]
const SCALE_PHRYGIAN = [0,1,3,5,7,8,10]
const SCALE_MIXOLYDIAN = [0,2,4,5,7,9,10]
const SCALE_HARMMINOR = [0,2,3,5,7,8,11]
const SCALE_PENTATONIC = [0,2,4,7,9]
const SCALE_JAPANESE = [0,1,5,7,8]
const SCALE_ARABIC = [0,1,4,5,7,8,11]
// === FUNK tracks — syncopated slap bass, wah stabs, ghost note drums ===
const track5 = genTrack('Funk Machine', 200, 147, SCALE_MIXOLYDIAN, DRUMS_FUNK, 420, 'funk')
const track6 = genTrack('Funk Royale', 195, 131, SCALE_MIXOLYDIAN, DRUMS_FUNK, 1971, 'funk')
const track7 = genTrack('Soul Slugger', 185, 117, SCALE_DORIAN, DRUMS_FUNK, 1968, 'funk')
const track8 = genTrack('Funky Fists', 205, 208, SCALE_MIXOLYDIAN, DRUMS_FUNK, 1975, 'funk')
const track9 = genTrack('Slap City', 210, 165, SCALE_BLUES, DRUMS_FUNK, 1979, 'funk')
// === HIP HOP tracks — deep sparse bass, looping hooks, boom bap ===
const track10 = genTrack('Boom Bap Brawl', 170, 110, SCALE_MINOR, DRUMS_HIPHOP, 1994, 'hiphop')
const track11 = genTrack('Trap Arena', 155, 185, SCALE_MINOR, DRUMS_HIPHOP, 2012, 'hiphop')
const track12 = genTrack('Hip Hop Havoc', 175, 131, SCALE_MINOR, DRUMS_HIPHOP, 1988, 'hiphop')
const track13 = genTrack('Beat Down Blvd', 165, 147, SCALE_DORIAN, DRUMS_HIPHOP, 1996, 'hiphop')
const track14 = genTrack('Street Cypher', 180, 98, SCALE_BLUES, DRUMS_HIPHOP, 2004, 'hiphop')
// === ROCK tracks — power chord pumps, pentatonic licks, driving backbeat ===
const track15 = genTrack('Stadium Rock', 210, 165, SCALE_MAJOR, DRUMS_ROCK, 1985, 'rock')
const track16 = genTrack('Garage Smasher', 220, 147, SCALE_MINOR, DRUMS_ROCK, 1969, 'rock')
const track17 = genTrack('Punk Blitz', 245, 165, SCALE_MAJOR, DRUMS_ROCK, 1977, 'rock')
const track18 = genTrack('Arena Anthem', 200, 131, SCALE_PENTATONIC, DRUMS_ROCK, 1987, 'rock')
const track19 = genTrack('Riff Rampage', 215, 110, SCALE_BLUES, DRUMS_ROCK, 1991, 'rock')
// === METAL tracks — palm mute gallops, shred bursts, double bass fills ===
const track20 = genTrack('Metal Mayhem', 260, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666, 'metal')
const track21 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256, 'metal')
const track22 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793, 'metal')
const track23 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603, 'metal')
const track24 = genTrack('Skull Crusher', 270, 98, SCALE_PHRYGIAN, DRUMS_FRANTIC, 999, 'metal')
// === CHIPTUNE tracks — classic bouncy game music ===
const track25 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137, 'chiptune')
const track26 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42, 'chiptune')
const track27 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001, 'chiptune')
const track28 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313, 'chiptune')
const track29 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99, 'chiptune')
// === JAZZ tracks — walking bass, bebop lines, 7th chords, swing drums ===
const track30 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333, 'jazz')
const track31 = genTrack('Shuffle Beatdown', 190, 196, SCALE_BLUES, DRUMS_SHUFFLE, 1955, 'jazz')
const track32 = genTrack('Smooth Operator', 165, 175, SCALE_DORIAN, DRUMS_SWING, 1961, 'jazz')
// === ELECTRONIC tracks — pulsing bass, filter sweeps, relentless arps ===
const track33 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984, 'electronic')
const track34 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174, 'electronic')
const track35 = genTrack('Neon Overload', 230, 131, SCALE_MINOR, DRUMS_FRANTIC, 2077, 'electronic')
const track36 = genTrack('Breakbeat Fury', 240, 117, SCALE_MINOR, DRUMS_BREAK, 1997, 'electronic')
// === LATIN tracks — tresillo bass, salsa piano, clave rhythms ===
const track37 = genTrack('Latin Knockout', 200, 131, SCALE_HARMMINOR, DRUMS_LATIN, 1959, 'latin')
const track38 = genTrack('Acid Meltdown', 235, 110, SCALE_ARABIC, DRUMS_LATIN, 303, 'latin')
// === REGGAE tracks — one-drop bass, skank chops ===
const track39 = genTrack('Dub Smash', 170, 196, SCALE_MINOR, DRUMS_REGGAE, 1973, 'reggae')
const track40 = genTrack('Reggae Rumble', 195, 156, SCALE_MAJOR, DRUMS_REGGAE, 1978, 'reggae')
// === GENRE BLENDS — unique crossovers ===
const track41 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SHUFFLE, 1865, 'rock')
const track42 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808, 'funk')
const track43 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977, 'funk')
const ALL_TRACKS = [
track1, track2, track3, track4, track5, track6, track7, track8, track9, track10,
track11, track12, track13, track14, track15, track16, track17, track18, track19, track20,
track21, track22, track23, track24, track25, track26, track27, track28, track29, track30,
track31, track32, track33, track34, track35, track36, track37, track38, track39, track40,
track41, track42, track43,
]
let activeTrack: MusicTrack = track1
let barIndex = 0
function playMusicBar() {
if (!musicPlaying || !musicGain) return
const c = getCtx()
getMusicDelay() // ensure delay is created
// Dynamic tempo: base BPM shifts with intensity (+30 BPM at max)
const dynamicBpm = activeTrack.bpm + currentIntensity * 30
const beat = 60 / dynamicBpm
const now = c.currentTime + 0.05
const bar = barIndex % BARS
const off = bar * STEPS
const step = beat / 2
// Switch tracks aggressively — every 2-4 bars, always to a DIFFERENT track
const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185)
const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230)
const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230)
const switchEvery = currentIntensity > 0.7 ? 2 : currentIntensity > 0.4 ? 3 : 4
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]
}
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)
}
}
// 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 percussion at high intensity
if (currentIntensity > 0.8 && i % 2 === 0 && Math.random() < 0.3) {
hihat(musicGain, t, false)
}
}
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
}
export async function ensureAudioContext() {
const c = getCtx()
if (c.state === 'suspended') {
try { await c.resume() } catch {}
}
// Prime speech synthesis on user gesture — some browsers need this
if (typeof speechSynthesis !== 'undefined' && !voicesLoaded) {
loadVoices()
}
}
// === 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)
}