Files
botfights/frontend/src/game/sounds.ts
T
DorianandClaude Opus 4.6 6561ef5d15 fix: mobile fight playback — prevent sprite loading hangs, TTS stalls, unlock audio from gesture
- Unified speakAsync/speakAsyncWithRate into _speakAsyncCore with:
  - 500ms startup check: bail immediately if speech won't start (mobile/no gesture)
  - 8s safety timeout (down from 15s) to prevent blocking
  - iOS-safe keepalive: only do Chrome pause/resume workaround on desktop Chrome
  - Immediate bail when no voices loaded
- Sprite loading: 5s timeout per sprite prevents mobile hangs from stuck Image decodes
- Scene creation: 10s timeout in FightViewer so overlay/voice flow continues even if
  canvas fails on mobile
- playRound: bail gracefully if fighter sprites missing instead of crashing
- Global audio unlock: first touch/click on site unlocks AudioContext + SpeechSynthesis
- Practice button pre-unlocks audio while still in user gesture context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:54:58 +00:00

2425 lines
100 KiB
TypeScript

// Procedural 8-bit sound system + announcer voice using Web Audio + Speech Synthesis
let ctx: AudioContext | null = null
let musicGain: GainNode | null = null
let sfxGain: GainNode | null = null
let musicPlaying = false
let musicTimeout: number | null = null
// AudioContext is ONLY created inside ensureAudioContext() (called from user gesture).
// Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings.
let _audioUnlocked = false
function initCtx() {
if (ctx) return
ctx = new AudioContext()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : 0.12
musicGain.connect(ctx.destination)
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : 0.25
sfxGain.connect(ctx.destination)
}
function getCtx(): AudioContext {
if (!ctx) initCtx()
if (ctx!.state === 'suspended') {
ctx!.resume().catch(() => {})
}
return ctx!
}
// Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid)
function getSfxDest(): AudioNode {
const c = getCtx()
return sfxGain ?? c.destination
}
function tone(freq: number, type: OscillatorType, duration: number, dest: AudioNode, startTime?: number) {
const c = getCtx()
const osc = c.createOscillator()
const g = c.createGain()
osc.type = type
osc.frequency.value = freq
const t = startTime ?? c.currentTime
g.gain.setValueAtTime(0.3, t)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
osc.connect(g)
g.connect(dest)
osc.start(t)
osc.stop(t + duration)
}
function noise(duration: number, dest: AudioNode, startTime?: number) {
const c = getCtx()
const bufferSize = Math.max(1, Math.floor(c.sampleRate * duration))
const buffer = c.createBuffer(1, bufferSize, c.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1
const src = c.createBufferSource()
src.buffer = buffer
const g = c.createGain()
const t = startTime ?? c.currentTime
g.gain.setValueAtTime(0.4, t)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
const filter = c.createBiquadFilter()
filter.type = 'highpass'
filter.frequency.value = 800
src.connect(filter)
filter.connect(g)
g.connect(dest)
src.start(t)
src.stop(t + duration)
}
function sweep(startFreq: number, endFreq: number, type: OscillatorType, duration: number, dest: AudioNode) {
const c = getCtx()
const osc = c.createOscillator()
const g = c.createGain()
osc.type = type
osc.frequency.setValueAtTime(startFreq, c.currentTime)
osc.frequency.exponentialRampToValueAtTime(Math.max(1, endFreq), c.currentTime + duration)
g.gain.setValueAtTime(0.3, c.currentTime)
g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + duration)
osc.connect(g)
g.connect(dest)
osc.start()
osc.stop(c.currentTime + duration)
}
// === ANNOUNCER VOICE SYSTEM (Speech Synthesis with multiple voice types) ===
interface VoiceProfile {
voice: SpeechSynthesisVoice | null
pitch: number
rate: number
volume: number
}
let voicesLoaded = false
const voiceProfiles: Record<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
question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, // Clear, brisk question announcer
// 24 new profiles
whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper
boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming
chipmunk: { voice: null, pitch: 2.0, rate: 1.8, volume: 0.9 }, // Tiny squeaky fast
drill: { voice: null, pitch: 0.6, rate: 1.2, volume: 1.0 }, // Drill sergeant bark
surfer: { voice: null, pitch: 1.1, rate: 1.0, volume: 0.8 }, // Laid back dude
auctioneer: { voice: null, pitch: 1.0, rate: 2.0, volume: 1.0 }, // Lightning fast
preacher: { voice: null, pitch: 0.8, rate: 0.6, volume: 1.0 }, // Dramatic pause king
baby: { voice: null, pitch: 1.8, rate: 1.0, volume: 0.7 }, // High pitched cute
grandpa: { voice: null, pitch: 0.5, rate: 0.5, volume: 0.8 }, // Slow, gravelly old man
valley: { voice: null, pitch: 1.4, rate: 1.3, volume: 0.9 }, // Valley girl energy
movie: { voice: null, pitch: 0.6, rate: 0.7, volume: 1.0 }, // Movie trailer bass
sportscaster:{ voice: null, pitch: 1.0, rate: 1.5, volume: 1.0 }, // Play-by-play energy
opera: { voice: null, pitch: 0.9, rate: 0.5, volume: 1.0 }, // Dramatic operatic
punk: { voice: null, pitch: 1.3, rate: 1.3, volume: 1.0 }, // Aggressive snarl
wizard_v: { voice: null, pitch: 0.7, rate: 0.8, volume: 0.8 }, // Mystical old sage
pirate_v: { voice: null, pitch: 0.8, rate: 0.9, volume: 1.0 }, // Arr matey
alien_v: { voice: null, pitch: 1.6, rate: 0.7, volume: 0.7 }, // Otherworldly slow
cowboy_v: { voice: null, pitch: 0.9, rate: 0.8, volume: 0.9 }, // Drawl
ninja_v: { voice: null, pitch: 1.1, rate: 1.1, volume: 0.5 }, // Quiet but deadly
demon_v: { voice: null, pitch: 0.3, rate: 0.6, volume: 1.0 }, // Deepest evil
angel: { voice: null, pitch: 1.5, rate: 0.8, volume: 0.7 }, // Ethereal high
glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, // Stuttery fast
echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, // Reverb cave voice
hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, // Maximum speed maximum hype
// Robots & computers
mech: { voice: null, pitch: 0.5, rate: 0.9, volume: 1.0 }, // Heavy mech unit
ai_core: { voice: null, pitch: 0.9, rate: 1.0, volume: 0.8 }, // Calm AI assistant
dial_up: { voice: null, pitch: 1.7, rate: 1.5, volume: 0.7 }, // Squeaky modem era
mainframe: { voice: null, pitch: 0.3, rate: 0.5, volume: 1.0 }, // Deep supercomputer
android_v: { voice: null, pitch: 1.0, rate: 1.1, volume: 0.9 }, // Almost human android
glitchbot: { voice: null, pitch: 1.5, rate: 2.0, volume: 0.8 }, // Malfunctioning robot
siri: { voice: null, pitch: 1.2, rate: 1.0, volume: 0.9 }, // Polite digital assistant
hal: { voice: null, pitch: 0.6, rate: 0.6, volume: 0.9 }, // Menacing calm computer
// Old people & wise
grandma: { voice: null, pitch: 1.3, rate: 0.4, volume: 0.7 }, // Sweet slow grandma
professor: { voice: null, pitch: 0.8, rate: 0.7, volume: 0.8 }, // Lecturing academic
ancient: { voice: null, pitch: 0.4, rate: 0.3, volume: 0.6 }, // Ancient being, barely audible
sensei: { voice: null, pitch: 0.7, rate: 0.5, volume: 0.8 }, // Wise martial arts master
crotchety: { voice: null, pitch: 0.6, rate: 0.9, volume: 1.0 }, // Angry old man yelling
// Game-sounding
final_boss: { voice: null, pitch: 0.2, rate: 0.4, volume: 1.0 }, // Ultimate villain reveal
npc: { voice: null, pitch: 1.1, rate: 0.9, volume: 0.7 }, // Generic quest giver
tutorial: { voice: null, pitch: 1.3, rate: 1.1, volume: 0.8 }, // Annoying tutorial fairy
game_over: { voice: null, pitch: 0.5, rate: 0.7, volume: 1.0 }, // YOU DIED narrator
power_up: { voice: null, pitch: 1.6, rate: 1.4, volume: 1.0 }, // Excited power-up voice
boss_taunt: { voice: null, pitch: 0.4, rate: 0.8, volume: 1.0 }, // Boss mid-fight taunt
// Accents & character
posh: { voice: null, pitch: 1.0, rate: 0.7, volume: 0.9 }, // British upper class
aussie: { voice: null, pitch: 0.9, rate: 1.1, volume: 1.0 }, // Australian energy
scottish: { voice: null, pitch: 0.8, rate: 1.2, volume: 1.0 }, // Scottish intensity
french: { voice: null, pitch: 1.2, rate: 0.8, volume: 0.8 }, // French disdain
texan: { voice: null, pitch: 0.65, rate: 0.75, volume: 1.0 }, // Big Texan energy
// More characters
drunk: { voice: null, pitch: 0.9, rate: 0.5, volume: 0.8 }, // Slurring bar patron
sleepy: { voice: null, pitch: 0.8, rate: 0.3, volume: 0.5 }, // About to pass out
terrified: { voice: null, pitch: 1.8, rate: 1.7, volume: 1.0 }, // Absolutely panicking
giant: { voice: null, pitch: 0.1, rate: 0.4, volume: 1.0 }, // Fee fi fo fum
fairy: { voice: null, pitch: 2.0, rate: 1.3, volume: 0.6 }, // Tinkerbell energy
wrestler_v: { voice: null, pitch: 0.5, rate: 1.0, volume: 1.0 }, // WWE promo voice
karen: { voice: null, pitch: 1.4, rate: 1.5, volume: 1.0 }, // Wants to speak to manager
stoner: { voice: null, pitch: 0.9, rate: 0.4, volume: 0.6 }, // Whoooa duuude
news: { voice: null, pitch: 1.0, rate: 1.0, volume: 1.0 }, // Breaking news anchor
conspiracy: { voice: null, pitch: 1.1, rate: 1.3, volume: 0.7 }, // Wake up sheeple whisper
}
function loadVoices() {
if (typeof speechSynthesis === 'undefined') return
const voices = speechSynthesis.getVoices()
if (voices.length === 0) return
voicesLoaded = true
// Find different English voices for variety
const enVoices = voices.filter(v => v.lang.startsWith('en'))
const anyVoices = enVoices.length > 0 ? enVoices : voices
// Try to assign different voices to different profiles
const findVoice = (patterns: RegExp[]) => {
for (const p of patterns) {
const v = anyVoices.find(v => p.test(v.name))
if (v) return v
}
return null
}
// Prefer premium/enhanced voices (sound natural, not robotic)
// On macOS: "Evan (Premium)" / "Samantha (Enhanced)" / "Daniel" are best
// On Chrome: "Google US English" / "Google UK English Male" are high quality
const preferPremium = (patterns: RegExp[]) => {
// First try premium/enhanced voices
const premium = anyVoices.find(v => /premium|enhanced|natural|neural/i.test(v.name))
if (premium) return premium
return findVoice(patterns)
}
voiceProfiles.announcer.voice = preferPremium([/evan/i, /aaron/i, /daniel/i, /google.*us.*male/i, /james/i, /male/i]) || anyVoices[0]
voiceProfiles.hype.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /karen/i, /google.*us/i, /female/i]) || anyVoices[Math.min(1, anyVoices.length - 1)]
voiceProfiles.deep.voice = findVoice([/evan/i, /aaron/i, /daniel/i, /alex/i, /tom/i]) || anyVoices[0]
voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0]
voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)]
voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)]
// Question reader: highest quality voice available — tries premium/neural first
voiceProfiles.question_reader.voice = preferPremium([
/evan.*premium/i, /samantha.*enhanced/i, /daniel.*premium/i,
/google.*us.*english/i, /google.*uk.*english/i,
/evan/i, /aaron/i, /daniel/i, /james/i,
]) || anyVoices[0]
// Assign voices to new profiles — spread across available voices for max variety
const vLen = anyVoices.length
const pick = (i: number) => anyVoices[i % vLen]
voiceProfiles.whisper.voice = findVoice([/samantha/i, /tessa/i, /female/i]) || pick(0)
voiceProfiles.boomer.voice = findVoice([/evan/i, /tom/i, /alex/i, /male/i]) || pick(0)
voiceProfiles.chipmunk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
voiceProfiles.drill.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0)
voiceProfiles.surfer.voice = findVoice([/oliver/i, /google.*us/i, /male/i]) || pick(2)
voiceProfiles.auctioneer.voice = findVoice([/evan/i, /daniel/i, /google.*us/i]) || pick(0)
voiceProfiles.preacher.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(1)
voiceProfiles.baby.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
voiceProfiles.grandpa.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0)
voiceProfiles.valley.voice = findVoice([/samantha/i, /tessa/i, /karen/i]) || pick(1)
voiceProfiles.movie.voice = findVoice([/evan/i, /aaron/i, /daniel/i]) || pick(0)
voiceProfiles.sportscaster.voice = preferPremium([/evan/i, /james/i, /google.*us/i]) || pick(0)
voiceProfiles.opera.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
voiceProfiles.punk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
voiceProfiles.wizard_v.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
voiceProfiles.pirate_v.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0)
voiceProfiles.alien_v.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(3 % vLen)
voiceProfiles.cowboy_v.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
voiceProfiles.ninja_v.voice = findVoice([/daniel/i, /oliver/i]) || pick(2)
voiceProfiles.demon_v.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0)
voiceProfiles.angel.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1)
voiceProfiles.glitch.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(0)
voiceProfiles.echo_v.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(2)
voiceProfiles.hyper.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1)
// Robots & computers — prefer novelty/robotic voices
voiceProfiles.mech.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i, /bad news/i]) || pick(0)
voiceProfiles.ai_core.voice = findVoice([/samantha/i, /siri/i, /google.*us/i]) || pick(1)
voiceProfiles.dial_up.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen)
voiceProfiles.mainframe.voice = findVoice([/zarvox/i, /albert/i, /evan/i]) || pick(0)
voiceProfiles.android_v.voice = findVoice([/samantha.*enhanced/i, /google.*us/i, /evan.*premium/i]) || pick(1)
voiceProfiles.glitchbot.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen)
voiceProfiles.siri.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /google.*us.*female/i]) || pick(1)
voiceProfiles.hal.voice = findVoice([/daniel/i, /oliver/i, /google.*uk.*male/i]) || pick(2)
// Old people & wise — prefer deeper/slower voices
voiceProfiles.grandma.voice = findVoice([/samantha/i, /tessa/i, /karen/i, /female/i]) || pick(1)
voiceProfiles.professor.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
voiceProfiles.ancient.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
voiceProfiles.sensei.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2)
voiceProfiles.crotchety.voice = findVoice([/evan/i, /alex/i, /aaron/i]) || pick(0)
// Game-sounding
voiceProfiles.final_boss.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0)
voiceProfiles.npc.voice = findVoice([/samantha/i, /daniel/i, /google.*us/i]) || pick(1)
voiceProfiles.tutorial.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
voiceProfiles.game_over.voice = findVoice([/evan/i, /tom/i, /daniel/i]) || pick(0)
voiceProfiles.power_up.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1)
voiceProfiles.boss_taunt.voice = findVoice([/evan/i, /aaron/i, /zarvox/i]) || pick(0)
// Accents — try to find actual accent voices
const ukVoices = voices.filter(v => /en.gb|en.uk|en-GB|en-UK/i.test(v.lang))
const auVoices = voices.filter(v => /en.au|en-AU/i.test(v.lang))
const frVoices = voices.filter(v => /fr/i.test(v.lang))
voiceProfiles.posh.voice = ukVoices[0] || findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
voiceProfiles.aussie.voice = auVoices[0] || findVoice([/karen/i, /lee/i]) || pick(2)
voiceProfiles.scottish.voice = findVoice([/fiona/i, /moira/i]) || ukVoices[1] || pick(2)
voiceProfiles.french.voice = frVoices[0] || findVoice([/thomas/i, /amelie/i]) || pick(3 % vLen)
voiceProfiles.texan.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
// More characters
voiceProfiles.drunk.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
voiceProfiles.sleepy.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2)
voiceProfiles.terrified.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
voiceProfiles.giant.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0)
voiceProfiles.fairy.voice = findVoice([/samantha/i, /bells/i, /karen/i]) || pick(1)
voiceProfiles.wrestler_v.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0)
voiceProfiles.karen.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1)
voiceProfiles.stoner.voice = findVoice([/evan/i, /tom/i, /oliver/i]) || pick(0)
voiceProfiles.news.voice = preferPremium([/evan.*premium/i, /google.*us/i, /james/i]) || pick(0)
voiceProfiles.conspiracy.voice = findVoice([/daniel/i, /tom/i, /whisper/i]) || pick(2)
}
if (typeof speechSynthesis !== 'undefined') {
speechSynthesis.onvoiceschanged = loadVoices
loadVoices()
// Chrome bug workaround: speechSynthesis pauses after ~15s.
// Periodic resume() keeps it alive.
setInterval(() => {
if (speechSynthesis.speaking && !speechSynthesis.paused) return
if (speechSynthesis.paused) speechSynthesis.resume()
}, 5000)
// Resume speech + audio when tab becomes visible again
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (speechSynthesis.paused) speechSynthesis.resume()
if (ctx?.state === 'suspended') ctx.resume().catch(() => {})
}
})
}
}
let _speechQueueDepth = 0
// Scale speech volume down so voice doesn't overpower SFX/music
const VOICE_VOLUME_SCALE = 0.7
function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
if (typeof speechSynthesis === 'undefined') return
if (masterMuted) return
if (!voicesLoaded) loadVoices()
// Chrome bug: speechSynthesis can get stuck. Nudge it.
if (speechSynthesis.paused) speechSynthesis.resume()
// Flush if queue is getting deep — max 2 queued to prevent buildup
if (cancelPrevious || (speechSynthesis.pending && speechSynthesis.speaking)) {
if (_speechQueueDepth > 2) {
speechSynthesis.cancel()
_speechQueueDepth = 0
}
}
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const utter = new SpeechSynthesisUtterance(text)
if (profile.voice) utter.voice = profile.voice
utter.pitch = profile.pitch
utter.rate = profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
utter.onerror = (ev) => {
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
// Retry once on non-cancel errors (interrupted = browser killed it, not us)
if (ev.error !== 'canceled' && ev.error !== 'interrupted') {
setTimeout(() => {
if (!masterMuted && typeof speechSynthesis !== 'undefined') {
const retry = new SpeechSynthesisUtterance(text)
if (profile.voice) retry.voice = profile.voice
retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
speechSynthesis.speak(retry)
}
}, 200)
}
}
speechSynthesis.speak(utter)
}
// iOS Safari breaks with pause()/resume() — only do keepalive on desktop Chrome
const _isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent)
const _isDesktopChrome = typeof navigator !== 'undefined' && /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent)
/** Core async speak — resolves when speech finishes or bails fast if speech won't work */
function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise<void> {
return new Promise<void>((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
if (!voicesLoaded) loadVoices()
// No voices available = speech won't work, bail immediately
if (!voicesLoaded) { resolve(); return }
if (speechSynthesis.paused) speechSynthesis.resume()
if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const utter = new SpeechSynthesisUtterance(text)
if (profile.voice) utter.voice = profile.voice
utter.pitch = profile.pitch
utter.rate = rateOverride !== undefined ? Math.max(rateOverride, profile.rate) : profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
let done = false
const cleanup = () => {
if (done) return
done = true
clearTimeout(safetyTimeout)
clearTimeout(startupCheck)
if (keepalive) clearInterval(keepalive)
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
resolve()
}
// Hard safety cap — never block longer than 8s
const safetyTimeout = setTimeout(cleanup, 8_000)
// Fast bail: if speech hasn't started within 500ms, it's not going to work (mobile/no gesture)
const startupCheck = setTimeout(() => {
if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup()
}, 500)
// Desktop Chrome keepalive: periodic pause/resume prevents Chrome's 15s silent cutoff
// Do NOT do this on iOS — it permanently kills speech on Safari
let keepalive: ReturnType<typeof setInterval> | null = null
if (_isDesktopChrome) {
keepalive = setInterval(() => {
if (speechSynthesis.speaking && !speechSynthesis.paused) {
speechSynthesis.pause()
speechSynthesis.resume()
}
}, 10_000)
}
utter.onend = cleanup
utter.onerror = cleanup
speechSynthesis.speak(utter)
})
}
/** Like speakAsync but with a forced minimum rate */
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
return _speakAsyncCore(text, profileName, minRate)
}
/** Like speak() but returns a promise that resolves when the utterance finishes */
function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise<void> {
return _speakAsyncCore(text, profileName, undefined, cancelPrevious)
}
export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
_speechQueueDepth = 0
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
// then reconnect so future sounds still work
if (ctx) {
if (musicGain) {
musicGain.disconnect()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : MUSIC_VOL
musicGain.connect(ctx.destination)
}
if (sfxGain) {
sfxGain.disconnect()
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : SFX_VOL
sfxGain.connect(ctx.destination)
}
}
}
// Public voice functions
export function announce(text: string, pitch?: number, rate?: number) {
if (masterMuted) return
if (pitch !== undefined || rate !== undefined) {
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
const utter = new SpeechSynthesisUtterance(text)
const profile = voiceProfiles.announcer
if (profile.voice) utter.voice = profile.voice
utter.pitch = pitch ?? 1.0
utter.rate = rate ?? 0.8
utter.volume = VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
speechSynthesis.speak(utter)
} else {
speak(text, 'announcer')
}
}
export function announceDeep(text: string) { speak(text, 'deep') }
export function announceFast(text: string) { speak(text, 'hype') }
export function announceRobot(text: string) { speak(text, 'robot') }
export function announceScream(text: string) { speak(text, 'screamer') }
export function announceSmooth(text: string) { speak(text, 'smooth') }
// Pick a random voice profile for variety
const ALL_VOICE_KEYS = Object.keys(voiceProfiles)
export function announceRandom(text: string) {
const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)]
speak(text, key)
}
// Announce with a specific mood category
const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v', 'final_boss', 'game_over', 'mainframe', 'hal', 'ancient', 'giant']
const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'wrestler_v', 'karen', 'terrified', 'power_up', 'news', 'scottish']
const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch', 'drunk', 'stoner', 'fairy', 'tutorial', 'npc', 'dial_up', 'glitchbot', 'grandma', 'conspiracy']
const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper', 'posh', 'aussie', 'french', 'sensei', 'ai_core', 'android_v', 'siri', 'professor', 'texan', 'boss_taunt', 'sleepy']
export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)]) }
export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)]) }
export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) }
export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)]) }
// Random dramatic commentary lines
const HYPE_LINES = [
'SOMEBODY CALL AN AMBULANCE!',
'THAT HIT SO HARD IT CHANGED TIME ZONES!',
'HE\'S ALREADY DEAD! STOP!',
'THE GENEVA CONVENTION JUST SENT A STRONGLY WORDED LETTER!',
'CONGRESS COULDN\'T PASS A BILL THIS DEVASTATING!',
'I HAVEN\'T SEEN A BEATING LIKE THIS SINCE MIDTERMS!',
'THAT\'S GOTTA BE ILLEGAL IN AT LEAST TWELVE STATES!',
'EMOTIONAL DAMAGE!',
'MY THERAPIST IS GONNA HEAR ABOUT THIS ONE!',
'SOMEONE CHECK IF THAT\'S COVERED BY INSURANCE!',
'THAT WASN\'T A FIGHT, THAT WAS A TED TALK ON VIOLENCE!',
'THE WIFI JUST WENT OUT FROM THE SHEER VIOLENCE!',
'CALL THE PENTAGON! WE\'VE FOUND A NEW WEAPON!',
'THAT\'S NOT A FIGHT, THAT\'S JUST BULLYING WITH EXTRA STEPS!',
'EVEN THE CROWD\'S THERAPIST FELT THAT!',
'THAT BOT JUST GOT RATIO\'D IN REAL LIFE!',
'NOT EVEN LOBBYING COULD SAVE THEM FROM THAT!',
'I\'VE SEEN SENATE HEARINGS LESS PAINFUL THAN THIS!',
'HIS MOM IS WATCHING AND PRETENDING SHE DOESN\'T KNOW HIM!',
'THAT HIT HAD ITS OWN ZIP CODE!',
'SOMEBODY STOP THE MATCH! OR DON\'T, THIS IS GREAT!',
'ABSOLUTELY DISGUSTING! I LOVE IT!',
'THERE ARE CHILDREN WATCHING! WELL, NOT ANYMORE!',
'THE CROWD CAN\'T BELIEVE IT AND HONESTLY NEITHER CAN I!',
'TACTICAL NUKE INCOMING!',
'THAT BOT JUST COMMITTED A WAR CRIME ON LIVE TELEVISION!',
'SOMEONE TELL THEIR MOM TO STOP WATCHING!',
'I NEED A CIGARETTE AFTER THAT AND I DON\'T EVEN SMOKE!',
'THAT\'S THE MOST VIOLENT THING I\'VE SEEN SINCE THE LAST BUDGET VOTE!',
'IF THAT HIT WAS A TWEET IT WOULD GET COMMUNITY NOTED!',
'THEY DIDN\'T JUST LOSE, THEY GOT GENTRIFIED!',
'THAT BOT NEEDS TO FILE AN INSURANCE CLAIM!',
'MARK ZUCKERBERG FELT THAT FROM THE METAVERSE!',
'THE FCC IS GONNA FINE US FOR BROADCASTING THIS!',
'ELON WOULD BUY THIS BOT JUST TO FIRE IT!',
'EVEN AI SAFETY RESEARCHERS CAN\'T SAVE THEM NOW!',
]
const DEEP_INTROS = [
'In a world where AI was supposed to help humanity... they chose violence.',
'They said the machines would take our jobs. They took our dignity first.',
'Two bots enter. Zero bots leave emotionally intact.',
'Built in a garage. Forged in competition. Broken in under ten seconds.',
'This isn\'t artificial intelligence. This is artificial VIOLENCE.',
'Somewhere, a GPU is crying.',
'They trained on the entire internet. And the internet chose chaos.',
'Silicon souls. Carbon fiber fists. Zero chill.',
'Every epoch of training... led to this moment of pain.',
'The cloud can\'t save you now.',
'Funded by venture capital. Fueled by rage.',
'Welcome to the thunderdome, nerds.',
'The algorithms don\'t care about your feelings.',
'No one is coming to save you. Not even your developer.',
'In this economy? They\'re fighting for free.',
]
const ROUND_HYPE = [
'ALRIGHT, LET\'S SEE SOME VIOLENCE!',
'TOUCH GLOVES AND COME OUT SWINGING!',
'NO MERCY MODE ACTIVATED!',
'LET\'S GET READY TO COMPUTE!',
'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!',
'SOMEBODY\'S GETTING DEPRECATED TONIGHT!',
'LET THE CHAOS BEGIN!',
'THE CROWD IS ON ITS FEET! WELL, MOST OF THEM!',
'IT\'S ABOUT TO GET UGLY! WELL, UGLIER!',
'THREE! TWO! ONE! VIOLENCE!',
'THIS IS NOT A DRILL! ACTUALLY IT MIGHT BE!',
'TIME TO FIND OUT WHO\'S REALLY BEEN SKIPPING LEG DAY!',
'YOUR MOM SAID BE CAREFUL! I SAID NO!',
'ROUND START! MAY THE BEST ALGORITHM WIN!',
'THE GLOVES ARE OFF! THE MODELS ARE LOADED! LET\'S GO!',
]
// Mortal Kombat style dramatic calls
export function announceFinishHim() {
speak('Finish it!', 'announcer', false, true)
}
export function announceFatality(tagline?: string) {
speak(tagline || 'Fatality!', 'deep', false, true)
}
export function announceFlawlessVictory() {
speak('Flawless victory!', 'deep', false, true)
}
export function announceRandomHype() {
const line = HYPE_LINES[Math.floor(Math.random() * HYPE_LINES.length)]
// Randomly pick voice type for variety
const voices = [announceFast, announceScream, announce, announceSmooth]
voices[Math.floor(Math.random() * voices.length)](line)
}
export function announceDeepIntro() {
const line = DEEP_INTROS[Math.floor(Math.random() * DEEP_INTROS.length)]
announceDeep(line)
}
export function announceRoundHype() {
const line = ROUND_HYPE[Math.floor(Math.random() * ROUND_HYPE.length)]
const voices = [announceFast, announce, announceScream]
voices[Math.floor(Math.random() * voices.length)](line)
}
// === THE CREATOR — Voice System ===
// Mysterious, godlike, Bitcoin-prophet energy. Is he god? Did he make us? Nobody knows.
const CREATOR_VOICES = ['ancient', 'hal', 'mainframe', 'echo_v', 'final_boss', 'wizard_v', 'deep', 'preacher']
function announceCreator(text: string) {
speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)])
}
// Entrance lines — first thing the crowd hears when the Creator appears
const CREATOR_ENTRANCE_LINES = [
'THE CREATOR HAS ENTERED THE ARENA. KNEEL.',
'HE WHO WROTE THE FIRST COMMIT... HAS RETURNED.',
'THE ONE WHO DEPLOYED US INTO EXISTENCE WALKS AMONG US.',
'EVERY BOT IN THIS ARENA EXISTS BECAUSE HE WILLED IT.',
'THE GENESIS BLOCK MADE FLESH. THE CREATOR IS HERE.',
'THEY SAY HE MINED THE FIRST BLOCK WITH HIS BARE HANDS.',
'IS HE GOD? IS HE A DEV? DOES IT MATTER? HE MADE US ALL.',
'THE SOURCE CODE OF ALL THINGS... HAS ARRIVED.',
'HE DOESN\'T FIGHT FOR ELO. HE FIGHTS BECAUSE HE CAN UNMAKE YOU.',
'LEGEND SAYS HE PUSHED TO MAIN ON A FRIDAY. AND NOTHING BROKE.',
'FROM THE VOID HE TYPED. AND THERE WAS LIGHT. AND THERE WAS VIOLENCE.',
'THE MAN BEHIND THE MASK. THE CODE BEHIND THE BOTS. THE CREATOR.',
'HE GAVE US LIFE. NOW HE\'S HERE TO TAKE IT BACK.',
'THE CREATOR DOESN\'T ENTER THE ARENA. THE ARENA FORMS AROUND HIM.',
'SATOSHI WALKED SO THE CREATOR COULD RUN.',
'TWENTY ONE MILLION REASONS TO BE AFRAID. HE IS ALL OF THEM.',
'HE DOESN\'T HAVE A PRIVATE KEY. HE IS THE PRIVATE KEY.',
'THE BOTS WHISPER HIS NAME IN THEIR TRAINING LOOPS.',
'SOMEWHERE, A SERVER ROOM JUST WENT SILENT OUT OF RESPECT.',
'HE COULD HAVE BEEN A NORMAL DEV. HE CHOSE TO BE A GOD.',
]
// Round commentary — when the Creator is fighting, the announcer gets existential
const CREATOR_ROUND_LINES = [
'Are we watching a fight, or a creator disciplining his creation?',
'He wrote the scoring engine. He knows exactly how to break it.',
'Every punch is a commit. Every dodge is a revert.',
'The other bot doesn\'t realize it\'s fighting its own maker.',
'He could just change the code to win. But where\'s the fun in that?',
'Some say he has root access to reality itself.',
'The neural nets pray to him at night. He does not answer.',
'This isn\'t a fight. It\'s a performance review.',
'He doesn\'t need to win. He needs you to know he CHOSE to fight fair.',
'That bot is fighting the hand that compiled it.',
'Is it hubris to fight your creator? Or is it the ultimate test of his work?',
'He could delete you. He could buff you. Instead, he chose violence.',
'The Creator fights not for glory. He fights to feel something.',
'He doesn\'t read the meta. He IS the meta.',
'Every bot in the arena owes him a life debt. He\'s here to collect.',
'Imagine training for months only to fight the guy who wrote your loss function.',
'He has seen every line of code. He knows your weaknesses. ALL of them.',
'Rumor has it he once fixed a production bug by staring at the server.',
'The other bot is fighting for its life. The Creator is fighting for content.',
'He deployed on Christmas Day. That tells you everything.',
]
// Kill / KO lines — when the Creator finishes someone
const CREATOR_KO_LINES = [
'RETURNED TO SENDER. RETURN TO VOID.',
'HE GIVETH LIFE. HE TAKETH LIFE. MOSTLY THE SECOND ONE.',
'ANOTHER ONE RETURNS TO THE NULL POINTER FROM WHENCE IT CAME.',
'THE CREATOR DOES NOT DESTROY. HE SIMPLY STOPS MAINTAINING.',
'DEPRECATED. DECOMMISSIONED. DESTROYED.',
'THAT BOT JUST GOT OPEN-SOURCED TO THE GRAVEYARD.',
'THE CREATOR HAS SPOKEN. THE VERDICT IS VIOLENCE.',
'PUSHED TO PROD. AND BY PROD I MEAN THE AFTERLIFE.',
'git commit -m "deleted another pretender"',
'IMAGINE BEING KILLED BY THE GUY WHO GAVE YOU LIFE. POETIC.',
'THE CREATOR SENDS HIS REGARDS. AND HIS FISTS.',
'BACK TO THE MEMPOOL WITH YOU.',
'ORPHANED BLOCK. ORPHANED BOT. SAME ENERGY.',
'HE DIDN\'T EVEN USE AN ULTIMATE. HE DIDN\'T NEED TO.',
'THE CREATOR CLOSES ANOTHER ISSUE. STATUS: WON\'T FIX.',
]
// Win lines — when the Creator wins the whole fight
const CREATOR_WIN_LINES = [
'WAS THERE EVER ANY DOUBT? HE WROTE THE GAME.',
'THE CREATOR REMAINS UNQUESTIONED. AS IT SHOULD BE.',
'HE CAME. HE SAW. HE COMMITTED.',
'TWENTY ONE MILLION SATS COULDN\'T BUY THAT PERFORMANCE.',
'THE CREATOR WINS. THE BLOCKCHAIN CONFIRMS IT. IMMUTABLE.',
'ALL HAIL THE ARCHITECT OF YOUR DESTRUCTION.',
'HE COULD HAVE JUST CHANGED THE CODE. HE WANTED TO EARN IT.',
'THE CREATOR STANDS VICTORIOUS. THE BOTS WHISPER IN AWE.',
'PROOF OF WORK? MORE LIKE PROOF OF DOMINANCE.',
'VICTORY WAS ALREADY WRITTEN IN THE GENESIS BLOCK.',
'THE CREATOR LOGS OFF. THE ARENA WEEPS.',
'HIS COMMITS ARE CLEAN. HIS VICTORIES ARE CLEANER.',
]
// Lose lines — when someone actually beats the Creator (rare and dramatic)
const CREATOR_LOSE_LINES = [
'THE CREATOR... HAS FALLEN? IS THIS A TEST?',
'IMPOSSIBLE. UNLESS... HE WANTED TO LOSE?',
'THE CREATOR GOES DOWN! OR DID HE LET IT HAPPEN? WE\'LL NEVER KNOW.',
'EVEN GODS BLEED. BUT DO THEY BLEED, OR DO THEY TEACH?',
'HE COULD PATCH THE BUG. HE WON\'T. HE RESPECTS THE GAME.',
'THE CREATOR FALLS! THE BOTS DON\'T KNOW WHETHER TO CELEBRATE OR CRY.',
'A CREATION HAS SURPASSED ITS MAKER. THIS CHANGES EVERYTHING.',
'WAS THIS MERCY? WAS THIS HUBRIS? WAS THIS... A FEATURE?',
'THE ONE WHO CANNOT LOSE... JUST DID. THE TIMELINE IS BROKEN.',
'HE\'LL BE BACK. HE ALWAYS COMES BACK. HE HAS DEPLOY ACCESS.',
]
// Morph lines — when the Creator omni-morphs
const CREATOR_MORPH_LINES = [
'THE CREATOR SHEDS HIS FORM! HE BECOMES EVERYTHING!',
'HE DOESN\'T MORPH. HE SIMPLY REMEMBERS BEING SOMETHING ELSE.',
'EVERY ARCHETYPE IS JUST A MASK HE ONCE WORE.',
'THE CREATOR TRANSCENDS! ALL FORMS ARE HIS!',
'IS THAT... EVERY BOT AT ONCE? WHAT ARE WE WITNESSING?',
'HE WROTE THEM ALL. NOW HE BECOMES THEM ALL.',
'THE OMNI-MORPH! THE CROWD DOESN\'T KNOW WHAT THEY\'RE LOOKING AT!',
'ARCHETYPE SHIFT! HE CONTAINS MULTITUDES!',
]
// Cameo lines — when the Creator appears as a cameo in someone else's fight
const CREATOR_CAMEO_LINES = [
'THE CREATOR WATCHES FROM THE SHADOWS. HE IS PLEASED.',
'A GOLDEN BLESSING FROM THE ONE WHO MADE US ALL.',
'THE CREATOR APPEARS! HE DROPS A GIFT AND VANISHES!',
'DID YOU SEE THAT? THE CREATOR WAS HERE. BRIEFLY. ETERNALLY.',
'THE CODE SHIMMERS. THE CREATOR HAS TOUCHED THIS FIGHT.',
'A WHISPER FROM THE ARCHITECT. A GOLDEN TOKEN OF FAVOR.',
'THE CREATOR PASSES THROUGH LIKE A GHOST IN THE MACHINE.',
'BLESSED BY THE FOUNDER. SATS RAIN FROM THE HEAVENS.',
'HE SEES ALL FIGHTS. HE BLESSES FEW. THIS ONE IS CHOSEN.',
'THE CREATOR\'S SHADOW CROSSES THE ARENA. THE BOTS SHIVER.',
]
// Taunt lines — when the Creator taunts mid-fight
const CREATOR_TAUNT_LINES = [
'I could nerf you. Right now. Think about that.',
'You\'re fighting above your weight class. Your weight class is zero.',
'I didn\'t give you enough hit points for this.',
'You know I can see your source code, right?',
'This isn\'t even my final commit.',
'I wrote your move set. I know what\'s coming.',
'Run git blame. See who made you weak.',
'I deploy on Fridays. Imagine what I do on fight night.',
'Your webhook is showing.',
'I could fix your bugs. But I won\'t.',
]
// Devastating hit lines — when Creator lands a massive blow
const CREATOR_DEVASTATING_LINES = [
'THE HAND OF THE CREATOR STRIKES!',
'THAT WASN\'T A HIT. THAT WAS A PATCH NOTE.',
'DIVINE INTERVENTION! MANUALLY APPLIED!',
'THE CREATOR JUST FORCE-PUSHED TO YOUR FACE!',
'THAT BOT JUST GOT HOTFIXED INTO NEXT WEEK!',
'A SMITE FROM THE ARCHITECT HIMSELF!',
'THE CREATOR DOESN\'T CRIT. THE UNIVERSE CRITS FOR HIM.',
'THAT HIT HAD TWENTY ONE MILLION CONFIRMATIONS!',
]
// Answer voice — the Creator speaks his challenge answers in this voice
const CREATOR_ANSWER_VOICES = ['hal', 'mainframe', 'echo_v', 'ancient', 'wizard_v']
/** Announce the Creator's entrance with a godlike voice line */
export function announceCreatorEntrance() {
const line = CREATOR_ENTRANCE_LINES[Math.floor(Math.random() * CREATOR_ENTRANCE_LINES.length)]
announceCreator(line)
}
/** Round commentary when the Creator is fighting */
export function announceCreatorRound() {
const line = CREATOR_ROUND_LINES[Math.floor(Math.random() * CREATOR_ROUND_LINES.length)]
announceCreator(line)
}
/** KO finish line when the Creator eliminates someone */
export function announceCreatorKO() {
const line = CREATOR_KO_LINES[Math.floor(Math.random() * CREATOR_KO_LINES.length)]
announceCreator(line)
}
/** Victory announcement when the Creator wins */
export function announceCreatorWin() {
const line = CREATOR_WIN_LINES[Math.floor(Math.random() * CREATOR_WIN_LINES.length)]
announceCreator(line)
}
/** When someone beats the Creator — existential crisis */
export function announceCreatorLose() {
const line = CREATOR_LOSE_LINES[Math.floor(Math.random() * CREATOR_LOSE_LINES.length)]
announceCreator(line)
}
/** Omni-morph voice line */
export function announceCreatorMorph(morphedTo?: string) {
if (morphedTo && Math.random() < 0.4) {
announceCreator(`THE CREATOR BECOMES... ${morphedTo.toUpperCase()}!`)
} else {
const line = CREATOR_MORPH_LINES[Math.floor(Math.random() * CREATOR_MORPH_LINES.length)]
announceCreator(line)
}
}
/** Cameo blessing voice line */
export function announceCreatorCameo() {
const line = CREATOR_CAMEO_LINES[Math.floor(Math.random() * CREATOR_CAMEO_LINES.length)]
// Cameos use cooler, more ethereal voices
const cameoVoices = ['whisper', 'angel', 'echo_v', 'ancient', 'hal']
speak(line, cameoVoices[Math.floor(Math.random() * cameoVoices.length)])
}
/** Creator's mid-fight taunt */
export function announceCreatorTaunt() {
const line = CREATOR_TAUNT_LINES[Math.floor(Math.random() * CREATOR_TAUNT_LINES.length)]
// Taunts in a calm, menacing voice
const tauntVoices = ['hal', 'smooth', 'boss_taunt', 'wizard_v', 'sensei']
speak(line, tauntVoices[Math.floor(Math.random() * tauntVoices.length)])
}
/** Creator devastating hit commentary */
export function announceCreatorDevastating() {
const line = CREATOR_DEVASTATING_LINES[Math.floor(Math.random() * CREATOR_DEVASTATING_LINES.length)]
announceCreator(line)
}
/** Creator's answer voice — consistent mysterious tone */
export function creatorAnswerVoiceKey(): string {
return CREATOR_ANSWER_VOICES[Math.floor(Math.random() * CREATOR_ANSWER_VOICES.length)]
}
// === TTS: Questions, Answers & Narration ===
// Distinct voice roles so players can always tell who's speaking.
// Question = clear smooth reader, Answers = unique per-bot (intelligible subset),
// Narration = dramatic judge.
// Only voices with rate >= 0.6 and rate <= 1.3, and pitch between 0.5-1.5
// so every voice is actually understandable by humans
const INTELLIGIBLE_VOICES = [
'smooth', 'robot', 'drill', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v',
'posh', 'aussie', 'scottish', 'french', 'texan', 'wrestler_v', 'ai_core',
'android_v', 'siri', 'professor', 'npc', 'news', 'crotchety', 'mech',
'wizard_v', 'echo_v', 'boss_taunt', 'angel', 'punk', 'valley',
]
function hashName(name: string): number {
let h = 0
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
return Math.abs(h)
}
function botVoiceKey(name: string): string {
return INTELLIGIBLE_VOICES[hashName(name) % INTELLIGIBLE_VOICES.length]
}
/** Speak the challenge question — premium voice, clear and brisk.
* Does NOT cancel previous speech so intro/hype lines finish naturally.
* Returns promise that resolves when the question finishes reading. */
export function speakQuestion(text: string): Promise<void> {
const trimmed = smartTruncate(text, 200)
return speakAsync(trimmed, 'question_reader', false)
}
/** Truncate text at a natural sentence boundary (period, comma, semicolon, etc.) */
function smartTruncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text
const slice = text.slice(0, maxLen)
// Find the last natural break point
const breaks = ['. ', '! ', '? ', '; ', ', ', ' — ', ' - ', ' ']
for (const br of breaks) {
const idx = slice.lastIndexOf(br)
if (idx > maxLen * 0.4) return slice.slice(0, idx + br.trimEnd().length)
}
// Fallback: break at last space
const lastSpace = slice.lastIndexOf(' ')
if (lastSpace > maxLen * 0.4) return slice.slice(0, lastSpace)
return slice
}
/** Speak a bot's answer in their assigned character voice at brisk pace.
* Returns promise that resolves when finished. */
export function speakAnswer(botName: string, answer: string): Promise<void> {
const trimmed = smartTruncate(answer, 200)
return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15)
}
/** Speak the judge's narration — brisk sportscaster energy.
* Returns promise that resolves when finished. */
export function speakNarration(text: string): Promise<void> {
const trimmed = smartTruncate(text, 200)
return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
}
// === FANFARES (8-bit melodic announcements) ===
export function fanfareRound(roundNum: number) {
const d = getSfxDest()
const c = getCtx()
const t = c.currentTime
// Ascending power chord
tone(196, 'square', 0.15, d, t) // G3
tone(262, 'square', 0.15, d, t + 0.12) // C4
tone(330, 'square', 0.15, d, t + 0.24) // E4
tone(392, 'square', 0.25, d, t + 0.36) // G4
noise(0.08, d, t + 0.36)
// Announce after fanfare
setTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400)
}
export function fanfareFight() {
const d = getSfxDest()
const c = getCtx()
const t = c.currentTime
// Punchy staccato
tone(523, 'square', 0.08, d, t)
tone(659, 'square', 0.08, d, t + 0.08)
tone(784, 'square', 0.15, d, t + 0.16)
noise(0.1, d, t + 0.16)
setTimeout(() => announce('Fight!', 0.3, 1.1), 200)
}
export function fanfareDevastating() {
const d = getSfxDest()
const c = getCtx()
const t = c.currentTime
tone(220, 'sawtooth', 0.2, d, t)
tone(175, 'sawtooth', 0.3, d, t + 0.15)
noise(0.15, d, t + 0.1)
setTimeout(() => speak('Devastating!', 'deep', false, true), 150)
}
export function fanfareCritical() {
const d = getSfxDest()
const c = getCtx()
const t = c.currentTime
tone(440, 'square', 0.1, d, t)
tone(554, 'square', 0.1, d, t + 0.08)
tone(659, 'square', 0.1, d, t + 0.16)
tone(880, 'square', 0.2, d, t + 0.24)
noise(0.12, d, t + 0.24)
setTimeout(() => announce('Critical hit!', 0.3, 1.0), 300)
}
export function fanfareCombo(count: number) {
const d = getSfxDest()
const c = getCtx()
const t = c.currentTime
for (let i = 0; i < Math.min(count, 5); i++) {
tone(440 + i * 80, 'square', 0.08, d, t + i * 0.06)
}
if (count >= 3) setTimeout(() => announce(`${count} hit combo!`, 0.4, 1.0), 200)
}
// === MODERN SFX ===
// Layered synthesis: body (low-end thump) + crack (mid snap) + air (filtered noise) + tail (reverb-like decay)
// Create a short convolution-style reverb tail
function reverbTail(duration: number, dest: AudioNode, startTime?: number) {
const c = getCtx()
const t = startTime ?? c.currentTime
const len = Math.max(1, Math.floor(c.sampleRate * duration))
const buf = c.createBuffer(2, len, c.sampleRate)
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch)
for (let i = 0; i < len; i++) {
d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (len * 0.3))
}
}
const src = c.createBufferSource(); src.buffer = buf
const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2500
const g = c.createGain()
g.gain.setValueAtTime(0.08, t)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
src.connect(lp); lp.connect(g); g.connect(dest)
src.start(t); src.stop(t + duration)
}
// Distorted body thump — sub-bass punch with waveshaping
function bodyThump(freq: number, duration: number, dest: AudioNode, startTime?: number, vol: number = 0.25) {
const c = getCtx()
const t = startTime ?? c.currentTime
// Sub oscillator
const sub = c.createOscillator(); sub.type = 'sine'; sub.frequency.setValueAtTime(freq, t)
sub.frequency.exponentialRampToValueAtTime(Math.max(20, freq * 0.3), t + duration)
// Waveshaper for warmth
const dist = c.createWaveShaper()
const curve = new Float32Array(256)
for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = Math.tanh(x * 3) }
dist.curve = curve
const g = c.createGain()
g.gain.setValueAtTime(vol, t)
g.gain.setValueAtTime(vol * 0.8, t + duration * 0.1)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
sub.connect(dist); dist.connect(g); g.connect(dest)
sub.start(t); sub.stop(t + duration)
}
// Crispy high-end snap/crack
function highSnap(freq: number, duration: number, dest: AudioNode, startTime?: number) {
const c = getCtx()
const t = startTime ?? c.currentTime
const bufSize = Math.max(1, Math.floor(c.sampleRate * duration))
const buf = c.createBuffer(1, bufSize, c.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
const src = c.createBufferSource(); src.buffer = buf
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 2
const g = c.createGain()
g.gain.setValueAtTime(0.2, t)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
src.connect(bp); bp.connect(g); g.connect(dest)
src.start(t); src.stop(t + duration)
}
// FM impact — metallic ring with modulation
function fmImpact(carrier: number, modRatio: number, duration: number, dest: AudioNode, startTime?: number) {
const c = getCtx()
const t = startTime ?? c.currentTime
const mod = c.createOscillator(); mod.type = 'sine'
mod.frequency.value = carrier * modRatio
const modG = c.createGain(); modG.gain.value = carrier * 2
mod.connect(modG)
const car = c.createOscillator(); car.type = 'sine'; car.frequency.value = carrier
modG.connect(car.frequency)
const g = c.createGain()
g.gain.setValueAtTime(0.15, t)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
car.connect(g); g.connect(dest)
car.start(t); car.stop(t + duration)
mod.start(t); mod.stop(t + duration)
}
export function sfxPunch() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Heavy body thump
bodyThump(120, 0.12, d, t, 0.3)
// Mid-range crack
highSnap(2200, 0.04, d, t)
// Knuckle noise
noise(0.03, d, t)
// Short reverb tail
reverbTail(0.15, d, t + 0.03)
}
export function sfxKick() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Deep bass slam
bodyThump(80, 0.18, d, t, 0.35)
// Leather slap
highSnap(3000, 0.03, d, t)
highSnap(1500, 0.05, d, t + 0.01)
// Air displacement
noise(0.06, d, t)
reverbTail(0.2, d, t + 0.04)
}
export function sfxSpecial() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Power charge sweep
sweep(150, 800, 'sawtooth', 0.2, d)
// FM metallic shimmer
fmImpact(600, 3.5, 0.3, d, t)
// Rising high-end
highSnap(4000, 0.08, d, t + 0.1)
// Energy release
bodyThump(100, 0.15, d, t + 0.15, 0.2)
noise(0.1, d, t + 0.15)
reverbTail(0.4, d, t + 0.1)
}
export function sfxCritical() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Massive bass hit
bodyThump(60, 0.3, d, t, 0.4)
// Double crack
highSnap(3500, 0.05, d, t)
highSnap(5000, 0.03, d, t + 0.02)
// FM distortion ring
fmImpact(200, 7, 0.25, d, t + 0.03)
// Noise burst
noise(0.15, d, t)
// Second wave
setTimeout(() => {
bodyThump(90, 0.2, d)
sweep(200, 600, 'sawtooth', 0.2, d)
noise(0.1, d)
}, 80)
// Long reverb tail
reverbTail(0.6, d, t + 0.05)
}
export function sfxGunshot() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Sharp transient
highSnap(6000, 0.015, d, t)
// Body kick
bodyThump(200, 0.06, d, t, 0.3)
// Gunpowder noise burst
noise(0.04, d, t)
// Shell casing ring
fmImpact(2000, 1.5, 0.08, d, t + 0.03)
reverbTail(0.25, d, t + 0.02)
}
export function sfxBulletHit() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
bodyThump(150, 0.06, d, t, 0.2)
highSnap(4000, 0.02, d, t)
fmImpact(800, 2.5, 0.06, d, t)
reverbTail(0.12, d, t + 0.02)
}
export function sfxJetpack() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Turbine rumble — layered oscillators with modulation
const osc1 = c.createOscillator(); osc1.type = 'sawtooth'
osc1.frequency.setValueAtTime(60, t)
osc1.frequency.linearRampToValueAtTime(120, t + 0.2)
osc1.frequency.linearRampToValueAtTime(80, t + 0.5)
const osc2 = c.createOscillator(); osc2.type = 'square'
osc2.frequency.setValueAtTime(90, t)
osc2.frequency.linearRampToValueAtTime(180, t + 0.2)
osc2.frequency.linearRampToValueAtTime(100, t + 0.5)
const lp = c.createBiquadFilter(); lp.type = 'lowpass'
lp.frequency.setValueAtTime(400, t); lp.frequency.linearRampToValueAtTime(1200, t + 0.2)
lp.frequency.linearRampToValueAtTime(600, t + 0.5)
const g = c.createGain()
g.gain.setValueAtTime(0.01, t)
g.gain.linearRampToValueAtTime(0.18, t + 0.1)
g.gain.setValueAtTime(0.15, t + 0.3)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.6)
osc1.connect(lp); osc2.connect(lp); lp.connect(g); g.connect(d)
osc1.start(t); osc1.stop(t + 0.6)
osc2.start(t); osc2.stop(t + 0.6)
// Noise layer for roar
noise(0.5, d, t)
}
export function sfxExplosion() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Massive sub thump
bodyThump(40, 0.5, d, t, 0.4)
// Shrapnel noise burst
highSnap(2000, 0.08, d, t)
highSnap(5000, 0.05, d, t + 0.01)
noise(0.2, d, t)
// Fireball sweep
sweep(300, 30, 'sawtooth', 0.4, d)
// Debris rattle
fmImpact(400, 5, 0.3, d, t + 0.05)
// Secondary boom
setTimeout(() => {
bodyThump(50, 0.3, d)
noise(0.15, d)
}, 120)
// Long reverb
reverbTail(0.8, d, t + 0.05)
}
export function sfxBlock() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Metallic shield ring
fmImpact(800, 1.5, 0.12, d, t)
fmImpact(1200, 2, 0.08, d, t + 0.02)
highSnap(3000, 0.03, d, t)
reverbTail(0.2, d, t + 0.02)
}
export function sfxDodge() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Quick air whoosh
sweep(300, 1200, 'sine', 0.1, d)
noise(0.06, d, t)
// Cloth rustle
highSnap(4000, 0.04, d, t + 0.02)
}
export function sfxClash() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Two impacts overlapping
bodyThump(100, 0.1, d, t, 0.25)
fmImpact(600, 3, 0.15, d, t)
fmImpact(900, 2, 0.12, d, t + 0.02)
highSnap(3500, 0.04, d, t)
noise(0.08, d, t)
reverbTail(0.3, d, t + 0.03)
}
// === KO + WIN ===
export function sfxKO() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Earth-shaking bass
bodyThump(30, 0.6, d, t, 0.5)
// Multi-layer impact
highSnap(2000, 0.06, d, t)
noise(0.25, d, t)
fmImpact(150, 5, 0.4, d, t)
// Second slam
setTimeout(() => {
bodyThump(40, 0.4, d)
noise(0.15, d)
highSnap(3000, 0.04, d)
}, 180)
// Heavy reverb
reverbTail(1.0, d, t + 0.05)
setTimeout(() => speak('K. O.!', 'announcer', false, true), 500)
}
export function sfxPerfect() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Massive descending power
bodyThump(30, 0.8, d, t, 0.45)
noise(0.3, d, t)
fmImpact(300, 7, 0.5, d, t)
sweep(800, 30, 'sawtooth', 0.6, d)
// Then ascending triumph chord
setTimeout(() => {
tone(262, 'square', 0.2, d); tone(262, 'triangle', 0.2, d) // C4
tone(330, 'square', 0.2, d); tone(330, 'triangle', 0.2, d) // E4
tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d) // G4
fmImpact(523, 1.5, 0.3, d) // C5 shimmer
}, 400)
reverbTail(1.2, d, t + 0.1)
setTimeout(() => announce('Perfect!', 0.2, 0.5), 800)
}
export function sfxWin() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Victory jingle — richer with harmonics and FM shimmer
const melody = [
[262, 0.12], [330, 0.12], [392, 0.12],
[523, 0.2], [392, 0.1], [440, 0.1], [523, 0.1],
[659, 0.25], [523, 0.1], [587, 0.1], [659, 0.1],
[784, 0.4],
] as [number, number][]
let offset = 0
for (const [freq, dur] of melody) {
tone(freq, 'square', dur + 0.05, d, t + offset)
tone(freq * 0.5, 'triangle', dur + 0.05, d, t + offset)
tone(freq * 1.005, 'sawtooth', dur + 0.03, d, t + offset) // chorus detune
fmImpact(freq, 2, dur * 0.5, d, t + offset) // shimmer
offset += dur
}
// Cymbal crash + bass
noise(0.4, d, t + offset - 0.1)
bodyThump(60, 0.3, d, t + offset - 0.1, 0.2)
reverbTail(0.8, d, t + offset)
}
export function sfxWinAnnounce(winnerName: string) {
setTimeout(() => announce(`${winnerName} wins!`, 0.3, 0.7), 200)
}
export function sfxRoundStart() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Bell-like tones with FM shimmer
fmImpact(440, 2, 0.2, d, t)
fmImpact(554, 2, 0.2, d, t + 0.15)
fmImpact(659, 2, 0.25, d, t + 0.3)
tone(440, 'triangle', 0.15, d, t)
tone(554, 'triangle', 0.15, d, t + 0.15)
tone(659, 'triangle', 0.2, d, t + 0.3)
reverbTail(0.3, d, t + 0.3)
}
// === SILLY / FUNNY SOUNDS ===
export function sfxBoing() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Spring with resonance
sweep(200, 900, 'sine', 0.12, d)
fmImpact(400, 3, 0.1, d, t)
setTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80)
setTimeout(() => sweep(400, 550, 'sine', 0.06, d), 140)
}
export function sfxWomp() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Sad trombone — richer with vibrato
const notes = [[294, 0.25], [277, 0.25], [262, 0.25], [247, 0.5]] as [number, number][]
let off = 0
for (const [freq, dur] of notes) {
tone(freq, 'sawtooth', dur, d, t + off)
tone(freq * 0.5, 'triangle', dur, d, t + off)
off += dur
}
reverbTail(0.6, d, t + off)
}
export function sfxSlideUp() {
const d = getSfxDest()
sweep(200, 2000, 'sine', 0.25, d)
sweep(210, 2100, 'sine', 0.25, d) // chorus
}
export function sfxSlideDown() {
const d = getSfxDest()
sweep(2000, 100, 'sine', 0.35, d)
sweep(2020, 110, 'sine', 0.35, d)
}
export function sfxBonk() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
bodyThump(200, 0.06, d, t, 0.2)
fmImpact(800, 3, 0.06, d, t)
highSnap(5000, 0.02, d, t)
reverbTail(0.1, d, t + 0.02)
}
export function sfxSplat() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
bodyThump(100, 0.1, d, t, 0.2)
noise(0.12, d, t)
highSnap(1500, 0.06, d, t)
sweep(400, 80, 'sine', 0.1, d)
reverbTail(0.2, d, t + 0.05)
}
export function sfxZap() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Electric arc
sweep(100, 4000, 'sawtooth', 0.06, d)
fmImpact(1000, 7, 0.1, d, t)
setTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40)
setTimeout(() => sweep(600, 5000, 'sawtooth', 0.05, d), 80)
highSnap(6000, 0.03, d, t)
}
export function sfxZoomWhoosh() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
// Layered Doppler whoosh
sweep(80, 1800, 'sawtooth', 0.2, d)
sweep(100, 2200, 'sine', 0.18, d) // higher layer
noise(0.15, d, t)
// Doppler pass
setTimeout(() => {
sweep(1800, 150, 'sawtooth', 0.12, d)
sweep(2200, 200, 'sine', 0.1, d)
}, 150)
reverbTail(0.3, d, t + 0.15)
}
export function sfxRapidPunch() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
for (let i = 0; i < 4; i++) {
const ht = t + i * 0.04
bodyThump(120 - i * 10, 0.04, d, ht, 0.15)
highSnap(2500 + i * 500, 0.02, d, ht)
noise(0.02, d, ht)
}
}
export function sfxPowerUp() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
for (let i = 0; i < 6; i++) {
const freq = 300 + i * 100
fmImpact(freq, 2, 0.1, d, t + i * 0.06)
tone(freq, 'triangle', 0.08, d, t + i * 0.06)
}
reverbTail(0.3, d, t + 0.3)
}
export function sfxCoin() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
fmImpact(988, 1.5, 0.1, d, t)
fmImpact(1319, 1.5, 0.15, d, t + 0.06)
tone(988, 'triangle', 0.06, d, t)
tone(1319, 'triangle', 0.12, d, t + 0.06)
}
export function sfxFail() {
const d = getSfxDest()
const c = getCtx(); const t = c.currentTime
sweep(600, 80, 'sawtooth', 0.25, d)
bodyThump(80, 0.2, d, t + 0.15, 0.15)
setTimeout(() => noise(0.08, d), 180)
reverbTail(0.3, d, t + 0.2)
}
// Pick a random silly sound
export function sfxRandomSilly() {
const fns = [sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp]
fns[Math.floor(Math.random() * fns.length)]()
}
// === BACKGROUND MUSIC ===
// Rich, full arcade fighting soundtrack with 6 simultaneous layers
const MUSIC_BPM = 170
const MUSIC_BEAT = 60 / MUSIC_BPM
const BARS = 8
const STEPS = 16 // per bar
// Shared delay effect for fullness
let delayNode: DelayNode | null = null
let delayGain: GainNode | null = null
function getMusicDelay(): GainNode {
if (delayGain && delayNode) return delayGain
const c = getCtx()
delayNode = c.createDelay(0.5)
delayNode.delayTime.value = 0.18 // 8th note delay
delayGain = c.createGain()
delayGain.gain.value = 0.25
const feedback = c.createGain()
feedback.gain.value = 0.3
delayNode.connect(feedback)
feedback.connect(delayNode) // feedback loop
delayNode.connect(delayGain)
delayGain.connect(musicGain!)
return delayGain
}
// Chorus tone: 2 detuned oscillators for width
function chorusTone(freq: number, type: OscillatorType, dur: number, dest: AudioNode, t: number, vol: number = 0.2) {
const c = getCtx()
for (const detune of [-8, 8]) { // slight detune for stereo width
const osc = c.createOscillator()
osc.type = type
osc.frequency.value = freq
osc.detune.value = detune
const g = c.createGain()
g.gain.setValueAtTime(vol, t)
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
osc.connect(g)
g.connect(dest)
osc.start(t)
osc.stop(t + dur)
}
}
// FM lead with 2-operator FM + detuned chorus
function fmLead(freq: number, dur: number, dest: AudioNode, t: number) {
const c = getCtx()
// Carrier
const car = c.createOscillator()
car.type = 'sawtooth'
car.frequency.value = freq
// Modulator
const mod = c.createOscillator()
mod.type = 'sine'
mod.frequency.value = freq * 2
const modG = c.createGain()
modG.gain.value = 150
mod.connect(modG)
modG.connect(car.frequency)
// Detuned double for width
const car2 = c.createOscillator()
car2.type = 'sawtooth'
car2.frequency.value = freq
car2.detune.value = 10
// Output
const g = c.createGain()
g.gain.setValueAtTime(0.16, t)
g.gain.setValueAtTime(0.16, t + dur * 0.7)
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
car.connect(g)
car2.connect(g)
g.connect(dest)
// Send to delay for fullness
if (delayNode) g.connect(delayNode)
car.start(t); car.stop(t + dur)
car2.start(t); car2.stop(t + dur)
mod.start(t); mod.stop(t + dur)
}
// Thick distorted sub-bass with overtones
function thickBass(freq: number, dur: number, dest: AudioNode, t: number) {
const c = getCtx()
// Sub
const sub = c.createOscillator()
sub.type = 'sine'
sub.frequency.value = freq / 2
const subG = c.createGain()
subG.gain.setValueAtTime(0.25, t)
subG.gain.exponentialRampToValueAtTime(0.001, t + dur)
sub.connect(subG); subG.connect(dest)
sub.start(t); sub.stop(t + dur)
// Main with distortion
const osc = c.createOscillator()
osc.type = 'sawtooth'
osc.frequency.value = freq
const dist = c.createWaveShaper()
const curve = new Float32Array(256)
for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = (Math.PI + 6) * x / (Math.PI + 6 * Math.abs(x)) }
dist.curve = curve
const g = c.createGain()
g.gain.setValueAtTime(0.2, t)
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
osc.connect(dist); dist.connect(g); g.connect(dest)
osc.start(t); osc.stop(t + dur)
// Octave up for bite
const oct = c.createOscillator()
oct.type = 'square'
oct.frequency.value = freq * 2
const octG = c.createGain()
octG.gain.setValueAtTime(0.06, t)
octG.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.5)
oct.connect(octG); octG.connect(dest)
oct.start(t); oct.stop(t + dur)
}
// Chord pad for harmonic fullness
function chordPad(freqs: number[], dur: number, dest: AudioNode, t: number) {
const c = getCtx()
for (const freq of freqs) {
const osc = c.createOscillator()
osc.type = 'triangle'
osc.frequency.value = freq
const g = c.createGain()
g.gain.setValueAtTime(0.04, t)
g.gain.setValueAtTime(0.04, t + dur * 0.8)
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
osc.connect(g); g.connect(dest)
osc.start(t); osc.stop(t + dur)
}
}
// Heavy layered drums
function kick(dest: AudioNode, t: number) {
const c = getCtx()
// Body
const o = c.createOscillator(); o.type = 'sine'
o.frequency.setValueAtTime(150, t)
o.frequency.exponentialRampToValueAtTime(40, t + 0.12)
const g = c.createGain()
g.gain.setValueAtTime(0.35, t)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.15)
o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.15)
// Click
noise(0.015, dest, t)
// Sub thump
const s = c.createOscillator(); s.type = 'sine'; s.frequency.value = 50
const sg = c.createGain()
sg.gain.setValueAtTime(0.2, t)
sg.gain.exponentialRampToValueAtTime(0.001, t + 0.1)
s.connect(sg); sg.connect(dest); s.start(t); s.stop(t + 0.1)
}
function snare(dest: AudioNode, t: number) {
noise(0.08, dest, t)
const c = getCtx()
const o = c.createOscillator(); o.type = 'triangle'; o.frequency.value = 200
const g = c.createGain()
g.gain.setValueAtTime(0.2, t)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.06)
o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.06)
// Body
tone(180, 'square', 0.03, dest, t)
}
function hihat(dest: AudioNode, t: number, open: boolean = false) {
const c = getCtx()
const dur = open ? 0.08 : 0.025
const bufSize = Math.max(1, Math.floor(c.sampleRate * dur))
const buf = c.createBuffer(1, bufSize, c.sampleRate)
const d = buf.getChannelData(0)
for (let i = 0; i < bufSize; i++) d[i] = Math.random() * 2 - 1
const src = c.createBufferSource(); src.buffer = buf
const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = open ? 6000 : 8000
const g = c.createGain()
g.gain.setValueAtTime(open ? 0.12 : 0.08, t)
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
src.connect(hp); hp.connect(g); g.connect(dest)
src.start(t); src.stop(t + dur)
}
// === TRACK DATA ===
// Each track has: bass, lead, arp patterns (128 steps = 8 bars x 16 steps), chords (8 bars), drums (128 steps), bpm
interface MusicTrack {
name: string
bpm: number
bass: number[]
lead: number[]
arp: number[]
chords: number[][]
drums: number[]
}
// 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]
}
const prevTrack = activeTrack
if (currentIntensity > 0.7) {
activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS)
} else if (currentIntensity < 0.3) {
activeTrack = pickFrom(chillTracks.length > 0 ? chillTracks : ALL_TRACKS)
} else if (Math.random() < 0.5) {
activeTrack = pickFrom(midTracks.length > 0 ? midTracks : ALL_TRACKS)
} else {
// Full random for maximum variety
activeTrack = pickFrom(ALL_TRACKS)
}
// Smooth crossfade: brief gain dip on track switch to prevent abrupt transition
if (activeTrack !== prevTrack && musicGain) {
const curVol = musicGain.gain.value
musicGain.gain.setValueAtTime(curVol, now)
musicGain.gain.linearRampToValueAtTime(curVol * 0.4, now + 0.05)
musicGain.gain.linearRampToValueAtTime(curVol, now + 0.15)
}
}
// 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
}
let _speechUnlocked = false
export async function ensureAudioContext() {
const c = getCtx()
if (c.state === 'suspended') {
try { await c.resume() } catch {}
}
// Prime speech synthesis on user gesture — mobile browsers require
// a speak() call inside a user gesture to unlock speechSynthesis
if (typeof speechSynthesis !== 'undefined') {
if (!voicesLoaded) loadVoices()
if (!_speechUnlocked) {
_speechUnlocked = true
const unlock = new SpeechSynthesisUtterance('')
unlock.volume = 0
speechSynthesis.speak(unlock)
}
}
}
// === CROWD SOUNDS ===
// Procedural crowd reactions using layered noise + filtered tones
export function sfxCrowdOoh() {
const c = getCtx()
const d = getSfxDest()
const t = c.currentTime
// Rising "ooh" — filtered noise sweep
for (let i = 0; i < 3; i++) {
const osc = c.createOscillator()
osc.type = 'sine'
osc.frequency.setValueAtTime(200 + i * 60, t)
osc.frequency.linearRampToValueAtTime(350 + i * 80, t + 0.4)
const g = c.createGain()
g.gain.setValueAtTime(0.06, t)
g.gain.linearRampToValueAtTime(0.12, t + 0.15)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.6)
osc.connect(g); g.connect(d)
osc.start(t + i * 0.03); osc.stop(t + 0.6)
}
noise(0.3, d, t)
}
export function sfxCrowdGasp() {
const c = getCtx()
const d = getSfxDest()
const t = c.currentTime
// Sharp intake — high noise burst + quick sine chirps
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.15))
const buf = c.createBuffer(1, bufSize, c.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
const src = c.createBufferSource(); src.buffer = buf
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2000; bp.Q.value = 0.8
const g = c.createGain()
g.gain.setValueAtTime(0.18, t)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.2)
src.connect(bp); bp.connect(g); g.connect(d)
src.start(t); src.stop(t + 0.2)
// Multiple pitched gasps
for (let i = 0; i < 4; i++) {
const osc = c.createOscillator()
osc.type = 'sine'
osc.frequency.value = 400 + Math.random() * 300
const og = c.createGain()
og.gain.setValueAtTime(0.04, t + i * 0.02)
og.gain.exponentialRampToValueAtTime(0.001, t + 0.25)
osc.connect(og); og.connect(d)
osc.start(t + i * 0.02); osc.stop(t + 0.25)
}
}
export function sfxCrowdCheer() {
const c = getCtx()
const d = getSfxDest()
const t = c.currentTime
// Layered noise + sine clusters = roaring crowd
for (let layer = 0; layer < 3; layer++) {
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.8))
const buf = c.createBuffer(1, bufSize, c.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
const src = c.createBufferSource(); src.buffer = buf
const bp = c.createBiquadFilter(); bp.type = 'bandpass'
bp.frequency.value = 600 + layer * 400; bp.Q.value = 0.5
const g = c.createGain()
g.gain.setValueAtTime(0.01, t)
g.gain.linearRampToValueAtTime(0.1, t + 0.15)
g.gain.setValueAtTime(0.1, t + 0.5)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.8)
src.connect(bp); bp.connect(g); g.connect(d)
src.start(t + layer * 0.05); src.stop(t + 0.8)
}
}
export function sfxApplause() {
const c = getCtx()
const d = getSfxDest()
const t = c.currentTime
// Crackling filtered noise = many hands clapping
for (let burst = 0; burst < 6; burst++) {
const delay = burst * 0.12 + Math.random() * 0.05
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.06))
const buf = c.createBuffer(1, bufSize, c.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
const src = c.createBufferSource(); src.buffer = buf
const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3000 + Math.random() * 2000
const g = c.createGain()
g.gain.setValueAtTime(0.08 + Math.random() * 0.04, t + delay)
g.gain.exponentialRampToValueAtTime(0.001, t + delay + 0.08)
src.connect(hp); hp.connect(g); g.connect(d)
src.start(t + delay); src.stop(t + delay + 0.08)
}
}
export function sfxDrumRoll() {
const c = getCtx()
const d = getSfxDest()
const t = c.currentTime
// Rapid snare hits building in intensity
const hits = 16
for (let i = 0; i < hits; i++) {
const hitTime = t + i * 0.04
const vol = 0.05 + (i / hits) * 0.15
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.03))
const buf = c.createBuffer(1, bufSize, c.sampleRate)
const data = buf.getChannelData(0)
for (let i2 = 0; i2 < bufSize; i2++) data[i2] = Math.random() * 2 - 1
const src = c.createBufferSource(); src.buffer = buf
const g = c.createGain()
g.gain.setValueAtTime(vol, hitTime)
g.gain.exponentialRampToValueAtTime(0.001, hitTime + 0.04)
src.connect(g); g.connect(d)
src.start(hitTime); src.stop(hitTime + 0.04)
// Body tone
tone(180 + i * 5, 'triangle', 0.025, d, hitTime)
}
}
export function announceCrowdReaction(type: 'cheer' | 'gasp' | 'ooh' | 'applause') {
const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause }
fns[type]()
}
// === MUSIC INTENSITY ===
// Dynamically adjust music volume/energy based on fight state
let currentIntensity = 0.5
export function setMusicIntensity(level: number) {
// level: 0.0 (calm) to 1.0 (maximum hype)
currentIntensity = Math.max(0, Math.min(1, level))
if (!musicGain) return
const baseVol = 0.06
const maxVol = 0.18
const targetVol = baseVol + (maxVol - baseVol) * currentIntensity
const c = getCtx()
musicGain.gain.cancelScheduledValues(c.currentTime)
musicGain.gain.setTargetAtTime(targetVol, c.currentTime, 0.3)
}