2026-03-06 22:13:19 +00:00
|
|
|
// 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
|
|
|
|
|
|
2026-03-08 10:33:30 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
function getCtx(): AudioContext {
|
2026-03-08 10:33:30 +00:00
|
|
|
if (!ctx) initCtx()
|
|
|
|
|
if (ctx!.state === 'suspended') {
|
|
|
|
|
ctx!.resume().catch(() => {})
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
2026-03-08 10:33:30 +00:00
|
|
|
return ctx!
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 22:46:47 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
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
|
2026-03-08 12:08:18 +00:00
|
|
|
question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, // Clear, brisk question announcer
|
2026-03-06 22:13:19 +00:00
|
|
|
// 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
|
2026-03-07 11:42:32 +00:00
|
|
|
// 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
|
2026-03-08 00:10:49 +00:00
|
|
|
texan: { voice: null, pitch: 0.65, rate: 0.75, volume: 1.0 }, // Big Texan energy
|
2026-03-07 11:42:32 +00:00
|
|
|
// 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
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)]
|
2026-03-08 12:08:18 +00:00
|
|
|
// 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]
|
2026-03-06 22:13:19 +00:00
|
|
|
// 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)
|
2026-03-07 11:42:32 +00:00
|
|
|
// 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)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof speechSynthesis !== 'undefined') {
|
|
|
|
|
speechSynthesis.onvoiceschanged = loadVoices
|
|
|
|
|
loadVoices()
|
2026-03-07 22:46:47 +00:00
|
|
|
// 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)
|
2026-03-08 00:09:46 +00:00
|
|
|
// 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(() => {})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 21:15:42 +00:00
|
|
|
let _speechQueueDepth = 0
|
2026-03-08 00:09:46 +00:00
|
|
|
// Scale speech volume down so voice doesn't overpower SFX/music
|
|
|
|
|
const VOICE_VOLUME_SCALE = 0.7
|
2026-03-07 21:15:42 +00:00
|
|
|
|
2026-03-07 11:59:10 +00:00
|
|
|
function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
|
2026-03-06 22:13:19 +00:00
|
|
|
if (typeof speechSynthesis === 'undefined') return
|
2026-03-07 11:59:10 +00:00
|
|
|
if (masterMuted) return
|
2026-03-06 22:13:19 +00:00
|
|
|
if (!voicesLoaded) loadVoices()
|
2026-03-07 22:46:47 +00:00
|
|
|
// Chrome bug: speechSynthesis can get stuck. Nudge it.
|
|
|
|
|
if (speechSynthesis.paused) speechSynthesis.resume()
|
2026-03-07 23:52:11 +00:00
|
|
|
// Flush if queue is getting deep — max 2 queued to prevent buildup
|
2026-03-07 22:46:47 +00:00
|
|
|
if (cancelPrevious || (speechSynthesis.pending && speechSynthesis.speaking)) {
|
2026-03-07 23:52:11 +00:00
|
|
|
if (_speechQueueDepth > 2) {
|
2026-03-07 22:46:47 +00:00
|
|
|
speechSynthesis.cancel()
|
|
|
|
|
_speechQueueDepth = 0
|
|
|
|
|
}
|
2026-03-07 21:15:42 +00:00
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
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
|
2026-03-08 00:09:46 +00:00
|
|
|
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
2026-03-07 21:15:42 +00:00
|
|
|
_speechQueueDepth++
|
|
|
|
|
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
2026-03-08 00:09:46 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
speechSynthesis.speak(utter)
|
2026-03-07 11:42:32 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 15:54:58 +00:00
|
|
|
// 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)
|
2026-03-08 12:08:18 +00:00
|
|
|
|
2026-03-08 15:54:58 +00:00
|
|
|
/** 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> {
|
2026-03-08 12:08:18 +00:00
|
|
|
return new Promise<void>((resolve) => {
|
|
|
|
|
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
|
|
|
|
|
if (!voicesLoaded) loadVoices()
|
2026-03-08 15:54:58 +00:00
|
|
|
// No voices available = speech won't work, bail immediately
|
|
|
|
|
if (!voicesLoaded) { resolve(); return }
|
2026-03-08 12:08:18 +00:00
|
|
|
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
|
2026-03-08 15:54:58 +00:00
|
|
|
utter.rate = rateOverride !== undefined ? Math.max(rateOverride, profile.rate) : profile.rate
|
2026-03-08 12:08:18 +00:00
|
|
|
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
|
|
|
|
_speechQueueDepth++
|
2026-03-08 15:32:29 +00:00
|
|
|
let done = false
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
if (done) return
|
|
|
|
|
done = true
|
|
|
|
|
clearTimeout(safetyTimeout)
|
2026-03-08 15:54:58 +00:00
|
|
|
clearTimeout(startupCheck)
|
|
|
|
|
if (keepalive) clearInterval(keepalive)
|
2026-03-08 15:32:29 +00:00
|
|
|
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
|
|
|
|
|
resolve()
|
|
|
|
|
}
|
2026-03-08 15:54:58 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-03-08 15:32:29 +00:00
|
|
|
utter.onend = cleanup
|
|
|
|
|
utter.onerror = cleanup
|
2026-03-08 12:08:18 +00:00
|
|
|
speechSynthesis.speak(utter)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 15:54:58 +00:00
|
|
|
/** 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 11:42:32 +00:00
|
|
|
export function stopAllAudio() {
|
|
|
|
|
stopMusic()
|
|
|
|
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
2026-03-07 21:15:42 +00:00
|
|
|
_speechQueueDepth = 0
|
2026-03-08 10:33:30 +00:00
|
|
|
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
|
2026-03-08 01:21:12 +00:00
|
|
|
// then reconnect so future sounds still work
|
2026-03-08 10:33:30 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-08 01:21:12 +00:00
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Public voice functions
|
|
|
|
|
export function announce(text: string, pitch?: number, rate?: number) {
|
2026-03-07 11:59:10 +00:00
|
|
|
if (masterMuted) return
|
2026-03-06 22:13:19 +00:00
|
|
|
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
|
2026-03-08 00:09:46 +00:00
|
|
|
utter.volume = VOICE_VOLUME_SCALE
|
2026-03-07 21:15:42 +00:00
|
|
|
_speechQueueDepth++
|
|
|
|
|
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
|
|
|
|
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
2026-03-06 22:13:19 +00:00
|
|
|
speechSynthesis.speak(utter)
|
|
|
|
|
} else {
|
|
|
|
|
speak(text, 'announcer')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceDeep(text: string) { speak(text, 'deep') }
|
2026-03-07 11:42:32 +00:00
|
|
|
export function announceFast(text: string) { speak(text, 'hype') }
|
2026-03-06 22:13:19 +00:00
|
|
|
export function announceRobot(text: string) { speak(text, 'robot') }
|
2026-03-07 11:42:32 +00:00
|
|
|
export function announceScream(text: string) { speak(text, 'screamer') }
|
2026-03-06 22:13:19 +00:00
|
|
|
export function announceSmooth(text: string) { speak(text, 'smooth') }
|
|
|
|
|
|
|
|
|
|
// Pick a random voice profile for variety
|
|
|
|
|
const ALL_VOICE_KEYS = Object.keys(voiceProfiles)
|
2026-03-07 11:59:10 +00:00
|
|
|
export function announceRandom(text: string) {
|
2026-03-06 22:13:19 +00:00
|
|
|
const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)]
|
2026-03-07 11:59:10 +00:00
|
|
|
speak(text, key)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
// Announce with a specific mood category
|
2026-03-07 11:42:32 +00:00
|
|
|
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']
|
2026-03-07 11:59:10 +00:00
|
|
|
export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)]) }
|
2026-03-07 11:42:32 +00:00
|
|
|
export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)]) }
|
2026-03-06 22:13:19 +00:00
|
|
|
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 = [
|
2026-03-07 08:52:24 +00:00
|
|
|
'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!',
|
2026-03-06 22:13:19 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const DEEP_INTROS = [
|
2026-03-07 08:52:24 +00:00
|
|
|
'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.',
|
2026-03-06 22:13:19 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const ROUND_HYPE = [
|
2026-03-07 08:52:24 +00:00
|
|
|
'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!',
|
2026-03-06 22:13:19 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
// Mortal Kombat style dramatic calls
|
|
|
|
|
export function announceFinishHim() {
|
2026-03-07 11:42:32 +00:00
|
|
|
speak('Finish it!', 'announcer', false, true)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 10:42:18 +00:00
|
|
|
export function announceFatality(tagline?: string) {
|
2026-03-07 11:42:32 +00:00
|
|
|
speak(tagline || 'Fatality!', 'deep', false, true)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceFlawlessVictory() {
|
2026-03-07 11:42:32 +00:00
|
|
|
speak('Flawless victory!', 'deep', false, true)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 14:46:25 +00:00
|
|
|
// === 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)]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 12:08:18 +00:00
|
|
|
// === 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> {
|
2026-03-08 15:32:29 +00:00
|
|
|
const trimmed = smartTruncate(text, 200)
|
2026-03-08 12:08:18 +00:00
|
|
|
return speakAsync(trimmed, 'question_reader', false)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 15:32:29 +00:00
|
|
|
/** 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 12:08:18 +00:00
|
|
|
/** 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> {
|
2026-03-08 15:32:29 +00:00
|
|
|
const trimmed = smartTruncate(answer, 200)
|
2026-03-08 12:08:18 +00:00
|
|
|
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> {
|
2026-03-08 15:32:29 +00:00
|
|
|
const trimmed = smartTruncate(text, 200)
|
2026-03-08 12:08:18 +00:00
|
|
|
return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// === FANFARES (8-bit melodic announcements) ===
|
|
|
|
|
|
|
|
|
|
export function fanfareRound(roundNum: number) {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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)
|
2026-03-07 11:42:32 +00:00
|
|
|
setTimeout(() => speak('Devastating!', 'deep', false, true), 150)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fanfareCritical() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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) {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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)
|
2026-03-07 11:42:32 +00:00
|
|
|
setTimeout(() => speak('K. O.!', 'announcer', false, true), 500)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxPerfect() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
sweep(200, 2000, 'sine', 0.25, d)
|
|
|
|
|
sweep(210, 2100, 'sine', 0.25, d) // chorus
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxSlideDown() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
sweep(2000, 100, 'sine', 0.35, d)
|
|
|
|
|
sweep(2020, 110, 'sine', 0.35, d)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxBonk() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-08 16:55:14 +00:00
|
|
|
const fns = [
|
|
|
|
|
sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp,
|
|
|
|
|
sfxVineBoom, sfxAirHorn, sfxFart, sfxRubberChicken, sfxSqueakyToy,
|
|
|
|
|
sfxCartoonRun, sfxWetSlap, sfxBoneCrack, sfxRimShot, sfxDing,
|
|
|
|
|
]
|
2026-03-06 22:13:19 +00:00
|
|
|
fns[Math.floor(Math.random() * fns.length)]()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:55:14 +00:00
|
|
|
// Random comedy sound — heavier on meme sounds, for taunts/showboats/round ends
|
|
|
|
|
export function sfxRandomComedy() {
|
|
|
|
|
const fns = [
|
|
|
|
|
sfxVineBoom, sfxAirHorn, sfxBruh, sfxFart, sfxRecordScratch,
|
|
|
|
|
sfxRubberChicken, sfxSqueakyToy, sfxWetSlap, sfxBoneCrack,
|
|
|
|
|
sfxCartoonRun, sfxRimShot, sfxSlideWhistleUp, sfxSlideWhistleDown,
|
|
|
|
|
sfxYippee, sfxDing, sfxTacoBellBong, sfxWindowsError, sfxMemeThud,
|
|
|
|
|
sfxBoing, sfxBonk, sfxSplat,
|
|
|
|
|
]
|
|
|
|
|
fns[Math.floor(Math.random() * fns.length)]()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Random fail sound — for missed attacks, dodges, KOs on the loser
|
|
|
|
|
export function sfxRandomFail() {
|
|
|
|
|
const fns = [
|
|
|
|
|
sfxSadTrombone, sfxFailHorn, sfxPriceIsRightFail, sfxBuzzer,
|
|
|
|
|
sfxMissionFailed, sfxCrickets, sfxWindowsError, sfxSadViolin,
|
|
|
|
|
sfxWomp, sfxEmotionalDamage, sfxDunDunDun,
|
|
|
|
|
]
|
|
|
|
|
fns[Math.floor(Math.random() * fns.length)]()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === COMEDY SOUND EFFECTS ===
|
|
|
|
|
// Procedural comedy/meme sounds inspired by classic internet soundboard culture
|
|
|
|
|
|
|
|
|
|
// VINE BOOM — iconic deep bass boom with heavy sub + distortion
|
|
|
|
|
export function sfxVineBoom() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
bodyThump(45, 0.4, d, t, 0.5)
|
|
|
|
|
bodyThump(90, 0.3, d, t, 0.35)
|
|
|
|
|
fmImpact(150, 5, 0.25, d, t)
|
|
|
|
|
noise(0.06, d, t)
|
|
|
|
|
reverbTail(0.5, d, t + 0.1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AIR HORN (MLG) — obnoxious ascending horn blasts
|
|
|
|
|
export function sfxAirHorn() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const bt = t + i * 0.15
|
|
|
|
|
tone(520 + i * 30, 'sawtooth', 0.12, d, bt)
|
|
|
|
|
tone(523 + i * 30, 'square', 0.12, d, bt)
|
|
|
|
|
tone(1046 + i * 60, 'sawtooth', 0.08, d, bt)
|
|
|
|
|
}
|
|
|
|
|
reverbTail(0.3, d, t + 0.4)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// BRUH — deep bass impact with vowel-like formant resonance
|
|
|
|
|
export function sfxBruh() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
bodyThump(80, 0.3, d, t, 0.4)
|
|
|
|
|
const bufSize = Math.floor(c.sampleRate * 0.25)
|
|
|
|
|
const buf = c.createBuffer(1, bufSize, c.sampleRate)
|
|
|
|
|
const data = buf.getChannelData(0)
|
|
|
|
|
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 300; bp.Q.value = 5
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.25, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.25)
|
|
|
|
|
src.connect(bp); bp.connect(g); g.connect(d)
|
|
|
|
|
src.start(t); src.stop(t + 0.25)
|
|
|
|
|
bodyThump(55, 0.15, d, t + 0.05, 0.3)
|
|
|
|
|
reverbTail(0.3, d, t + 0.1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FART — rumbling low-frequency modulated noise
|
|
|
|
|
export function sfxFart() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
const duration = 0.3 + Math.random() * 0.3
|
|
|
|
|
const osc = c.createOscillator(); osc.type = 'sawtooth'
|
|
|
|
|
osc.frequency.setValueAtTime(80 + Math.random() * 40, t)
|
|
|
|
|
osc.frequency.exponentialRampToValueAtTime(40 + Math.random() * 30, t + duration)
|
|
|
|
|
const lfo = c.createOscillator(); lfo.type = 'square'
|
|
|
|
|
lfo.frequency.setValueAtTime(20 + Math.random() * 30, t)
|
|
|
|
|
const lfoG = c.createGain(); lfoG.gain.value = 40
|
|
|
|
|
lfo.connect(lfoG); lfoG.connect(osc.frequency)
|
|
|
|
|
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 150 + Math.random() * 100; bp.Q.value = 2
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.3, t)
|
|
|
|
|
g.gain.setValueAtTime(0.25, t + duration * 0.3)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
|
|
|
|
osc.connect(bp); bp.connect(g); g.connect(d)
|
|
|
|
|
osc.start(t); osc.stop(t + duration)
|
|
|
|
|
lfo.start(t); lfo.stop(t + duration)
|
|
|
|
|
noise(duration * 0.6, d, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RECORD SCRATCH — vinyl stop effect
|
|
|
|
|
export function sfxRecordScratch() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
sweep(3000, 100, 'sawtooth', 0.15, d)
|
|
|
|
|
sweep(2500, 80, 'square', 0.12, d)
|
|
|
|
|
noise(0.1, d, t)
|
|
|
|
|
highSnap(4000, 0.03, d, t)
|
|
|
|
|
setTimeout(() => sweep(200, 400, 'sine', 0.08, d), 100)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RUBBER CHICKEN — squeaky honk
|
|
|
|
|
export function sfxRubberChicken() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
sweep(300, 1200, 'sine', 0.1, d)
|
|
|
|
|
const osc = c.createOscillator(); osc.type = 'sine'
|
|
|
|
|
osc.frequency.setValueAtTime(1100, t + 0.1)
|
|
|
|
|
osc.frequency.setValueAtTime(1200, t + 0.15)
|
|
|
|
|
osc.frequency.exponentialRampToValueAtTime(600, t + 0.35)
|
|
|
|
|
const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 20
|
|
|
|
|
const lfoG = c.createGain(); lfoG.gain.value = 80
|
|
|
|
|
lfo.connect(lfoG); lfoG.connect(osc.frequency)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.25, t + 0.1)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.35)
|
|
|
|
|
osc.connect(g); g.connect(d)
|
|
|
|
|
osc.start(t + 0.1); osc.stop(t + 0.35)
|
|
|
|
|
lfo.start(t + 0.1); lfo.stop(t + 0.35)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SQUEAKY TOY — quick toy squeeze
|
|
|
|
|
export function sfxSqueakyToy() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
sweep(400, 1800, 'sine', 0.06, d)
|
|
|
|
|
sweep(1800, 600, 'sine', 0.1, d)
|
|
|
|
|
setTimeout(() => sweep(500, 1500, 'sine', 0.05, d), 120)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WET SLAP — meaty slap with low-end body
|
|
|
|
|
export function sfxWetSlap() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
highSnap(3000, 0.02, d, t)
|
|
|
|
|
highSnap(1200, 0.03, d, t)
|
|
|
|
|
bodyThump(150, 0.08, d, t, 0.3)
|
|
|
|
|
noise(0.06, d, t)
|
|
|
|
|
fmImpact(300, 2, 0.08, d, t)
|
|
|
|
|
reverbTail(0.15, d, t + 0.03)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// BONE CRACK — sharp multi-frequency crack sequence
|
|
|
|
|
export function sfxBoneCrack() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const ct = t + i * 0.04
|
|
|
|
|
highSnap(3000 + i * 1500, 0.015, d, ct)
|
|
|
|
|
fmImpact(800 + i * 400, 4, 0.03, d, ct)
|
|
|
|
|
noise(0.02, d, ct)
|
|
|
|
|
}
|
|
|
|
|
bodyThump(100, 0.04, d, t + 0.08, 0.15)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CARTOON RUN — rapid bongo/pitter-patter footsteps
|
|
|
|
|
export function sfxCartoonRun() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
const st = t + i * 0.05
|
|
|
|
|
bodyThump(200 + (i % 2) * 80, 0.03, d, st, 0.15)
|
|
|
|
|
highSnap(2000 + Math.random() * 1000, 0.01, d, st)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RIM SHOT — ba dum tss
|
|
|
|
|
export function sfxRimShot() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
bodyThump(180, 0.08, d, t, 0.2)
|
|
|
|
|
bodyThump(140, 0.08, d, t + 0.15, 0.2)
|
|
|
|
|
highSnap(4000, 0.04, d, t + 0.3)
|
|
|
|
|
noise(0.15, d, t + 0.3)
|
|
|
|
|
fmImpact(2000, 3, 0.12, d, t + 0.3)
|
|
|
|
|
bodyThump(200, 0.04, d, t + 0.3, 0.15)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SLIDE WHISTLE UP — cartoon ascend
|
|
|
|
|
export function sfxSlideWhistleUp() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
sweep(300, 2500, 'sine', 0.4, d)
|
|
|
|
|
sweep(305, 2510, 'sine', 0.4, d)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SLIDE WHISTLE DOWN — cartoon descend
|
|
|
|
|
export function sfxSlideWhistleDown() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
sweep(2500, 200, 'sine', 0.5, d)
|
|
|
|
|
sweep(2510, 205, 'sine', 0.5, d)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// YIPPEE — ascending cheerful chirps
|
|
|
|
|
export function sfxYippee() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
const notes = [523, 659, 784, 1047]
|
|
|
|
|
for (let i = 0; i < notes.length; i++) {
|
|
|
|
|
tone(notes[i], 'triangle', 0.08, d, t + i * 0.07)
|
|
|
|
|
tone(notes[i] * 2, 'sine', 0.06, d, t + i * 0.07)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DING — bright bell
|
|
|
|
|
export function sfxDing() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
fmImpact(1319, 1.5, 0.3, d, t)
|
|
|
|
|
tone(1319, 'triangle', 0.25, d, t)
|
|
|
|
|
tone(2638, 'sine', 0.15, d, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TACO BELL BONG — deep resonant bell
|
|
|
|
|
export function sfxTacoBellBong() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
fmImpact(130, 2.5, 0.8, d, t)
|
|
|
|
|
fmImpact(261, 3, 0.6, d, t)
|
|
|
|
|
tone(130, 'sine', 0.6, d, t)
|
|
|
|
|
bodyThump(65, 0.3, d, t, 0.2)
|
|
|
|
|
reverbTail(0.7, d, t + 0.1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WINDOWS ERROR — two-tone error chord
|
|
|
|
|
export function sfxWindowsError() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
tone(440, 'square', 0.15, d, t)
|
|
|
|
|
tone(466, 'square', 0.15, d, t)
|
|
|
|
|
tone(220, 'triangle', 0.2, d, t)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
tone(349, 'square', 0.2, d)
|
|
|
|
|
tone(175, 'triangle', 0.25, d)
|
|
|
|
|
}, 180)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MEME THUD — super heavy bass drop
|
|
|
|
|
export function sfxMemeThud() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
bodyThump(35, 0.5, d, t, 0.5)
|
|
|
|
|
bodyThump(70, 0.4, d, t, 0.4)
|
|
|
|
|
noise(0.08, d, t)
|
|
|
|
|
fmImpact(100, 6, 0.3, d, t)
|
|
|
|
|
reverbTail(0.6, d, t + 0.1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SAD TROMBONE (Wa Wa Wa Waaaa) — classic comedy fail
|
|
|
|
|
export function sfxSadTrombone() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
const notes: [number, number][] = [[466, 0.25], [440, 0.25], [415, 0.25], [392, 0.7]]
|
|
|
|
|
let off = 0
|
|
|
|
|
for (let i = 0; i < notes.length; i++) {
|
|
|
|
|
const [freq, dur] = notes[i]
|
|
|
|
|
tone(freq, 'sawtooth', dur, d, t + off)
|
|
|
|
|
tone(freq * 0.5, 'triangle', dur, d, t + off)
|
|
|
|
|
if (i === 3) {
|
|
|
|
|
const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq
|
|
|
|
|
const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 5
|
|
|
|
|
const lfoG = c.createGain(); lfoG.gain.value = 8
|
|
|
|
|
lfo.connect(lfoG); lfoG.connect(osc.frequency)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.2, t + off)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + off + dur)
|
|
|
|
|
osc.connect(g); g.connect(d)
|
|
|
|
|
osc.start(t + off); osc.stop(t + off + dur)
|
|
|
|
|
lfo.start(t + off); lfo.stop(t + off + dur)
|
|
|
|
|
}
|
|
|
|
|
off += dur
|
|
|
|
|
}
|
|
|
|
|
reverbTail(0.5, d, t + off)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EMOTIONAL DAMAGE — dramatic descending brass stab
|
|
|
|
|
export function sfxEmotionalDamage() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
tone(220, 'sawtooth', 0.3, d, t)
|
|
|
|
|
tone(277, 'sawtooth', 0.3, d, t)
|
|
|
|
|
tone(330, 'sawtooth', 0.3, d, t)
|
|
|
|
|
bodyThump(110, 0.2, d, t, 0.3)
|
|
|
|
|
noise(0.08, d, t)
|
|
|
|
|
fmImpact(600, 4, 0.2, d, t)
|
|
|
|
|
reverbTail(0.5, d, t + 0.1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DUN DUN DUNNNNN — dramatic reveal stinger
|
|
|
|
|
export function sfxDunDunDun() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
tone(147, 'sawtooth', 0.2, d, t)
|
|
|
|
|
tone(147, 'triangle', 0.2, d, t)
|
|
|
|
|
bodyThump(73, 0.15, d, t, 0.25)
|
|
|
|
|
tone(147, 'sawtooth', 0.2, d, t + 0.25)
|
|
|
|
|
bodyThump(73, 0.15, d, t + 0.25, 0.25)
|
|
|
|
|
tone(110, 'sawtooth', 0.8, d, t + 0.5)
|
|
|
|
|
tone(110, 'triangle', 0.8, d, t + 0.5)
|
|
|
|
|
tone(55, 'sine', 0.8, d, t + 0.5)
|
|
|
|
|
bodyThump(55, 0.5, d, t + 0.5, 0.3)
|
|
|
|
|
reverbTail(0.8, d, t + 0.6)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FAIL HORN — game show wrong answer horn
|
|
|
|
|
export function sfxFailHorn() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
tone(311, 'sawtooth', 0.3, d, t)
|
|
|
|
|
tone(156, 'sawtooth', 0.3, d, t)
|
|
|
|
|
tone(233, 'square', 0.3, d, t)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
tone(277, 'sawtooth', 0.5, d)
|
|
|
|
|
tone(139, 'sawtooth', 0.5, d)
|
|
|
|
|
tone(208, 'square', 0.5, d)
|
|
|
|
|
bodyThump(70, 0.3, d, undefined, 0.2)
|
|
|
|
|
}, 350)
|
|
|
|
|
reverbTail(0.5, d, t + 0.8)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PRICE IS RIGHT FAIL — descending tuba + sad brass
|
|
|
|
|
export function sfxPriceIsRightFail() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
const notes = [262, 247, 233, 220, 208, 196]
|
|
|
|
|
for (let i = 0; i < notes.length; i++) {
|
|
|
|
|
const nt = t + i * 0.18
|
|
|
|
|
tone(notes[i], 'sawtooth', 0.2, d, nt)
|
|
|
|
|
tone(notes[i] * 0.5, 'triangle', 0.2, d, nt)
|
|
|
|
|
}
|
|
|
|
|
bodyThump(65, 0.4, d, t + notes.length * 0.18, 0.3)
|
|
|
|
|
reverbTail(0.5, d, t + notes.length * 0.18)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// BUZZER — harsh game show wrong answer
|
|
|
|
|
export function sfxBuzzer() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
tone(150, 'square', 0.4, d, t)
|
|
|
|
|
tone(153, 'square', 0.4, d, t)
|
|
|
|
|
tone(75, 'square', 0.4, d, t)
|
|
|
|
|
noise(0.15, d, t)
|
|
|
|
|
bodyThump(60, 0.2, d, t, 0.2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SAD VIOLIN — thin high vibrato melody
|
|
|
|
|
export function sfxSadViolin() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
const notes: [number, number][] = [[659, 0.3], [622, 0.3], [587, 0.4], [554, 0.5]]
|
|
|
|
|
let off = 0
|
|
|
|
|
for (const [freq, dur] of notes) {
|
|
|
|
|
const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq
|
|
|
|
|
const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 6
|
|
|
|
|
const lfoG = c.createGain(); lfoG.gain.value = 6
|
|
|
|
|
lfo.connect(lfoG); lfoG.connect(osc.frequency)
|
|
|
|
|
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq * 2; bp.Q.value = 3
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.15, t + off)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + off + dur)
|
|
|
|
|
osc.connect(bp); bp.connect(g); g.connect(d)
|
|
|
|
|
osc.start(t + off); osc.stop(t + off + dur)
|
|
|
|
|
lfo.start(t + off); lfo.stop(t + off + dur)
|
|
|
|
|
off += dur
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MISSION FAILED — dark two-note descending stab
|
|
|
|
|
export function sfxMissionFailed() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
tone(165, 'sawtooth', 0.3, d, t)
|
|
|
|
|
tone(196, 'sawtooth', 0.3, d, t)
|
|
|
|
|
tone(247, 'sawtooth', 0.3, d, t)
|
|
|
|
|
bodyThump(82, 0.2, d, t, 0.2)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
tone(139, 'sawtooth', 0.6, d)
|
|
|
|
|
tone(165, 'sawtooth', 0.6, d)
|
|
|
|
|
tone(208, 'sawtooth', 0.6, d)
|
|
|
|
|
bodyThump(70, 0.4, d, undefined, 0.25)
|
|
|
|
|
reverbTail(0.6, d)
|
|
|
|
|
}, 400)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CRICKETS — awkward silence chirps
|
|
|
|
|
export function sfxCrickets() {
|
|
|
|
|
const d = getSfxDest()
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const ct = t + i * 0.4
|
|
|
|
|
tone(4200, 'sine', 0.03, d, ct)
|
|
|
|
|
tone(4200, 'sine', 0.03, d, ct + 0.05)
|
|
|
|
|
tone(4500, 'sine', 0.02, d, ct + 0.03)
|
|
|
|
|
tone(4500, 'sine', 0.02, d, ct + 0.08)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// === 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// === GENRE-SPECIFIC SYNTH VARIANTS ===
|
2026-03-06 22:13:19 +00:00
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Pulse bass — sharp square wave, chiptune/heroic/bouncy
|
|
|
|
|
function pulseBass(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'square'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.18, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Slap bass — short punchy sine with fast decay, funky/speed
|
|
|
|
|
function slapBass(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sine'
|
|
|
|
|
osc.frequency.setValueAtTime(freq * 1.5, t)
|
|
|
|
|
osc.frequency.exponentialRampToValueAtTime(freq, t + 0.02)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.3, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + Math.min(dur, 0.12))
|
|
|
|
|
osc.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sub bass — deep pure sine, atmospheric/emotional
|
|
|
|
|
function subBass(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sine'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.25, t)
|
|
|
|
|
g.gain.setValueAtTime(0.25, t + dur * 0.8)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Distorted bass — sawtooth through waveshaper, metal/boss
|
|
|
|
|
function distortedBass(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sawtooth'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const dist = c.createWaveShaper()
|
|
|
|
|
const curve = new Float32Array(256)
|
|
|
|
|
for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = Math.tanh(x * 3) }
|
|
|
|
|
dist.curve = curve
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.22, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(dist); dist.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
const sub = c.createOscillator()
|
|
|
|
|
sub.type = 'sine'
|
|
|
|
|
sub.frequency.value = freq * 0.5
|
|
|
|
|
const sg = c.createGain()
|
|
|
|
|
sg.gain.setValueAtTime(0.12, t)
|
|
|
|
|
sg.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.7)
|
|
|
|
|
sub.connect(sg); sg.connect(dest)
|
|
|
|
|
sub.start(t); sub.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Organ bass — square + sine harmonics, gothic
|
|
|
|
|
function organBass(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
for (const [waveType, vol, mult] of [['square', 0.1, 1], ['sine', 0.12, 2], ['sine', 0.06, 3]] as [OscillatorType, number, number][]) {
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = waveType
|
|
|
|
|
osc.frequency.value = freq * mult
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(vol, t)
|
|
|
|
|
g.gain.setValueAtTime(vol, t + dur * 0.85)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sharp lead — detuned square waves, chiptune/ninja
|
|
|
|
|
function sharpLead(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'square'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const osc2 = c.createOscillator()
|
|
|
|
|
osc2.type = 'square'
|
|
|
|
|
osc2.frequency.value = freq
|
|
|
|
|
osc2.detune.value = 6
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.1, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g); osc2.connect(g); g.connect(dest)
|
|
|
|
|
if (delayNode) g.connect(delayNode)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
osc2.start(t); osc2.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Smooth lead — gentle sine FM with vibrato, emotional/JRPG
|
|
|
|
|
function smoothLead(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const car = c.createOscillator()
|
|
|
|
|
car.type = 'triangle'
|
|
|
|
|
car.frequency.value = freq
|
|
|
|
|
const mod = c.createOscillator()
|
|
|
|
|
mod.type = 'sine'
|
|
|
|
|
mod.frequency.value = freq * 1.5
|
|
|
|
|
const modG = c.createGain()
|
|
|
|
|
modG.gain.value = 40
|
|
|
|
|
mod.connect(modG); modG.connect(car.frequency)
|
|
|
|
|
const vib = c.createOscillator()
|
|
|
|
|
vib.type = 'sine'
|
|
|
|
|
vib.frequency.value = 5
|
|
|
|
|
const vibG = c.createGain()
|
|
|
|
|
vibG.gain.value = 4
|
|
|
|
|
vib.connect(vibG); vibG.connect(car.frequency)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.001, t)
|
|
|
|
|
g.gain.linearRampToValueAtTime(0.14, t + dur * 0.15)
|
|
|
|
|
g.gain.setValueAtTime(0.14, t + dur * 0.7)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
car.connect(g); g.connect(dest)
|
|
|
|
|
if (delayNode) g.connect(delayNode)
|
|
|
|
|
car.start(t); car.stop(t + dur)
|
|
|
|
|
mod.start(t); mod.stop(t + dur)
|
|
|
|
|
vib.start(t); vib.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aggressive lead — heavy FM sawtooth, metal/boss
|
|
|
|
|
function aggressiveLead(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const car = c.createOscillator()
|
|
|
|
|
car.type = 'sawtooth'
|
|
|
|
|
car.frequency.value = freq
|
|
|
|
|
const mod = c.createOscillator()
|
|
|
|
|
mod.type = 'square'
|
|
|
|
|
mod.frequency.value = freq * 3
|
|
|
|
|
const modG = c.createGain()
|
|
|
|
|
modG.gain.value = 300
|
|
|
|
|
mod.connect(modG); modG.connect(car.frequency)
|
|
|
|
|
const car2 = c.createOscillator()
|
|
|
|
|
car2.type = 'sawtooth'
|
|
|
|
|
car2.frequency.value = freq
|
|
|
|
|
car2.detune.value = -15
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.13, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
car.connect(g); car2.connect(g); g.connect(dest)
|
|
|
|
|
if (delayNode) g.connect(delayNode)
|
|
|
|
|
car.start(t); car.stop(t + dur)
|
|
|
|
|
car2.start(t); car2.stop(t + dur)
|
|
|
|
|
mod.start(t); mod.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wah lead — filtered sawtooth with bandpass sweep, funky/speed
|
|
|
|
|
function wahLead(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sawtooth'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const filter = c.createBiquadFilter()
|
|
|
|
|
filter.type = 'bandpass'
|
|
|
|
|
filter.Q.value = 5
|
|
|
|
|
filter.frequency.setValueAtTime(freq * 2, t)
|
|
|
|
|
filter.frequency.exponentialRampToValueAtTime(freq * 8, t + dur * 0.3)
|
|
|
|
|
filter.frequency.exponentialRampToValueAtTime(freq * 2, t + dur)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.2, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(filter); filter.connect(g); g.connect(dest)
|
|
|
|
|
if (delayNode) g.connect(delayNode)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Gothic lead — vibrato sawtooth + triangle overtone, Castlevania
|
|
|
|
|
function gothicLead(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sawtooth'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const vib = c.createOscillator()
|
|
|
|
|
vib.type = 'sine'
|
|
|
|
|
vib.frequency.value = 6.5
|
|
|
|
|
const vibG = c.createGain()
|
|
|
|
|
vibG.gain.value = 8
|
|
|
|
|
vib.connect(vibG); vibG.connect(osc.frequency)
|
|
|
|
|
const osc2 = c.createOscillator()
|
|
|
|
|
osc2.type = 'triangle'
|
|
|
|
|
osc2.frequency.value = freq * 2
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.12, t)
|
|
|
|
|
g.gain.setValueAtTime(0.12, t + dur * 0.75)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g); g.connect(dest)
|
|
|
|
|
const g2 = c.createGain()
|
|
|
|
|
g2.gain.setValueAtTime(0.04, t)
|
|
|
|
|
g2.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc2.connect(g2); g2.connect(dest)
|
|
|
|
|
if (delayNode) g.connect(delayNode)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
osc2.start(t); osc2.stop(t + dur)
|
|
|
|
|
vib.start(t); vib.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dark pad — filtered sawtooth, gothic/atmospheric/boss
|
|
|
|
|
function darkPad(freqs: number[], dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
for (const freq of freqs) {
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sawtooth'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const filter = c.createBiquadFilter()
|
|
|
|
|
filter.type = 'lowpass'
|
|
|
|
|
filter.frequency.value = freq * 3
|
|
|
|
|
filter.Q.value = 1
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.03, t)
|
|
|
|
|
g.gain.setValueAtTime(0.03, t + dur * 0.8)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(filter); filter.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bright pad — square wave, heroic/bouncy
|
|
|
|
|
function brightPad(freqs: number[], dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
for (const freq of freqs) {
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'square'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.025, t)
|
|
|
|
|
g.gain.setValueAtTime(0.025, t + dur * 0.8)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Style dispatch tables
|
|
|
|
|
type SynthStyle = 'epic' | 'gothic' | 'funky' | 'heroic' | 'metal' | 'emotional' | 'ninja' | 'speed' | 'atmospheric' | 'military' | 'bouncy' | 'boss'
|
|
|
|
|
type BassFunc = (freq: number, dur: number, dest: AudioNode, t: number) => void
|
|
|
|
|
type PadFunc = (freqs: number[], dur: number, dest: AudioNode, t: number) => void
|
|
|
|
|
|
|
|
|
|
const STYLE_BASS: Record<SynthStyle, BassFunc> = {
|
|
|
|
|
epic: thickBass, gothic: organBass, funky: slapBass, heroic: pulseBass,
|
|
|
|
|
metal: distortedBass, emotional: subBass, ninja: pulseBass, speed: slapBass,
|
|
|
|
|
atmospheric: subBass, military: thickBass, bouncy: pulseBass, boss: distortedBass,
|
|
|
|
|
}
|
|
|
|
|
const STYLE_LEAD: Record<SynthStyle, BassFunc> = {
|
|
|
|
|
epic: fmLead, gothic: gothicLead, funky: wahLead, heroic: sharpLead,
|
|
|
|
|
metal: aggressiveLead, emotional: smoothLead, ninja: sharpLead, speed: wahLead,
|
|
|
|
|
atmospheric: smoothLead, military: fmLead, bouncy: sharpLead, boss: aggressiveLead,
|
|
|
|
|
}
|
|
|
|
|
const STYLE_ARP_TYPE: Record<SynthStyle, OscillatorType> = {
|
|
|
|
|
epic: 'triangle', gothic: 'sawtooth', funky: 'square', heroic: 'square',
|
|
|
|
|
metal: 'sawtooth', emotional: 'triangle', ninja: 'square', speed: 'triangle',
|
|
|
|
|
atmospheric: 'sine', military: 'triangle', bouncy: 'square', boss: 'sawtooth',
|
|
|
|
|
}
|
|
|
|
|
const STYLE_PAD: Record<SynthStyle, PadFunc> = {
|
|
|
|
|
epic: chordPad, gothic: darkPad, funky: chordPad, heroic: brightPad,
|
|
|
|
|
metal: darkPad, emotional: chordPad, ninja: darkPad, speed: brightPad,
|
|
|
|
|
atmospheric: darkPad, military: chordPad, bouncy: brightPad, boss: darkPad,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === TRACK DATA ===
|
2026-03-06 22:13:19 +00:00
|
|
|
interface MusicTrack {
|
|
|
|
|
name: string
|
|
|
|
|
bpm: number
|
2026-03-08 17:10:59 +00:00
|
|
|
synthStyle: SynthStyle
|
2026-03-06 22:13:19 +00:00
|
|
|
bass: number[]
|
|
|
|
|
lead: number[]
|
|
|
|
|
arp: number[]
|
|
|
|
|
chords: number[][]
|
|
|
|
|
drums: number[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 1: "Desert Storm" — 200 BPM, A minor, Turrican-style epic sweep
|
2026-03-06 22:13:19 +00:00
|
|
|
const track1: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Desert Storm', bpm: 200, synthStyle: 'epic',
|
2026-03-06 22:13:19 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
110,0,220,0, 110,0,220,110, 110,0,220,0, 165,0,220,0,
|
|
|
|
|
87,0,175,0, 87,0,175,87, 87,0,175,0, 131,0,175,0,
|
|
|
|
|
73,0,147,0, 73,0,147,73, 73,0,147,0, 110,0,147,0,
|
|
|
|
|
82,0,165,0, 82,0,165,82, 82,0,165,0, 123,0,165,0,
|
|
|
|
|
110,0,220,110, 110,165,220,110, 110,0,220,110, 110,165,220,165,
|
|
|
|
|
131,0,262,131, 131,196,262,131, 131,0,262,131, 131,196,262,196,
|
|
|
|
|
87,0,175,87, 87,131,175,87, 87,0,175,87, 87,131,175,131,
|
|
|
|
|
82,0,165,82, 82,123,165,82, 82,165,247,330, 165,82,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
440,0,523,659, 880,0,659,523, 440,0,523,659, 784,880,784,659,
|
|
|
|
|
349,0,440,523, 698,0,523,440, 349,0,440,523, 587,698,659,523,
|
|
|
|
|
294,0,349,440, 587,0,440,349, 587,698,880,1047, 880,698,587,440,
|
|
|
|
|
330,0,440,523, 659,0,784,880, 1047,0,880,784, 659,523,440,330,
|
|
|
|
|
880,1047,1319,1760, 1319,0,1047,880, 784,659,880,1047, 1319,1047,880,659,
|
|
|
|
|
1047,1319,1568,1319, 1047,784,659,784, 1047,1319,1568,2093, 1568,1319,1047,784,
|
|
|
|
|
698,880,1047,1397, 1760,0,1397,1047, 880,698,880,1047, 1397,1047,880,698,
|
|
|
|
|
659,784,880,1047, 880,784,659,523, 440,523,659,880, 659,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
440,523,659,523, 440,523,659,880, 659,523,440,523, 659,880,659,523,
|
|
|
|
|
349,440,523,440, 349,440,523,698, 523,440,349,440, 523,698,523,440,
|
|
|
|
|
294,349,440,349, 294,349,440,587, 440,349,294,349, 440,587,440,349,
|
|
|
|
|
330,415,523,415, 330,415,523,659, 523,415,330,415, 523,659,523,415,
|
|
|
|
|
440,659,880,659, 440,523,659,880, 1047,880,659,523, 440,659,880,1047,
|
|
|
|
|
523,784,1047,784, 523,659,784,1047, 1319,1047,784,659, 523,784,1047,1319,
|
|
|
|
|
349,523,698,523, 349,440,523,698, 880,698,523,440, 349,523,698,880,
|
|
|
|
|
330,523,659,523, 330,415,523,659, 880,659,523,415, 440,523,659,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[220,262,330],[175,220,262],[147,175,220],[165,208,247],[220,262,330],[131,165,196],[175,220,262],[165,208,247]],
|
2026-03-06 22:13:19 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,0,3,4, 1,0,3,0, 2,3,4,3,
|
|
|
|
|
1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,0,
|
|
|
|
|
1,3,1,3, 2,0,3,0, 1,3,1,3, 2,3,4,3,
|
|
|
|
|
1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,1,3,
|
|
|
|
|
1,3,1,3, 2,3,4,3, 1,1,2,1, 2,2,2,2,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 2: "Vampire's Requiem" — 190 BPM, D minor, Castlevania gothic
|
2026-03-06 22:13:19 +00:00
|
|
|
const track2: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: "Vampire's Requiem", bpm: 190, synthStyle: 'gothic',
|
2026-03-06 22:13:19 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
147,0,294,147, 147,0,294,0, 147,0,294,147, 175,220,294,220,
|
|
|
|
|
117,0,233,117, 117,0,233,0, 117,0,233,117, 147,175,233,175,
|
|
|
|
|
98,0,196,98, 98,0,196,0, 98,0,196,98, 131,147,196,147,
|
|
|
|
|
110,0,220,110, 110,0,220,0, 110,0,220,110, 139,165,220,165,
|
|
|
|
|
147,294,147,294, 147,294,175,220, 147,294,147,294, 175,220,294,349,
|
|
|
|
|
117,233,117,233, 117,233,175,233, 175,349,175,349, 175,349,220,262,
|
|
|
|
|
98,196,98,196, 131,196,262,196, 98,196,98,196, 131,196,262,330,
|
|
|
|
|
110,220,110,220, 139,220,277,220, 110,0,220,0, 110,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
587,0,698,880, 1047,0,880,698, 587,0,440,349, 440,587,698,880,
|
|
|
|
|
466,0,587,698, 932,0,698,587, 466,0,349,233, 349,466,587,698,
|
|
|
|
|
392,0,494,587, 784,0,587,494, 392,0,330,262, 330,392,494,587,
|
|
|
|
|
440,0,554,698, 880,0,698,554, 440,330,220,330, 440,554,698,880,
|
|
|
|
|
1175,0,1047,880, 698,587,440,587, 698,880,1047,1175, 1397,1175,1047,880,
|
|
|
|
|
932,0,880,698, 587,466,349,466, 587,698,880,932, 1047,932,880,698,
|
|
|
|
|
784,0,698,587, 494,392,330,392, 494,587,698,784, 880,784,698,587,
|
|
|
|
|
880,698,554,440, 554,440,330,220, 294,349,440,587, 698,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
294,349,440,349, 294,349,440,587, 440,349,294,349, 440,587,440,349,
|
|
|
|
|
233,294,349,294, 233,294,349,466, 349,294,233,294, 349,466,349,294,
|
|
|
|
|
196,262,330,262, 196,262,330,392, 330,262,196,262, 330,392,330,262,
|
|
|
|
|
220,277,349,277, 220,277,349,440, 349,277,220,277, 349,440,349,277,
|
|
|
|
|
294,440,587,440, 294,349,440,587, 698,587,440,349, 294,440,587,698,
|
|
|
|
|
233,349,466,349, 233,294,349,466, 587,466,349,294, 233,349,466,587,
|
|
|
|
|
196,330,392,330, 196,262,330,392, 494,392,330,262, 196,330,392,494,
|
|
|
|
|
220,349,440,349, 220,277,349,440, 554,440,349,277, 220,349,440,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[147,175,220],[233,294,349],[196,233,294],[220,277,330],[147,175,220],[175,220,262],[233,294,349],[220,277,330]],
|
2026-03-06 22:13:19 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,0,3, 2,0,0,3, 1,0,0,3, 2,0,0,3,
|
|
|
|
|
1,0,0,3, 2,0,0,3, 1,0,0,3, 2,0,3,4,
|
|
|
|
|
1,0,3,3, 2,0,0,3, 1,0,3,3, 2,0,0,3,
|
|
|
|
|
1,0,3,3, 2,0,0,3, 1,0,3,3, 2,0,3,4,
|
|
|
|
|
1,3,0,3, 2,0,3,3, 1,3,0,3, 2,0,3,3,
|
|
|
|
|
1,3,0,3, 2,3,0,3, 1,3,0,3, 2,3,4,3,
|
|
|
|
|
1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,1,3,
|
|
|
|
|
1,1,2,1, 2,1,2,1, 2,2,2,2, 2,2,2,2,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 3: "Street Heat" — 175 BPM, E minor, Streets of Rage funky
|
2026-03-06 22:13:19 +00:00
|
|
|
const track3: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Street Heat', bpm: 175, synthStyle: 'funky',
|
2026-03-06 22:13:19 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
82,0,0,165, 0,0,82,0, 165,0,0,196, 0,165,0,82,
|
|
|
|
|
110,0,0,220, 0,0,110,0, 220,0,0,262, 0,220,0,110,
|
|
|
|
|
131,0,0,262, 0,0,131,0, 262,0,0,330, 0,262,0,131,
|
|
|
|
|
123,0,0,247, 0,0,123,0, 247,0,0,294, 0,247,0,123,
|
|
|
|
|
82,165,0,82, 0,165,82,0, 165,82,0,196, 165,0,82,165,
|
|
|
|
|
110,220,0,110, 0,220,110,0, 220,110,0,262, 220,0,110,220,
|
|
|
|
|
131,262,0,131, 0,262,131,0, 262,131,0,330, 262,0,131,262,
|
|
|
|
|
123,247,0,123, 0,247,123,0, 247,0,0,0, 123,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
659,0,0,784, 0,0,659,0, 0,880,0,784, 0,659,0,0,
|
|
|
|
|
880,0,0,1047, 0,0,880,0, 0,1175,0,1047, 0,880,0,0,
|
|
|
|
|
1047,0,0,1175, 0,0,1319,0, 0,1175,0,1047, 0,880,784,0,
|
|
|
|
|
988,0,0,880, 0,0,784,0, 0,659,0,784, 880,0,784,0,
|
|
|
|
|
659,784,0,880, 1047,0,880,0, 659,0,784,880, 1047,0,880,659,
|
|
|
|
|
880,1047,0,1175, 1319,0,1175,0, 880,0,1047,1175, 1319,0,1175,880,
|
|
|
|
|
1047,1319,0,1568, 1760,0,1568,0, 1319,0,1047,880, 784,0,659,0,
|
|
|
|
|
880,659,0,784, 659,0,523,0, 494,0,659,0, 784,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
330,0,392,0, 494,0,392,0, 330,0,494,0, 659,0,494,0,
|
|
|
|
|
440,0,523,0, 659,0,523,0, 440,0,659,0, 880,0,659,0,
|
|
|
|
|
523,0,659,0, 784,0,659,0, 523,0,784,0, 1047,0,784,0,
|
|
|
|
|
494,0,587,0, 740,0,587,0, 494,0,740,0, 988,0,740,0,
|
|
|
|
|
330,494,659,494, 330,392,494,659, 784,659,494,392, 330,494,659,784,
|
|
|
|
|
440,659,880,659, 440,523,659,880, 1047,880,659,523, 440,659,880,1047,
|
|
|
|
|
523,784,1047,784, 523,659,784,1047, 1319,1047,784,659, 523,784,1047,1319,
|
|
|
|
|
494,740,988,740, 494,587,740,988, 740,0,0,0, 494,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[165,196,247],[220,262,330],[262,330,392],[247,294,370],[165,196,247],[196,247,294],[220,262,330],[247,294,370]],
|
2026-03-06 22:13:19 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,0,0, 2,0,0,3, 0,0,1,0, 2,0,3,0,
|
|
|
|
|
1,0,0,0, 2,0,0,3, 0,0,1,0, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,0,0,3, 0,3,1,0, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,3,0,3, 0,3,1,0, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,3,0,3, 1,3,1,0, 2,0,3,0,
|
|
|
|
|
1,3,3,0, 2,3,0,3, 1,3,1,0, 2,3,3,4,
|
|
|
|
|
1,3,3,3, 2,3,0,3, 1,3,3,3, 2,3,1,3,
|
|
|
|
|
1,1,2,1, 2,1,2,3, 1,1,2,2, 2,2,2,2,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 4: "Steel Resolve" — 210 BPM, C major, Mega Man heroic
|
2026-03-06 22:13:19 +00:00
|
|
|
const track4: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Steel Resolve', bpm: 210, synthStyle: 'heroic',
|
2026-03-06 22:13:19 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
131,0,262,131, 131,0,262,0, 131,0,196,0, 131,262,196,131,
|
|
|
|
|
196,0,392,196, 196,0,392,0, 196,0,262,0, 196,392,262,196,
|
|
|
|
|
220,0,440,220, 220,0,440,0, 220,0,330,0, 220,440,330,220,
|
|
|
|
|
175,0,349,175, 175,0,349,0, 175,0,262,0, 175,349,262,175,
|
|
|
|
|
131,262,131,262, 131,262,196,262, 196,392,196,392, 196,392,262,392,
|
|
|
|
|
220,440,220,440, 220,440,330,440, 175,349,175,349, 175,349,262,349,
|
|
|
|
|
131,196,262,330, 262,196,131,196, 196,262,330,392, 330,262,196,131,
|
|
|
|
|
175,262,349,440, 349,262,175,131, 131,262,0,0, 131,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
523,659,784,1047, 784,659,523,659, 784,1047,1319,1047, 784,659,523,392,
|
|
|
|
|
784,1047,1175,1319, 1175,1047,784,659, 523,659,784,1047, 1175,1047,784,659,
|
|
|
|
|
880,1047,1175,1319, 1175,1047,880,784, 659,784,880,1047, 1175,1047,880,659,
|
|
|
|
|
698,880,1047,1175, 1047,880,698,523, 440,523,698,880, 1047,880,698,523,
|
|
|
|
|
1047,1319,1568,1319, 1047,784,659,784, 1047,1319,1568,2093, 1568,1319,1047,784,
|
|
|
|
|
1175,1319,1568,1760, 1568,1319,1175,880, 659,880,1175,1319, 1568,1319,1175,880,
|
|
|
|
|
880,1047,1175,1319, 1175,1047,880,784, 659,784,880,1047, 1175,1047,880,784,
|
|
|
|
|
698,880,1047,1175, 1047,880,698,523, 523,659,784,1047, 784,0,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
262,330,392,330, 262,330,392,523, 392,330,262,330, 392,523,392,330,
|
|
|
|
|
392,494,587,494, 392,494,587,784, 587,494,392,494, 587,784,587,494,
|
|
|
|
|
440,523,659,523, 440,523,659,880, 659,523,440,523, 659,880,659,523,
|
|
|
|
|
349,440,523,440, 349,440,523,698, 523,440,349,440, 523,698,523,440,
|
|
|
|
|
262,392,523,784, 523,392,262,392, 523,784,1047,784, 523,392,262,523,
|
|
|
|
|
392,587,784,1047, 784,587,392,587, 784,1047,1319,1047, 784,587,392,784,
|
|
|
|
|
440,659,880,659, 440,523,659,880, 1047,880,659,523, 440,659,880,1047,
|
|
|
|
|
349,523,698,523, 349,440,523,698, 880,698,523,0, 262,523,0,0,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[262,330,392],[196,247,294],[220,262,330],[175,220,262],[262,330,392],[165,196,247],[175,220,262],[196,247,294]],
|
2026-03-06 22:13:19 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,3,3,0, 2,0,3,4,
|
|
|
|
|
1,3,3,0, 2,0,3,0, 1,3,3,0, 2,0,3,0,
|
|
|
|
|
1,3,3,0, 2,3,3,0, 1,3,3,0, 2,3,3,4,
|
|
|
|
|
1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,4,
|
|
|
|
|
1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3,
|
|
|
|
|
1,3,1,3, 2,3,1,3, 1,1,1,3, 2,1,1,3,
|
|
|
|
|
1,1,1,1, 2,1,2,1, 2,2,2,1, 2,2,2,2,
|
2026-03-06 22:13:19 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 5: "Thunder Blade" — 230 BPM, B minor, Thunder Force metal shred
|
2026-03-08 16:23:47 +00:00
|
|
|
const track5: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Thunder Blade', bpm: 230, synthStyle: 'metal',
|
2026-03-08 16:23:47 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
123,123,247,123, 123,123,247,123, 123,123,247,0, 185,247,185,123,
|
|
|
|
|
98,98,196,98, 98,98,196,98, 98,98,196,0, 147,196,147,98,
|
|
|
|
|
82,82,165,82, 82,82,165,82, 82,82,165,0, 131,165,131,82,
|
|
|
|
|
92,92,185,92, 92,92,185,92, 92,92,185,0, 139,185,139,92,
|
|
|
|
|
123,247,123,247, 123,247,185,247, 123,247,123,247, 185,247,294,370,
|
|
|
|
|
98,196,98,196, 98,196,147,196, 98,196,98,196, 147,196,247,294,
|
|
|
|
|
82,165,82,165, 82,165,131,165, 82,165,82,165, 131,165,196,247,
|
|
|
|
|
92,185,92,185, 92,185,139,185, 92,185,247,370, 247,185,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 16:23:47 +00:00
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
988,0,988,1175, 1480,0,1175,988, 740,0,740,988, 1175,0,988,740,
|
|
|
|
|
784,0,784,988, 1175,0,988,784, 587,0,587,784, 988,0,784,587,
|
|
|
|
|
659,0,659,784, 988,0,784,659, 494,0,494,659, 784,0,659,494,
|
|
|
|
|
740,0,740,880, 1109,0,880,740, 554,0,554,740, 880,0,740,554,
|
|
|
|
|
988,1175,1480,1976, 1480,1175,988,1175, 1480,1976,1480,1175, 988,740,988,1175,
|
|
|
|
|
784,988,1175,1568, 1175,988,784,988, 1175,1568,1175,988, 784,587,784,988,
|
|
|
|
|
659,784,988,1319, 988,784,659,784, 988,1319,988,784, 659,494,659,784,
|
|
|
|
|
1480,1175,988,740, 988,740,554,440, 554,740,988,1175, 1480,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 16:23:47 +00:00
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
247,370,494,370, 247,370,494,740, 494,370,247,370, 494,740,494,370,
|
|
|
|
|
196,294,392,294, 196,294,392,587, 392,294,196,294, 392,587,392,294,
|
|
|
|
|
165,247,330,247, 165,247,330,494, 330,247,165,247, 330,494,330,247,
|
|
|
|
|
185,277,370,277, 185,277,370,554, 370,277,185,277, 370,554,370,277,
|
|
|
|
|
247,494,740,494, 247,370,494,740, 988,740,494,370, 247,494,740,988,
|
|
|
|
|
196,392,587,392, 196,294,392,587, 784,587,392,294, 196,392,587,784,
|
|
|
|
|
165,330,494,330, 165,247,330,494, 659,494,330,247, 165,330,494,659,
|
|
|
|
|
185,370,554,370, 185,277,370,554, 740,554,370,0, 247,370,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[247,294,370],[196,247,294],[165,196,247],[185,220,277],[247,294,370],[220,262,330],[196,247,294],[185,220,277]],
|
2026-03-08 16:23:47 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1,
|
|
|
|
|
1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1,
|
|
|
|
|
1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,4,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1,
|
|
|
|
|
1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,4,1,
|
|
|
|
|
1,1,2,1, 2,1,2,1, 2,2,2,2, 2,2,2,2,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 6: "Crystal Elegy" — 150 BPM, F major, JRPG emotional
|
2026-03-08 16:23:47 +00:00
|
|
|
const track6: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Crystal Elegy', bpm: 150, synthStyle: 'emotional',
|
2026-03-08 16:23:47 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
87,0,175,0, 87,0,175,0, 87,0,175,0, 131,0,175,0,
|
|
|
|
|
73,0,147,0, 73,0,147,0, 73,0,147,0, 110,0,147,0,
|
|
|
|
|
117,0,233,0, 117,0,233,0, 117,0,233,0, 175,0,233,0,
|
|
|
|
|
131,0,262,0, 131,0,262,0, 131,0,262,0, 196,0,262,0,
|
|
|
|
|
87,175,87,175, 87,131,175,262, 175,131,87,131, 175,262,175,131,
|
|
|
|
|
73,147,73,147, 73,110,147,220, 147,110,73,110, 147,220,147,110,
|
|
|
|
|
117,233,117,233, 117,175,233,349, 233,175,117,175, 233,349,233,175,
|
|
|
|
|
131,262,131,262, 131,196,262,330, 262,196,131,0, 131,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 16:23:47 +00:00
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
698,0,0,880, 1047,0,0,880, 698,0,0,523, 440,0,523,698,
|
|
|
|
|
587,0,0,698, 880,0,0,698, 587,0,0,440, 349,0,440,587,
|
|
|
|
|
466,0,0,587, 698,0,0,587, 466,0,0,349, 233,0,349,466,
|
|
|
|
|
523,0,0,659, 784,0,0,659, 523,0,0,440, 330,0,440,523,
|
|
|
|
|
1397,0,1175,1047, 880,0,698,523, 440,523,698,880, 1047,880,698,523,
|
|
|
|
|
1175,0,1047,880, 698,0,587,440, 349,440,587,698, 880,698,587,440,
|
|
|
|
|
932,0,880,698, 587,0,466,349, 233,349,466,587, 698,587,466,349,
|
|
|
|
|
1047,0,880,784, 659,523,440,349, 440,523,698,880, 1047,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 16:23:47 +00:00
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
175,220,262,220, 175,220,262,349, 262,220,175,220, 262,349,262,220,
|
|
|
|
|
147,175,220,175, 147,175,220,294, 220,175,147,175, 220,294,220,175,
|
|
|
|
|
233,294,349,294, 233,294,349,466, 349,294,233,294, 349,466,349,294,
|
|
|
|
|
262,330,392,330, 262,330,392,523, 392,330,262,330, 392,523,392,330,
|
|
|
|
|
175,262,349,523, 349,262,175,262, 349,523,698,523, 349,262,175,349,
|
|
|
|
|
147,220,294,440, 294,220,147,220, 294,440,587,440, 294,220,147,294,
|
|
|
|
|
233,349,466,698, 466,349,233,349, 466,698,932,698, 466,349,233,466,
|
|
|
|
|
262,392,523,784, 523,392,262,392, 523,784,0,0, 262,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[175,220,262],[147,175,220],[233,294,349],[262,330,392],[175,220,262],[220,262,330],[233,294,349],[262,330,392]],
|
2026-03-08 16:23:47 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,0,0, 0,0,2,0, 0,0,0,0, 0,0,3,0,
|
|
|
|
|
1,0,0,0, 0,0,2,0, 0,0,0,0, 0,0,3,0,
|
|
|
|
|
1,0,0,3, 0,0,2,0, 0,3,0,0, 0,0,3,0,
|
|
|
|
|
1,0,0,3, 0,0,2,0, 0,3,0,0, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,0,0,3, 1,0,3,0, 2,0,0,3,
|
|
|
|
|
1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,3,3,0, 1,0,0,0, 0,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 7: "Shadow Assault" — 215 BPM, G minor, Ninja Gaiden action
|
2026-03-08 16:23:47 +00:00
|
|
|
const track7: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Shadow Assault', bpm: 215, synthStyle: 'ninja',
|
2026-03-08 16:23:47 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
98,0,196,98, 98,0,196,0, 98,0,196,98, 131,156,196,156,
|
|
|
|
|
156,0,311,156, 156,0,311,0, 156,0,311,156, 196,233,311,233,
|
|
|
|
|
131,0,262,131, 131,0,262,0, 131,0,262,131, 175,196,262,196,
|
|
|
|
|
147,0,294,147, 147,0,294,0, 147,0,294,147, 185,220,294,220,
|
|
|
|
|
98,196,98,196, 156,196,131,196, 98,196,98,196, 156,196,262,330,
|
|
|
|
|
156,311,156,311, 196,311,233,311, 156,311,156,311, 196,311,392,466,
|
|
|
|
|
131,262,131,262, 175,262,196,262, 131,262,131,262, 175,262,330,392,
|
|
|
|
|
147,294,147,294, 185,294,220,294, 147,294,0,0, 147,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 16:23:47 +00:00
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
784,0,932,784, 0,784,932,1175, 1568,0,1175,932, 784,0,659,784,
|
|
|
|
|
622,0,784,622, 0,622,784,988, 1175,0,988,784, 622,0,523,622,
|
|
|
|
|
523,0,659,523, 0,523,659,784, 1047,0,784,659, 523,0,440,523,
|
|
|
|
|
587,0,740,587, 0,587,740,880, 1175,0,880,740, 587,0,494,587,
|
|
|
|
|
784,932,1175,1568, 1175,932,784,932, 1175,1568,1175,932, 784,659,784,932,
|
|
|
|
|
622,784,988,1319, 988,784,622,784, 988,1319,988,784, 622,523,622,784,
|
|
|
|
|
1047,1175,1568,1865, 1568,1175,1047,1175, 1568,1865,1568,1175, 1047,784,659,523,
|
|
|
|
|
587,740,880,1175, 880,740,587,440, 587,0,784,0, 932,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 16:23:47 +00:00
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
196,233,294,233, 196,233,294,392, 294,233,196,233, 294,392,294,233,
|
|
|
|
|
311,392,466,392, 311,392,466,622, 466,392,311,392, 466,622,466,392,
|
|
|
|
|
262,330,392,330, 262,330,392,523, 392,330,262,330, 392,523,392,330,
|
|
|
|
|
294,370,440,370, 294,370,440,587, 440,370,294,370, 440,587,440,370,
|
|
|
|
|
196,294,392,587, 392,294,196,294, 392,587,784,587, 392,294,196,392,
|
|
|
|
|
311,466,622,932, 622,466,311,466, 622,932,1175,932, 622,466,311,622,
|
|
|
|
|
262,392,523,784, 523,392,262,392, 523,784,1047,784, 523,392,262,523,
|
|
|
|
|
294,440,587,880, 587,440,294,440, 587,0,0,0, 294,0,0,0,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[196,233,294],[311,392,466],[262,311,392],[294,370,440],[196,233,294],[233,294,349],[311,392,466],[294,370,440]],
|
2026-03-08 16:23:47 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,4,
|
|
|
|
|
1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,3,0,
|
|
|
|
|
1,3,0,3, 2,3,0,3, 1,3,0,3, 2,3,4,3,
|
|
|
|
|
1,3,1,3, 2,0,3,0, 1,3,1,3, 2,0,3,4,
|
|
|
|
|
1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3,
|
|
|
|
|
1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1,
|
|
|
|
|
1,1,1,1, 2,1,2,1, 2,2,2,1, 2,2,2,2,
|
2026-03-07 11:42:32 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 8: "Emerald Rush" — 195 BPM, G major, Sonic-style funky speed
|
2026-03-08 16:23:47 +00:00
|
|
|
const track8: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Emerald Rush', bpm: 195, synthStyle: 'speed',
|
2026-03-08 16:23:47 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
98,0,0,196, 0,0,98,0, 196,0,0,98, 0,196,0,98,
|
|
|
|
|
82,0,0,165, 0,0,82,0, 165,0,0,82, 0,165,0,82,
|
|
|
|
|
131,0,0,262, 0,0,131,0, 262,0,0,131, 0,262,0,131,
|
|
|
|
|
73,0,0,147, 0,0,73,0, 147,0,0,73, 0,147,0,73,
|
|
|
|
|
98,196,0,98, 196,0,98,196, 0,196,98,0, 196,98,196,262,
|
|
|
|
|
82,165,0,82, 165,0,82,165, 0,165,82,0, 165,82,165,247,
|
|
|
|
|
131,262,0,131, 262,0,131,262, 0,262,131,0, 262,131,262,330,
|
|
|
|
|
147,294,0,147, 294,0,147,294, 147,0,0,0, 98,0,0,0,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
|
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
784,0,880,1047, 1175,0,1047,880, 784,0,659,523, 659,784,880,1047,
|
|
|
|
|
659,0,784,880, 1047,0,880,784, 659,0,523,440, 523,659,784,880,
|
|
|
|
|
1047,0,1175,1319, 1568,0,1319,1175, 1047,0,880,784, 880,1047,1175,1319,
|
|
|
|
|
587,0,659,784, 880,0,784,659, 587,0,523,440, 523,587,659,784,
|
|
|
|
|
1175,1319,1568,1760, 1568,1319,1175,1047, 880,784,880,1047, 1175,1047,880,784,
|
|
|
|
|
1047,1175,1319,1568, 1319,1175,1047,880, 784,659,784,880, 1047,880,784,659,
|
|
|
|
|
1568,1760,2093,1760, 1568,1319,1175,1047, 880,784,659,523, 659,784,880,1047,
|
|
|
|
|
880,784,659,587, 523,440,392,330, 392,440,523,659, 784,0,0,0,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
|
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
392,494,587,494, 392,494,587,784, 587,494,392,494, 587,784,587,494,
|
|
|
|
|
330,392,494,392, 330,392,494,659, 494,392,330,392, 494,659,494,392,
|
|
|
|
|
523,659,784,659, 523,659,784,1047, 784,659,523,659, 784,1047,784,659,
|
|
|
|
|
294,370,440,370, 294,370,440,587, 440,370,294,370, 440,587,440,370,
|
|
|
|
|
392,587,784,587, 392,494,587,784, 1047,784,587,494, 392,587,784,1047,
|
|
|
|
|
330,494,659,494, 330,392,494,659, 880,659,494,392, 330,494,659,880,
|
|
|
|
|
523,784,1047,784, 523,659,784,1047, 1319,1047,784,659, 523,784,1047,1319,
|
|
|
|
|
294,440,587,440, 294,370,440,587, 784,587,0,0, 392,0,0,0,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[196,247,294],[165,196,247],[262,330,392],[147,175,220],[196,247,294],[123,147,185],[262,330,392],[147,175,220]],
|
2026-03-08 16:23:47 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,0,3, 2,0,0,3, 0,0,1,0, 2,0,3,0,
|
|
|
|
|
1,0,0,3, 2,0,0,3, 0,3,1,0, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,0,0,3, 1,0,3,0, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,3,0,3, 1,0,3,0, 2,3,3,4,
|
|
|
|
|
1,3,3,0, 2,0,3,3, 1,3,3,0, 2,0,3,4,
|
|
|
|
|
1,3,3,0, 2,3,3,3, 1,3,3,0, 2,3,3,4,
|
|
|
|
|
1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3,
|
|
|
|
|
1,1,1,3, 2,1,2,1, 2,1,2,1, 2,2,2,2,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
2026-03-07 11:42:32 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:10:59 +00:00
|
|
|
// Track 9: "Alien Abyss" — 140 BPM, E minor, Metroid atmospheric tension
|
2026-03-08 16:23:47 +00:00
|
|
|
const track9: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Alien Abyss', bpm: 140, synthStyle: 'atmospheric',
|
2026-03-08 16:23:47 +00:00
|
|
|
bass: [
|
2026-03-08 17:10:59 +00:00
|
|
|
82,0,0,0, 82,0,0,0, 165,0,0,0, 82,0,165,0,
|
|
|
|
|
131,0,0,0, 131,0,0,0, 262,0,0,0, 131,0,262,0,
|
|
|
|
|
110,0,0,0, 110,0,0,0, 220,0,0,0, 110,0,220,0,
|
|
|
|
|
123,0,0,0, 123,0,0,0, 247,0,0,0, 123,0,247,0,
|
|
|
|
|
82,0,165,0, 0,0,82,0, 165,0,0,0, 82,0,165,82,
|
|
|
|
|
131,0,262,0, 0,0,131,0, 262,0,0,0, 131,0,262,131,
|
|
|
|
|
110,0,220,0, 0,0,110,0, 220,0,0,0, 110,0,220,110,
|
|
|
|
|
123,0,247,0, 123,0,0,0, 123,0,0,0, 82,0,0,0,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
|
|
|
|
lead: [
|
2026-03-08 17:10:59 +00:00
|
|
|
494,0,0,659, 0,0,784,0, 0,0,659,0, 0,494,0,0,
|
|
|
|
|
523,0,0,622, 0,0,784,0, 0,0,622,0, 0,523,0,0,
|
|
|
|
|
440,0,0,523, 0,0,659,0, 0,0,523,0, 0,440,659,0,
|
|
|
|
|
494,0,0,587, 0,0,740,0, 0,988,0,0, 740,0,587,0,
|
|
|
|
|
659,0,784,0, 988,0,1319,0, 1568,0,1319,0, 988,784,659,494,
|
|
|
|
|
523,0,659,0, 784,0,1047,0, 1319,0,1047,0, 784,659,523,392,
|
|
|
|
|
440,0,523,0, 659,0,880,0, 1047,0,880,0, 659,523,440,330,
|
|
|
|
|
494,0,587,0, 740,0,988,0, 740,0,587,0, 494,0,0,0,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
|
|
|
|
arp: [
|
2026-03-08 17:10:59 +00:00
|
|
|
330,392,494,0, 392,494,0,0, 330,392,494,0, 392,0,0,0,
|
|
|
|
|
262,330,392,0, 330,392,0,0, 262,330,392,0, 330,0,0,0,
|
|
|
|
|
220,262,330,0, 262,330,0,0, 220,262,330,0, 262,330,0,0,
|
|
|
|
|
247,294,370,0, 294,370,494,0, 247,294,370,494, 294,370,494,0,
|
|
|
|
|
330,494,659,494, 330,392,494,659, 784,659,494,392, 330,494,392,330,
|
|
|
|
|
262,392,523,392, 262,330,392,523, 659,523,392,330, 262,392,330,262,
|
|
|
|
|
220,330,440,330, 220,262,330,440, 523,440,330,262, 220,330,262,220,
|
|
|
|
|
247,370,494,370, 247,294,370,494, 587,494,370,0, 0,0,0,0,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
2026-03-08 17:10:59 +00:00
|
|
|
chords: [[165,196,247],[262,330,392],[220,262,330],[247,294,370],[165,196,247],[196,247,294],[220,262,330],[247,294,370]],
|
2026-03-08 16:23:47 +00:00
|
|
|
drums: [
|
2026-03-08 17:10:59 +00:00
|
|
|
1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,0,0,
|
|
|
|
|
1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0,
|
|
|
|
|
1,0,0,0, 0,0,3,0, 2,0,0,0, 0,0,3,0,
|
|
|
|
|
1,0,0,3, 0,0,3,0, 2,0,3,0, 0,0,3,4,
|
|
|
|
|
1,0,0,3, 2,0,3,0, 1,0,0,3, 2,0,3,0,
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,0,3,3, 1,0,3,3, 2,0,3,4,
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,0,0,0, 0,0,0,0,
|
2026-03-08 16:23:47 +00:00
|
|
|
],
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:23:47 +00:00
|
|
|
// Track 10: "Halving Day" — Cm, 162 BPM — Triumphant climax
|
|
|
|
|
const track10: MusicTrack = {
|
2026-03-08 17:10:59 +00:00
|
|
|
name: 'Halving Day', bpm: 162, synthStyle: 'heroic',
|
2026-03-08 16:23:47 +00:00
|
|
|
bass: [
|
|
|
|
|
131,0,262,0, 131,0,196,0, 131,0,262,0, 131,196,262,196,
|
|
|
|
|
156,0,311,0, 156,0,233,0, 156,0,311,0, 156,233,311,233,
|
|
|
|
|
117,0,233,0, 117,0,175,0, 117,0,233,0, 117,175,233,175,
|
|
|
|
|
98,0,196,0, 98,0,147,0, 98,0,196,0, 98,147,196,147,
|
|
|
|
|
131,0,262,0, 131,0,196,0, 131,0,262,196, 131,196,262,196,
|
|
|
|
|
104,0,208,0, 104,0,156,0, 104,0,208,0, 104,156,208,156,
|
|
|
|
|
87,0,175,0, 87,0,131,0, 87,0,175,131, 87,131,175,131,
|
|
|
|
|
98,0,196,0, 98,0,147,0, 98,0,196,0, 98,0,0,0,
|
|
|
|
|
],
|
|
|
|
|
lead: [
|
|
|
|
|
523,0,622,784, 1047,0,784,622, 523,0,622,784, 1047,1245,1568,1245,
|
|
|
|
|
622,0,784,932, 1245,0,932,784, 622,784,932,1245, 1568,1245,932,784,
|
|
|
|
|
932,0,1175,1397, 932,0,698,587, 932,1175,1397,1865, 1175,932,698,587,
|
|
|
|
|
784,0,988,1175, 1568,0,1175,988, 784,988,1175,1568, 1976,1568,1175,988,
|
|
|
|
|
1245,1047,784,523, 622,784,1047,1245, 1568,0,1245,1047, 784,622,523,622,
|
|
|
|
|
831,0,1047,1245, 831,0,622,523, 831,1047,1245,1661, 1245,1047,831,622,
|
|
|
|
|
698,0,831,1047, 1397,0,1047,831, 698,831,1047,1397, 1661,1397,1047,831,
|
|
|
|
|
784,988,1175,1568, 1976,0,1568,1175, 988,784,587,784, 988,1175,1568,784,
|
|
|
|
|
],
|
|
|
|
|
arp: [
|
|
|
|
|
262,311,392,311, 262,311,392,523, 392,523,392,311, 262,392,311,262,
|
|
|
|
|
311,392,466,392, 311,392,466,622, 466,622,466,392, 311,466,392,311,
|
|
|
|
|
233,294,349,294, 233,294,349,466, 349,466,349,294, 233,349,294,233,
|
|
|
|
|
392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392,
|
|
|
|
|
262,311,392,523, 392,523,622,523, 392,311,262,311, 392,523,392,311,
|
|
|
|
|
208,262,311,262, 208,262,311,415, 311,415,311,262, 208,311,262,208,
|
|
|
|
|
349,415,523,415, 349,415,523,698, 523,698,523,415, 349,523,415,349,
|
|
|
|
|
392,494,587,494, 392,587,494,392, 587,494,392,294, 392,494,587,0,
|
|
|
|
|
],
|
2026-03-08 16:55:14 +00:00
|
|
|
// I-vi-ii-V in Eb major (triumphant jazz-pop)
|
|
|
|
|
chords: [[156,196,233],[131,156,196],[175,208,262],[233,294,349],[156,196,233],[131,156,196],[175,208,262],[233,294,349]],
|
2026-03-08 16:23:47 +00:00
|
|
|
drums: [
|
|
|
|
|
1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,4,0,
|
|
|
|
|
1,0,3,0, 2,0,3,3, 1,0,3,0, 2,3,3,0, 1,3,3,0, 2,3,3,3, 1,3,1,3, 2,3,4,3,
|
|
|
|
|
1,3,3,0, 2,0,3,0, 1,0,3,3, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,3,3,0, 2,3,4,0,
|
|
|
|
|
1,3,1,3, 2,3,3,0, 1,3,1,3, 2,3,1,3, 1,1,1,1, 2,2,2,2, 2,2,4,2, 1,0,0,0,
|
|
|
|
|
],
|
|
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
|
2026-03-08 16:23:47 +00:00
|
|
|
const ALL_TRACKS = [track1, track2, track3, track4, track5, track6, track7, track8, track9, track10]
|
2026-03-06 22:13:19 +00:00
|
|
|
let activeTrack: MusicTrack = track1
|
|
|
|
|
let barIndex = 0
|
|
|
|
|
|
2026-03-08 16:23:47 +00:00
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
function playMusicBar() {
|
|
|
|
|
if (!musicPlaying || !musicGain) return
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
getMusicDelay() // ensure delay is created
|
|
|
|
|
|
2026-03-08 16:55:14 +00:00
|
|
|
// Dynamic tempo: subtle intensity shift (+8 BPM at max)
|
|
|
|
|
const dynamicBpm = activeTrack.bpm + currentIntensity * 8
|
2026-03-06 22:13:19 +00:00
|
|
|
const beat = 60 / dynamicBpm
|
|
|
|
|
const now = c.currentTime + 0.05
|
|
|
|
|
const bar = barIndex % BARS
|
|
|
|
|
const off = bar * STEPS
|
|
|
|
|
const step = beat / 2
|
|
|
|
|
|
2026-03-08 16:55:14 +00:00
|
|
|
// === VERSE / CHORUS DYNAMICS ===
|
|
|
|
|
// Bars 0-3 are "verse" (sparser), bars 4-7 are "chorus" (climactic, full)
|
|
|
|
|
const isChorus = bar >= 4
|
|
|
|
|
const leadThreshold = isChorus ? 0.15 : 0.4
|
|
|
|
|
const arpThreshold = isChorus ? 0.25 : 0.55
|
|
|
|
|
|
|
|
|
|
// Switch tracks at musical boundaries — NEVER repeat the same track
|
2026-03-08 16:23:47 +00:00
|
|
|
const switchEvery = currentIntensity > 0.7 ? 8 : currentIntensity > 0.4 ? 16 : 24
|
2026-03-07 11:42:32 +00:00
|
|
|
if (barIndex > 0 && barIndex % switchEvery === 0) {
|
2026-03-08 00:09:46 +00:00
|
|
|
const prevTrack = activeTrack
|
2026-03-08 16:55:14 +00:00
|
|
|
// Always pick from ALL tracks, excluding current — guarantees no repeat
|
|
|
|
|
const others = ALL_TRACKS.filter(t => t !== activeTrack)
|
|
|
|
|
// Prefer tracks matching intensity mood
|
|
|
|
|
const chill = others.filter(t => t.bpm < 155)
|
|
|
|
|
const mid = others.filter(t => t.bpm >= 155 && t.bpm < 168)
|
|
|
|
|
const intense = others.filter(t => t.bpm >= 168)
|
|
|
|
|
if (currentIntensity > 0.7 && intense.length > 0) {
|
|
|
|
|
activeTrack = intense[Math.floor(Math.random() * intense.length)]
|
|
|
|
|
} else if (currentIntensity < 0.3 && chill.length > 0) {
|
|
|
|
|
activeTrack = chill[Math.floor(Math.random() * chill.length)]
|
|
|
|
|
} else if (mid.length > 0 && Math.random() < 0.5) {
|
|
|
|
|
activeTrack = mid[Math.floor(Math.random() * mid.length)]
|
2026-03-07 11:42:32 +00:00
|
|
|
} else {
|
2026-03-08 16:55:14 +00:00
|
|
|
activeTrack = others[Math.floor(Math.random() * others.length)]
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
2026-03-08 16:23:47 +00:00
|
|
|
// Smooth crossfade on track switch
|
2026-03-08 00:09:46 +00:00
|
|
|
if (activeTrack !== prevTrack && musicGain) {
|
|
|
|
|
const curVol = musicGain.gain.value
|
|
|
|
|
musicGain.gain.setValueAtTime(curVol, now)
|
2026-03-08 16:23:47 +00:00
|
|
|
musicGain.gain.linearRampToValueAtTime(curVol * 0.25, now + 0.08)
|
|
|
|
|
musicGain.gain.linearRampToValueAtTime(curVol, now + 0.4)
|
2026-03-08 00:09:46 +00:00
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:16:27 +00:00
|
|
|
// Chord pad for the whole bar — style-specific pad synth
|
2026-03-06 22:13:19 +00:00
|
|
|
const barDur = STEPS * step
|
2026-03-08 16:55:14 +00:00
|
|
|
const padFreqs = activeTrack.chords[bar].map(f => f * 2)
|
2026-03-08 17:16:27 +00:00
|
|
|
STYLE_PAD[activeTrack.synthStyle](padFreqs, barDur, musicGain, now)
|
2026-03-06 22:13:19 +00:00
|
|
|
|
|
|
|
|
for (let i = 0; i < STEPS; i++) {
|
|
|
|
|
const t = now + i * step
|
|
|
|
|
const idx = off + i
|
|
|
|
|
const nd = step - 0.01
|
|
|
|
|
|
2026-03-08 17:16:27 +00:00
|
|
|
// Layer 1: Bass — style-specific synth
|
|
|
|
|
if (activeTrack.bass[idx] > 0) STYLE_BASS[activeTrack.synthStyle](activeTrack.bass[idx], nd, musicGain, t)
|
2026-03-06 22:13:19 +00:00
|
|
|
|
2026-03-08 17:16:27 +00:00
|
|
|
// Layer 2: Lead — style-specific synth; verse: needs higher intensity; chorus: plays freely
|
2026-03-08 16:55:14 +00:00
|
|
|
if (activeTrack.lead[idx] > 0 && currentIntensity > leadThreshold) {
|
2026-03-08 17:16:27 +00:00
|
|
|
STYLE_LEAD[activeTrack.synthStyle](activeTrack.lead[idx], nd * 0.8, musicGain, t)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:16:27 +00:00
|
|
|
// Layer 3: Arpeggio — style-specific waveform; verse: needs high intensity; chorus: plays freely
|
2026-03-08 16:55:14 +00:00
|
|
|
if (activeTrack.arp[idx] > 0 && currentIntensity > arpThreshold) {
|
2026-03-08 17:16:27 +00:00
|
|
|
chorusTone(activeTrack.arp[idx], STYLE_ARP_TYPE[activeTrack.synthStyle], nd * 0.6, musicGain, t, 0.04 + currentIntensity * 0.04)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:55:14 +00:00
|
|
|
// Layer 4: Drums — intensity controls density
|
2026-03-06 22:13:19 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-03-08 16:55:14 +00:00
|
|
|
// Chorus: extra snare ghost notes on off-beats for energy
|
|
|
|
|
if (isChorus && currentIntensity > 0.6 && d === 0 && i % 4 === 2 && Math.random() < 0.3) {
|
|
|
|
|
snare(musicGain, t)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:23:47 +00:00
|
|
|
// Extra open hihat at high intensity (musical, not chaotic)
|
|
|
|
|
if (currentIntensity > 0.85 && i === 14 && Math.random() < 0.3) {
|
|
|
|
|
hihat(musicGain, t, true)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 11:59:10 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 10:33:30 +00:00
|
|
|
let _speechUnlocked = false
|
2026-03-07 22:46:47 +00:00
|
|
|
export async function ensureAudioContext() {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
if (c.state === 'suspended') {
|
|
|
|
|
try { await c.resume() } catch {}
|
|
|
|
|
}
|
2026-03-08 10:33:30 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-03-07 22:46:47 +00:00
|
|
|
}
|
2026-03-07 11:59:10 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// === CROWD SOUNDS ===
|
|
|
|
|
// Procedural crowd reactions using layered noise + filtered tones
|
|
|
|
|
|
|
|
|
|
export function sfxCrowdOoh() {
|
|
|
|
|
const c = getCtx()
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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()
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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()
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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()
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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()
|
2026-03-07 22:46:47 +00:00
|
|
|
const d = getSfxDest()
|
2026-03-06 22:13:19 +00:00
|
|
|
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)
|
|
|
|
|
}
|