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

1619 lines
62 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
function getCtx(): AudioContext {
if (!ctx) {
ctx = new AudioContext()
musicGain = ctx.createGain()
musicGain.gain.value = 0.12
musicGain.connect(ctx.destination)
sfxGain = ctx.createGain()
sfxGain.gain.value = 0.25
sfxGain.connect(ctx.destination)
}
if (ctx.state === 'suspended') ctx.resume()
return ctx
}
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
}
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)
}
if (typeof speechSynthesis !== 'undefined') {
speechSynthesis.onvoiceschanged = loadVoices
loadVoices()
}
function speak(text: string, profileName: string, cancelPrevious: boolean = true, echo: boolean = false) {
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
if (cancelPrevious) speechSynthesis.cancel()
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
speechSynthesis.speak(utter)
// Echo effect: repeat at lower volume with slight delay
if (echo) {
setTimeout(() => {
const echo1 = new SpeechSynthesisUtterance(text)
if (profile.voice) echo1.voice = profile.voice
echo1.pitch = profile.pitch * 0.9
echo1.rate = profile.rate * 0.95
echo1.volume = profile.volume * 0.4
speechSynthesis.speak(echo1)
}, 250)
setTimeout(() => {
const echo2 = new SpeechSynthesisUtterance(text)
if (profile.voice) echo2.voice = profile.voice
echo2.pitch = profile.pitch * 0.8
echo2.rate = profile.rate * 0.9
echo2.volume = profile.volume * 0.15
speechSynthesis.speak(echo2)
}, 500)
}
}
// Public voice functions
export function announce(text: string, pitch?: number, rate?: number) {
if (pitch !== undefined || rate !== undefined) {
// Custom params — use announcer voice with overrides
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
speechSynthesis.cancel()
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
speechSynthesis.speak(utter)
} else {
speak(text, 'announcer')
}
}
export function announceDeep(text: string) { speak(text, 'deep') }
export function announceFast(text: string) { speak(text, 'hype', false) }
export function announceRobot(text: string) { speak(text, 'robot') }
export function announceScream(text: string) { speak(text, 'screamer', false) }
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, echo: boolean = false) {
const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)]
speak(text, key, true, echo)
}
// Announce with a specific mood category
const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v']
const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill']
const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch']
const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper']
export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)], true, true) }
export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], false) }
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 = [
'Unbelievable!',
'What a hit!',
'Incredible!',
'Absolutely destroyed!',
'No mercy!',
'Is this even legal?',
'The crowd goes wild!',
'A display of raw power!',
'That had to hurt!',
'Total annihilation!',
'Can you believe this?',
'History in the making!',
'Oh the humanity!',
'Savage!',
'Ladies and gentlemen!',
]
const DEEP_INTROS = [
'In a world of machines...',
'Only the strongest survive.',
'This is... bot fights.',
'Two enter. One leaves.',
'No mercy. No remorse.',
'The arena awaits blood.',
'Silicon versus silicon.',
]
const ROUND_HYPE = [
'Here we go!',
'It\'s on!',
'Let\'s go!',
'Show me what you got!',
'Bring it!',
'Time to throw down!',
'Get ready to rumble!',
]
// Mortal Kombat style dramatic calls
export function announceFinishHim() {
speak('Finish him!', 'announcer', true, true)
}
export function announceFatality() {
speak('Fatality!', 'deep', true, true)
}
export function announceFlawlessVictory() {
speak('Flawless victory!', 'deep', true, 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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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', true, true), 150)
}
export function fanfareCritical() {
const d = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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', true, true), 500)
}
export function sfxPerfect() {
const d = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
sweep(200, 2000, 'sine', 0.25, d)
sweep(210, 2100, 'sine', 0.25, d) // chorus
}
export function sfxSlideDown() {
const d = sfxGain ?? getCtx().destination
sweep(2000, 100, 'sine', 0.35, d)
sweep(2020, 110, 'sine', 0.35, d)
}
export function sfxBonk() {
const d = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 = sfxGain ?? getCtx().destination
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 ===
// Procedurally generate tracks from musical parameters for variety
function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number): MusicTrack {
// Deterministic RNG
let s = seed
const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff }
// Build scale note lookup: root freq across octaves
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)) // offset by 1 octave
return allNotes[idx]
}
// Bass: degrees 0-6, mostly stepwise, bar-level progressions
const bassProgs = [
[0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0],
[0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2],
[0,0,0,2, 4,4,2,0, 3,3,3,5, 7,5,3,2],
[0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0],
]
const bass: number[] = []
for (let bar = 0; bar < BARS; bar++) {
const prog = bassProgs[bar % bassProgs.length]
const lift = Math.floor(bar / 2) // gradually ascend
for (let step = 0; step < STEPS; step++) {
bass.push(note(prog[step % prog.length] + lift))
}
}
// Lead: degrees 7-20 (octave 2-4), melodic contours
const leadShapes = [
[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],
]
const lead: number[] = []
for (let bar = 0; bar < BARS; bar++) {
const shape = leadShapes[bar % leadShapes.length]
const octShift = sLen + (bar >= 4 ? sLen : 0) // higher octave in second half
for (let step = 0; step < STEPS; step++) {
const degree = shape[step % shape.length] + octShift
// Add slight variation
const vary = rng() < 0.15 ? (rng() < 0.5 ? 1 : -1) : 0
lead.push(note(Math.max(sLen, degree + vary)))
}
}
// Arp: degrees in octave 1-3, arpeggiated patterns
const arpPatterns = [
[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],
]
const arp: number[] = []
for (let bar = 0; bar < BARS; bar++) {
const pat = arpPatterns[bar % arpPatterns.length]
const shift = Math.floor(sLen * 0.5) + Math.floor(bar / 3)
for (let step = 0; step < STEPS; step++) {
arp.push(note(pat[step % pat.length] + shift))
}
}
// Chords: triads from scale degrees
const chordDegs = [0, 3, 2, 4, 0, 5, 3, 4]
const chords: number[][] = []
for (const d of chordDegs) {
chords.push([note(d), note(d + 2), note(d + 4)])
}
// Drums: base pattern repeated, with builds in later bars
const drums: number[] = []
for (let bar = 0; bar < BARS; bar++) {
for (let step = 0; step < STEPS; step++) {
let d = drumStyle[step % drumStyle.length]
// Build: add fills in bars 6-7
if (bar >= 6 && step >= 12 && d === 3) d = 2
if (bar >= 7 && step >= 14) d = d === 3 ? 2 : d
// Extra hats in later bars
if (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]
// 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]
// Track 5: "Chill Lounge" — C major, relaxed, 155bpm
const track5 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42)
// Track 6: "Cyber Punk" — Bb minor, aggressive, 250bpm
const track6 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99)
// Track 7: "Retro Arcade" — G major, bouncy, 195bpm
const track7 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137)
// Track 8: "Boss Battle" — D minor, epic, 240bpm
const track8 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256)
// Track 9: "Jazz Fusion" — Eb dorian, smooth, 175bpm
const track9 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333)
// Track 10: "Metal Mayhem" — E phrygian, thrash, 270bpm
const track10 = genTrack('Metal Mayhem', 270, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666)
// Track 11: "Tropical Storm" — C mixolydian, upbeat, 185bpm
const track11 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808)
// Track 12: "Haunted Circus" — Bb harmonic minor, creepy, 200bpm
const track12 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313)
// Track 13: "Space Opera" — Ab major, majestic, 165bpm
const track13 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001)
// Track 14: "Funk Machine" — D mixolydian, groovy, 210bpm
const track14 = genTrack('Funk Machine', 210, 147, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 420)
// Track 15: "Viking Raid" — A minor, epic march, 225bpm
const track15 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793)
// Track 16: "Synthwave Dream" — F minor, dreamy, 170bpm
const track16 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984)
// Track 17: "Drum & Bass" — G minor, frantic, 255bpm
const track17 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174)
// Track 18: "Western Duel" — E blues, twangy, 190bpm
const track18 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SWING, 1865)
// Track 19: "Samurai Storm" — B japanese, intense, 245bpm
const track19 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603)
// Track 20: "Disco Inferno" — Bb major, groovy, 215bpm
const track20 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977)
const ALL_TRACKS = [track1, track2, track3, track4, track5, track6, track7, track8, track9, track10, track11, track12, track13, track14, track15, track16, track17, track18, track19, track20]
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 at bar boundaries based on intensity
// Categorize tracks by energy level
const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185) // Chill Lounge, Space Opera, Synthwave Dream, Jazz Fusion, Tropical Storm
const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230) // Retro Arcade, Western Duel, Haunted Circus, Funk Machine, Disco Inferno, Neon Fury, Viking Raid
const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230) // Dark Circuit, Pixel Blitz, Skull Crusher, Cyber Punk, Boss Battle, Metal Mayhem, Samurai Storm, Drum & Bass
if (barIndex > 0 && bar === 0) {
if (currentIntensity > 0.7 && activeTrack.bpm < 230) {
activeTrack = intenseTracks[Math.floor(Math.random() * intenseTracks.length)]
} else if (currentIntensity < 0.3 && activeTrack.bpm >= 200) {
activeTrack = chillTracks[Math.floor(Math.random() * chillTracks.length)]
} else if (currentIntensity >= 0.3 && currentIntensity <= 0.7 && Math.random() < 0.3) {
activeTrack = midTracks[Math.floor(Math.random() * midTracks.length)]
} else if (Math.random() < 0.2) {
// Random switch for variety
const others = ALL_TRACKS.filter(t => t !== activeTrack)
activeTrack = others[Math.floor(Math.random() * others.length)]
}
}
// 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))
}
// === CROWD SOUNDS ===
// Procedural crowd reactions using layered noise + filtered tones
export function sfxCrowdOoh() {
const c = getCtx()
const d = sfxGain ?? c.destination
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 = sfxGain ?? c.destination
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 = sfxGain ?? c.destination
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 = sfxGain ?? c.destination
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 = sfxGain ?? c.destination
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)
}