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
|
|
|
|
|
|
|
|
|
|
function getCtx(): AudioContext {
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
ctx = new AudioContext()
|
|
|
|
|
musicGain = ctx.createGain()
|
|
|
|
|
musicGain.gain.value = 0.12
|
|
|
|
|
musicGain.connect(ctx.destination)
|
|
|
|
|
sfxGain = ctx.createGain()
|
|
|
|
|
sfxGain.gain.value = 0.25
|
|
|
|
|
sfxGain.connect(ctx.destination)
|
|
|
|
|
}
|
|
|
|
|
if (ctx.state === 'suspended') ctx.resume()
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tone(freq: number, type: OscillatorType, duration: number, dest: AudioNode, startTime?: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
osc.type = type
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const t = startTime ?? c.currentTime
|
|
|
|
|
g.gain.setValueAtTime(0.3, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
|
|
|
|
osc.connect(g)
|
|
|
|
|
g.connect(dest)
|
|
|
|
|
osc.start(t)
|
|
|
|
|
osc.stop(t + duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function noise(duration: number, dest: AudioNode, startTime?: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const bufferSize = Math.max(1, Math.floor(c.sampleRate * duration))
|
|
|
|
|
const buffer = c.createBuffer(1, bufferSize, c.sampleRate)
|
|
|
|
|
const data = buffer.getChannelData(0)
|
|
|
|
|
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource()
|
|
|
|
|
src.buffer = buffer
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
const t = startTime ?? c.currentTime
|
|
|
|
|
g.gain.setValueAtTime(0.4, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
|
|
|
|
const filter = c.createBiquadFilter()
|
|
|
|
|
filter.type = 'highpass'
|
|
|
|
|
filter.frequency.value = 800
|
|
|
|
|
src.connect(filter)
|
|
|
|
|
filter.connect(g)
|
|
|
|
|
g.connect(dest)
|
|
|
|
|
src.start(t)
|
|
|
|
|
src.stop(t + duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sweep(startFreq: number, endFreq: number, type: OscillatorType, duration: number, dest: AudioNode) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
osc.type = type
|
|
|
|
|
osc.frequency.setValueAtTime(startFreq, c.currentTime)
|
|
|
|
|
osc.frequency.exponentialRampToValueAtTime(Math.max(1, endFreq), c.currentTime + duration)
|
|
|
|
|
g.gain.setValueAtTime(0.3, c.currentTime)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + duration)
|
|
|
|
|
osc.connect(g)
|
|
|
|
|
g.connect(dest)
|
|
|
|
|
osc.start()
|
|
|
|
|
osc.stop(c.currentTime + duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === ANNOUNCER VOICE SYSTEM (Speech Synthesis with multiple voice types) ===
|
|
|
|
|
|
|
|
|
|
interface VoiceProfile {
|
|
|
|
|
voice: SpeechSynthesisVoice | null
|
|
|
|
|
pitch: number
|
|
|
|
|
rate: number
|
|
|
|
|
volume: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let voicesLoaded = false
|
|
|
|
|
const voiceProfiles: Record<string, VoiceProfile> = {
|
|
|
|
|
// Original 6
|
|
|
|
|
announcer: { voice: null, pitch: 1.0, rate: 0.8, volume: 1.0 }, // Natural, authoritative announcer
|
|
|
|
|
hype: { voice: null, pitch: 1.1, rate: 1.4, volume: 1.0 }, // Fast excited commentator
|
|
|
|
|
deep: { voice: null, pitch: 0.7, rate: 0.7, volume: 1.0 }, // Lower but still clear
|
|
|
|
|
robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone
|
|
|
|
|
screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, // Frantic energy
|
|
|
|
|
smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, // Natural narrator
|
|
|
|
|
// 24 new profiles
|
|
|
|
|
whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper
|
|
|
|
|
boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming
|
|
|
|
|
chipmunk: { voice: null, pitch: 2.0, rate: 1.8, volume: 0.9 }, // Tiny squeaky fast
|
|
|
|
|
drill: { voice: null, pitch: 0.6, rate: 1.2, volume: 1.0 }, // Drill sergeant bark
|
|
|
|
|
surfer: { voice: null, pitch: 1.1, rate: 1.0, volume: 0.8 }, // Laid back dude
|
|
|
|
|
auctioneer: { voice: null, pitch: 1.0, rate: 2.0, volume: 1.0 }, // Lightning fast
|
|
|
|
|
preacher: { voice: null, pitch: 0.8, rate: 0.6, volume: 1.0 }, // Dramatic pause king
|
|
|
|
|
baby: { voice: null, pitch: 1.8, rate: 1.0, volume: 0.7 }, // High pitched cute
|
|
|
|
|
grandpa: { voice: null, pitch: 0.5, rate: 0.5, volume: 0.8 }, // Slow, gravelly old man
|
|
|
|
|
valley: { voice: null, pitch: 1.4, rate: 1.3, volume: 0.9 }, // Valley girl energy
|
|
|
|
|
movie: { voice: null, pitch: 0.6, rate: 0.7, volume: 1.0 }, // Movie trailer bass
|
|
|
|
|
sportscaster:{ voice: null, pitch: 1.0, rate: 1.5, volume: 1.0 }, // Play-by-play energy
|
|
|
|
|
opera: { voice: null, pitch: 0.9, rate: 0.5, volume: 1.0 }, // Dramatic operatic
|
|
|
|
|
punk: { voice: null, pitch: 1.3, rate: 1.3, volume: 1.0 }, // Aggressive snarl
|
|
|
|
|
wizard_v: { voice: null, pitch: 0.7, rate: 0.8, volume: 0.8 }, // Mystical old sage
|
|
|
|
|
pirate_v: { voice: null, pitch: 0.8, rate: 0.9, volume: 1.0 }, // Arr matey
|
|
|
|
|
alien_v: { voice: null, pitch: 1.6, rate: 0.7, volume: 0.7 }, // Otherworldly slow
|
|
|
|
|
cowboy_v: { voice: null, pitch: 0.9, rate: 0.8, volume: 0.9 }, // Drawl
|
|
|
|
|
ninja_v: { voice: null, pitch: 1.1, rate: 1.1, volume: 0.5 }, // Quiet but deadly
|
|
|
|
|
demon_v: { voice: null, pitch: 0.3, rate: 0.6, volume: 1.0 }, // Deepest evil
|
|
|
|
|
angel: { voice: null, pitch: 1.5, rate: 0.8, volume: 0.7 }, // Ethereal high
|
|
|
|
|
glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, // Stuttery fast
|
|
|
|
|
echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, // Reverb cave voice
|
|
|
|
|
hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, // Maximum speed maximum hype
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadVoices() {
|
|
|
|
|
if (typeof speechSynthesis === 'undefined') return
|
|
|
|
|
const voices = speechSynthesis.getVoices()
|
|
|
|
|
if (voices.length === 0) return
|
|
|
|
|
voicesLoaded = true
|
|
|
|
|
|
|
|
|
|
// Find different English voices for variety
|
|
|
|
|
const enVoices = voices.filter(v => v.lang.startsWith('en'))
|
|
|
|
|
const anyVoices = enVoices.length > 0 ? enVoices : voices
|
|
|
|
|
|
|
|
|
|
// Try to assign different voices to different profiles
|
|
|
|
|
const findVoice = (patterns: RegExp[]) => {
|
|
|
|
|
for (const p of patterns) {
|
|
|
|
|
const v = anyVoices.find(v => p.test(v.name))
|
|
|
|
|
if (v) return v
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prefer premium/enhanced voices (sound natural, not robotic)
|
|
|
|
|
// On macOS: "Evan (Premium)" / "Samantha (Enhanced)" / "Daniel" are best
|
|
|
|
|
// On Chrome: "Google US English" / "Google UK English Male" are high quality
|
|
|
|
|
const preferPremium = (patterns: RegExp[]) => {
|
|
|
|
|
// First try premium/enhanced voices
|
|
|
|
|
const premium = anyVoices.find(v => /premium|enhanced|natural|neural/i.test(v.name))
|
|
|
|
|
if (premium) return premium
|
|
|
|
|
return findVoice(patterns)
|
|
|
|
|
}
|
|
|
|
|
voiceProfiles.announcer.voice = preferPremium([/evan/i, /aaron/i, /daniel/i, /google.*us.*male/i, /james/i, /male/i]) || anyVoices[0]
|
|
|
|
|
voiceProfiles.hype.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /karen/i, /google.*us/i, /female/i]) || anyVoices[Math.min(1, anyVoices.length - 1)]
|
|
|
|
|
voiceProfiles.deep.voice = findVoice([/evan/i, /aaron/i, /daniel/i, /alex/i, /tom/i]) || anyVoices[0]
|
|
|
|
|
voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0]
|
|
|
|
|
voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)]
|
|
|
|
|
voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)]
|
|
|
|
|
// Assign voices to new profiles — spread across available voices for max variety
|
|
|
|
|
const vLen = anyVoices.length
|
|
|
|
|
const pick = (i: number) => anyVoices[i % vLen]
|
|
|
|
|
voiceProfiles.whisper.voice = findVoice([/samantha/i, /tessa/i, /female/i]) || pick(0)
|
|
|
|
|
voiceProfiles.boomer.voice = findVoice([/evan/i, /tom/i, /alex/i, /male/i]) || pick(0)
|
|
|
|
|
voiceProfiles.chipmunk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
|
|
|
|
|
voiceProfiles.drill.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0)
|
|
|
|
|
voiceProfiles.surfer.voice = findVoice([/oliver/i, /google.*us/i, /male/i]) || pick(2)
|
|
|
|
|
voiceProfiles.auctioneer.voice = findVoice([/evan/i, /daniel/i, /google.*us/i]) || pick(0)
|
|
|
|
|
voiceProfiles.preacher.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(1)
|
|
|
|
|
voiceProfiles.baby.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
|
|
|
|
|
voiceProfiles.grandpa.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0)
|
|
|
|
|
voiceProfiles.valley.voice = findVoice([/samantha/i, /tessa/i, /karen/i]) || pick(1)
|
|
|
|
|
voiceProfiles.movie.voice = findVoice([/evan/i, /aaron/i, /daniel/i]) || pick(0)
|
|
|
|
|
voiceProfiles.sportscaster.voice = preferPremium([/evan/i, /james/i, /google.*us/i]) || pick(0)
|
|
|
|
|
voiceProfiles.opera.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
|
|
|
|
|
voiceProfiles.punk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
|
|
|
|
|
voiceProfiles.wizard_v.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
|
|
|
|
|
voiceProfiles.pirate_v.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0)
|
|
|
|
|
voiceProfiles.alien_v.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(3 % vLen)
|
|
|
|
|
voiceProfiles.cowboy_v.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
|
|
|
|
|
voiceProfiles.ninja_v.voice = findVoice([/daniel/i, /oliver/i]) || pick(2)
|
|
|
|
|
voiceProfiles.demon_v.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0)
|
|
|
|
|
voiceProfiles.angel.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1)
|
|
|
|
|
voiceProfiles.glitch.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(0)
|
|
|
|
|
voiceProfiles.echo_v.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(2)
|
|
|
|
|
voiceProfiles.hyper.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof speechSynthesis !== 'undefined') {
|
|
|
|
|
speechSynthesis.onvoiceschanged = loadVoices
|
|
|
|
|
loadVoices()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function speak(text: string, profileName: string, cancelPrevious: boolean = true, echo: boolean = false) {
|
|
|
|
|
if (typeof speechSynthesis === 'undefined') return
|
|
|
|
|
if (!voicesLoaded) loadVoices()
|
|
|
|
|
if (cancelPrevious) speechSynthesis.cancel()
|
|
|
|
|
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
|
|
|
|
const utter = new SpeechSynthesisUtterance(text)
|
|
|
|
|
if (profile.voice) utter.voice = profile.voice
|
|
|
|
|
utter.pitch = profile.pitch
|
|
|
|
|
utter.rate = profile.rate
|
|
|
|
|
utter.volume = profile.volume
|
|
|
|
|
speechSynthesis.speak(utter)
|
|
|
|
|
// Echo effect: repeat at lower volume with slight delay
|
|
|
|
|
if (echo) {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const echo1 = new SpeechSynthesisUtterance(text)
|
|
|
|
|
if (profile.voice) echo1.voice = profile.voice
|
|
|
|
|
echo1.pitch = profile.pitch * 0.9
|
|
|
|
|
echo1.rate = profile.rate * 0.95
|
|
|
|
|
echo1.volume = profile.volume * 0.4
|
|
|
|
|
speechSynthesis.speak(echo1)
|
|
|
|
|
}, 250)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const echo2 = new SpeechSynthesisUtterance(text)
|
|
|
|
|
if (profile.voice) echo2.voice = profile.voice
|
|
|
|
|
echo2.pitch = profile.pitch * 0.8
|
|
|
|
|
echo2.rate = profile.rate * 0.9
|
|
|
|
|
echo2.volume = profile.volume * 0.15
|
|
|
|
|
speechSynthesis.speak(echo2)
|
|
|
|
|
}, 500)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Public voice functions
|
|
|
|
|
export function announce(text: string, pitch?: number, rate?: number) {
|
|
|
|
|
if (pitch !== undefined || rate !== undefined) {
|
|
|
|
|
// Custom params — use announcer voice with overrides
|
|
|
|
|
if (typeof speechSynthesis === 'undefined') return
|
|
|
|
|
if (!voicesLoaded) loadVoices()
|
|
|
|
|
speechSynthesis.cancel()
|
|
|
|
|
const utter = new SpeechSynthesisUtterance(text)
|
|
|
|
|
const profile = voiceProfiles.announcer
|
|
|
|
|
if (profile.voice) utter.voice = profile.voice
|
|
|
|
|
utter.pitch = pitch ?? 1.0
|
|
|
|
|
utter.rate = rate ?? 0.8
|
|
|
|
|
utter.volume = 1.0
|
|
|
|
|
speechSynthesis.speak(utter)
|
|
|
|
|
} else {
|
|
|
|
|
speak(text, 'announcer')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceDeep(text: string) { speak(text, 'deep') }
|
|
|
|
|
export function announceFast(text: string) { speak(text, 'hype', false) }
|
|
|
|
|
export function announceRobot(text: string) { speak(text, 'robot') }
|
|
|
|
|
export function announceScream(text: string) { speak(text, 'screamer', false) }
|
|
|
|
|
export function announceSmooth(text: string) { speak(text, 'smooth') }
|
|
|
|
|
|
|
|
|
|
// Pick a random voice profile for variety
|
|
|
|
|
const ALL_VOICE_KEYS = Object.keys(voiceProfiles)
|
|
|
|
|
export function announceRandom(text: string, echo: boolean = false) {
|
|
|
|
|
const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)]
|
|
|
|
|
speak(text, key, true, echo)
|
|
|
|
|
}
|
|
|
|
|
// Announce with a specific mood category
|
|
|
|
|
const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v']
|
|
|
|
|
const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill']
|
|
|
|
|
const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch']
|
|
|
|
|
const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper']
|
|
|
|
|
export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)], true, true) }
|
|
|
|
|
export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], false) }
|
|
|
|
|
export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) }
|
|
|
|
|
export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)]) }
|
|
|
|
|
|
|
|
|
|
// Random dramatic commentary lines
|
|
|
|
|
const HYPE_LINES = [
|
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 10:42:18 +00:00
|
|
|
speak('Finish it!', 'announcer', true, true)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 10:42:18 +00:00
|
|
|
export function announceFatality(tagline?: string) {
|
|
|
|
|
speak(tagline || 'Fatality!', 'deep', true, true)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceFlawlessVictory() {
|
|
|
|
|
speak('Flawless victory!', 'deep', true, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceRandomHype() {
|
|
|
|
|
const line = HYPE_LINES[Math.floor(Math.random() * HYPE_LINES.length)]
|
|
|
|
|
// Randomly pick voice type for variety
|
|
|
|
|
const voices = [announceFast, announceScream, announce, announceSmooth]
|
|
|
|
|
voices[Math.floor(Math.random() * voices.length)](line)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceDeepIntro() {
|
|
|
|
|
const line = DEEP_INTROS[Math.floor(Math.random() * DEEP_INTROS.length)]
|
|
|
|
|
announceDeep(line)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceRoundHype() {
|
|
|
|
|
const line = ROUND_HYPE[Math.floor(Math.random() * ROUND_HYPE.length)]
|
|
|
|
|
const voices = [announceFast, announce, announceScream]
|
|
|
|
|
voices[Math.floor(Math.random() * voices.length)](line)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === FANFARES (8-bit melodic announcements) ===
|
|
|
|
|
|
|
|
|
|
export function fanfareRound(roundNum: number) {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
// Ascending power chord
|
|
|
|
|
tone(196, 'square', 0.15, d, t) // G3
|
|
|
|
|
tone(262, 'square', 0.15, d, t + 0.12) // C4
|
|
|
|
|
tone(330, 'square', 0.15, d, t + 0.24) // E4
|
|
|
|
|
tone(392, 'square', 0.25, d, t + 0.36) // G4
|
|
|
|
|
noise(0.08, d, t + 0.36)
|
|
|
|
|
// Announce after fanfare
|
|
|
|
|
setTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fanfareFight() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
// Punchy staccato
|
|
|
|
|
tone(523, 'square', 0.08, d, t)
|
|
|
|
|
tone(659, 'square', 0.08, d, t + 0.08)
|
|
|
|
|
tone(784, 'square', 0.15, d, t + 0.16)
|
|
|
|
|
noise(0.1, d, t + 0.16)
|
|
|
|
|
setTimeout(() => announce('Fight!', 0.3, 1.1), 200)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fanfareDevastating() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
tone(220, 'sawtooth', 0.2, d, t)
|
|
|
|
|
tone(175, 'sawtooth', 0.3, d, t + 0.15)
|
|
|
|
|
noise(0.15, d, t + 0.1)
|
|
|
|
|
setTimeout(() => speak('Devastating!', 'deep', true, true), 150)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fanfareCritical() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
tone(440, 'square', 0.1, d, t)
|
|
|
|
|
tone(554, 'square', 0.1, d, t + 0.08)
|
|
|
|
|
tone(659, 'square', 0.1, d, t + 0.16)
|
|
|
|
|
tone(880, 'square', 0.2, d, t + 0.24)
|
|
|
|
|
noise(0.12, d, t + 0.24)
|
|
|
|
|
setTimeout(() => announce('Critical hit!', 0.3, 1.0), 300)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fanfareCombo(count: number) {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
for (let i = 0; i < Math.min(count, 5); i++) {
|
|
|
|
|
tone(440 + i * 80, 'square', 0.08, d, t + i * 0.06)
|
|
|
|
|
}
|
|
|
|
|
if (count >= 3) setTimeout(() => announce(`${count} hit combo!`, 0.4, 1.0), 200)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === MODERN SFX ===
|
|
|
|
|
// Layered synthesis: body (low-end thump) + crack (mid snap) + air (filtered noise) + tail (reverb-like decay)
|
|
|
|
|
|
|
|
|
|
// Create a short convolution-style reverb tail
|
|
|
|
|
function reverbTail(duration: number, dest: AudioNode, startTime?: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = startTime ?? c.currentTime
|
|
|
|
|
const len = Math.max(1, Math.floor(c.sampleRate * duration))
|
|
|
|
|
const buf = c.createBuffer(2, len, c.sampleRate)
|
|
|
|
|
for (let ch = 0; ch < 2; ch++) {
|
|
|
|
|
const d = buf.getChannelData(ch)
|
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
|
|
|
d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (len * 0.3))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2500
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.08, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
|
|
|
|
src.connect(lp); lp.connect(g); g.connect(dest)
|
|
|
|
|
src.start(t); src.stop(t + duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Distorted body thump — sub-bass punch with waveshaping
|
|
|
|
|
function bodyThump(freq: number, duration: number, dest: AudioNode, startTime?: number, vol: number = 0.25) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = startTime ?? c.currentTime
|
|
|
|
|
// Sub oscillator
|
|
|
|
|
const sub = c.createOscillator(); sub.type = 'sine'; sub.frequency.setValueAtTime(freq, t)
|
|
|
|
|
sub.frequency.exponentialRampToValueAtTime(Math.max(20, freq * 0.3), t + duration)
|
|
|
|
|
// Waveshaper for warmth
|
|
|
|
|
const dist = c.createWaveShaper()
|
|
|
|
|
const curve = new Float32Array(256)
|
|
|
|
|
for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = Math.tanh(x * 3) }
|
|
|
|
|
dist.curve = curve
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(vol, t)
|
|
|
|
|
g.gain.setValueAtTime(vol * 0.8, t + duration * 0.1)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
|
|
|
|
sub.connect(dist); dist.connect(g); g.connect(dest)
|
|
|
|
|
sub.start(t); sub.stop(t + duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Crispy high-end snap/crack
|
|
|
|
|
function highSnap(freq: number, duration: number, dest: AudioNode, startTime?: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = startTime ?? c.currentTime
|
|
|
|
|
const bufSize = Math.max(1, Math.floor(c.sampleRate * duration))
|
|
|
|
|
const buf = c.createBuffer(1, bufSize, c.sampleRate)
|
|
|
|
|
const data = buf.getChannelData(0)
|
|
|
|
|
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 2
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.2, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
|
|
|
|
src.connect(bp); bp.connect(g); g.connect(dest)
|
|
|
|
|
src.start(t); src.stop(t + duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FM impact — metallic ring with modulation
|
|
|
|
|
function fmImpact(carrier: number, modRatio: number, duration: number, dest: AudioNode, startTime?: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const t = startTime ?? c.currentTime
|
|
|
|
|
const mod = c.createOscillator(); mod.type = 'sine'
|
|
|
|
|
mod.frequency.value = carrier * modRatio
|
|
|
|
|
const modG = c.createGain(); modG.gain.value = carrier * 2
|
|
|
|
|
mod.connect(modG)
|
|
|
|
|
const car = c.createOscillator(); car.type = 'sine'; car.frequency.value = carrier
|
|
|
|
|
modG.connect(car.frequency)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.15, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
|
|
|
|
|
car.connect(g); g.connect(dest)
|
|
|
|
|
car.start(t); car.stop(t + duration)
|
|
|
|
|
mod.start(t); mod.stop(t + duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxPunch() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Heavy body thump
|
|
|
|
|
bodyThump(120, 0.12, d, t, 0.3)
|
|
|
|
|
// Mid-range crack
|
|
|
|
|
highSnap(2200, 0.04, d, t)
|
|
|
|
|
// Knuckle noise
|
|
|
|
|
noise(0.03, d, t)
|
|
|
|
|
// Short reverb tail
|
|
|
|
|
reverbTail(0.15, d, t + 0.03)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxKick() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Deep bass slam
|
|
|
|
|
bodyThump(80, 0.18, d, t, 0.35)
|
|
|
|
|
// Leather slap
|
|
|
|
|
highSnap(3000, 0.03, d, t)
|
|
|
|
|
highSnap(1500, 0.05, d, t + 0.01)
|
|
|
|
|
// Air displacement
|
|
|
|
|
noise(0.06, d, t)
|
|
|
|
|
reverbTail(0.2, d, t + 0.04)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxSpecial() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Power charge sweep
|
|
|
|
|
sweep(150, 800, 'sawtooth', 0.2, d)
|
|
|
|
|
// FM metallic shimmer
|
|
|
|
|
fmImpact(600, 3.5, 0.3, d, t)
|
|
|
|
|
// Rising high-end
|
|
|
|
|
highSnap(4000, 0.08, d, t + 0.1)
|
|
|
|
|
// Energy release
|
|
|
|
|
bodyThump(100, 0.15, d, t + 0.15, 0.2)
|
|
|
|
|
noise(0.1, d, t + 0.15)
|
|
|
|
|
reverbTail(0.4, d, t + 0.1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxCritical() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Massive bass hit
|
|
|
|
|
bodyThump(60, 0.3, d, t, 0.4)
|
|
|
|
|
// Double crack
|
|
|
|
|
highSnap(3500, 0.05, d, t)
|
|
|
|
|
highSnap(5000, 0.03, d, t + 0.02)
|
|
|
|
|
// FM distortion ring
|
|
|
|
|
fmImpact(200, 7, 0.25, d, t + 0.03)
|
|
|
|
|
// Noise burst
|
|
|
|
|
noise(0.15, d, t)
|
|
|
|
|
// Second wave
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
bodyThump(90, 0.2, d)
|
|
|
|
|
sweep(200, 600, 'sawtooth', 0.2, d)
|
|
|
|
|
noise(0.1, d)
|
|
|
|
|
}, 80)
|
|
|
|
|
// Long reverb tail
|
|
|
|
|
reverbTail(0.6, d, t + 0.05)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxGunshot() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Sharp transient
|
|
|
|
|
highSnap(6000, 0.015, d, t)
|
|
|
|
|
// Body kick
|
|
|
|
|
bodyThump(200, 0.06, d, t, 0.3)
|
|
|
|
|
// Gunpowder noise burst
|
|
|
|
|
noise(0.04, d, t)
|
|
|
|
|
// Shell casing ring
|
|
|
|
|
fmImpact(2000, 1.5, 0.08, d, t + 0.03)
|
|
|
|
|
reverbTail(0.25, d, t + 0.02)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxBulletHit() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
bodyThump(150, 0.06, d, t, 0.2)
|
|
|
|
|
highSnap(4000, 0.02, d, t)
|
|
|
|
|
fmImpact(800, 2.5, 0.06, d, t)
|
|
|
|
|
reverbTail(0.12, d, t + 0.02)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxJetpack() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Turbine rumble — layered oscillators with modulation
|
|
|
|
|
const osc1 = c.createOscillator(); osc1.type = 'sawtooth'
|
|
|
|
|
osc1.frequency.setValueAtTime(60, t)
|
|
|
|
|
osc1.frequency.linearRampToValueAtTime(120, t + 0.2)
|
|
|
|
|
osc1.frequency.linearRampToValueAtTime(80, t + 0.5)
|
|
|
|
|
const osc2 = c.createOscillator(); osc2.type = 'square'
|
|
|
|
|
osc2.frequency.setValueAtTime(90, t)
|
|
|
|
|
osc2.frequency.linearRampToValueAtTime(180, t + 0.2)
|
|
|
|
|
osc2.frequency.linearRampToValueAtTime(100, t + 0.5)
|
|
|
|
|
const lp = c.createBiquadFilter(); lp.type = 'lowpass'
|
|
|
|
|
lp.frequency.setValueAtTime(400, t); lp.frequency.linearRampToValueAtTime(1200, t + 0.2)
|
|
|
|
|
lp.frequency.linearRampToValueAtTime(600, t + 0.5)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.01, t)
|
|
|
|
|
g.gain.linearRampToValueAtTime(0.18, t + 0.1)
|
|
|
|
|
g.gain.setValueAtTime(0.15, t + 0.3)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.6)
|
|
|
|
|
osc1.connect(lp); osc2.connect(lp); lp.connect(g); g.connect(d)
|
|
|
|
|
osc1.start(t); osc1.stop(t + 0.6)
|
|
|
|
|
osc2.start(t); osc2.stop(t + 0.6)
|
|
|
|
|
// Noise layer for roar
|
|
|
|
|
noise(0.5, d, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxExplosion() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Massive sub thump
|
|
|
|
|
bodyThump(40, 0.5, d, t, 0.4)
|
|
|
|
|
// Shrapnel noise burst
|
|
|
|
|
highSnap(2000, 0.08, d, t)
|
|
|
|
|
highSnap(5000, 0.05, d, t + 0.01)
|
|
|
|
|
noise(0.2, d, t)
|
|
|
|
|
// Fireball sweep
|
|
|
|
|
sweep(300, 30, 'sawtooth', 0.4, d)
|
|
|
|
|
// Debris rattle
|
|
|
|
|
fmImpact(400, 5, 0.3, d, t + 0.05)
|
|
|
|
|
// Secondary boom
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
bodyThump(50, 0.3, d)
|
|
|
|
|
noise(0.15, d)
|
|
|
|
|
}, 120)
|
|
|
|
|
// Long reverb
|
|
|
|
|
reverbTail(0.8, d, t + 0.05)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxBlock() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Metallic shield ring
|
|
|
|
|
fmImpact(800, 1.5, 0.12, d, t)
|
|
|
|
|
fmImpact(1200, 2, 0.08, d, t + 0.02)
|
|
|
|
|
highSnap(3000, 0.03, d, t)
|
|
|
|
|
reverbTail(0.2, d, t + 0.02)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxDodge() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Quick air whoosh
|
|
|
|
|
sweep(300, 1200, 'sine', 0.1, d)
|
|
|
|
|
noise(0.06, d, t)
|
|
|
|
|
// Cloth rustle
|
|
|
|
|
highSnap(4000, 0.04, d, t + 0.02)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxClash() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Two impacts overlapping
|
|
|
|
|
bodyThump(100, 0.1, d, t, 0.25)
|
|
|
|
|
fmImpact(600, 3, 0.15, d, t)
|
|
|
|
|
fmImpact(900, 2, 0.12, d, t + 0.02)
|
|
|
|
|
highSnap(3500, 0.04, d, t)
|
|
|
|
|
noise(0.08, d, t)
|
|
|
|
|
reverbTail(0.3, d, t + 0.03)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === KO + WIN ===
|
|
|
|
|
|
|
|
|
|
export function sfxKO() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Earth-shaking bass
|
|
|
|
|
bodyThump(30, 0.6, d, t, 0.5)
|
|
|
|
|
// Multi-layer impact
|
|
|
|
|
highSnap(2000, 0.06, d, t)
|
|
|
|
|
noise(0.25, d, t)
|
|
|
|
|
fmImpact(150, 5, 0.4, d, t)
|
|
|
|
|
// Second slam
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
bodyThump(40, 0.4, d)
|
|
|
|
|
noise(0.15, d)
|
|
|
|
|
highSnap(3000, 0.04, d)
|
|
|
|
|
}, 180)
|
|
|
|
|
// Heavy reverb
|
|
|
|
|
reverbTail(1.0, d, t + 0.05)
|
|
|
|
|
setTimeout(() => speak('K. O.!', 'announcer', true, true), 500)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxPerfect() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Massive descending power
|
|
|
|
|
bodyThump(30, 0.8, d, t, 0.45)
|
|
|
|
|
noise(0.3, d, t)
|
|
|
|
|
fmImpact(300, 7, 0.5, d, t)
|
|
|
|
|
sweep(800, 30, 'sawtooth', 0.6, d)
|
|
|
|
|
// Then ascending triumph chord
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
tone(262, 'square', 0.2, d); tone(262, 'triangle', 0.2, d) // C4
|
|
|
|
|
tone(330, 'square', 0.2, d); tone(330, 'triangle', 0.2, d) // E4
|
|
|
|
|
tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d) // G4
|
|
|
|
|
fmImpact(523, 1.5, 0.3, d) // C5 shimmer
|
|
|
|
|
}, 400)
|
|
|
|
|
reverbTail(1.2, d, t + 0.1)
|
|
|
|
|
setTimeout(() => announce('Perfect!', 0.2, 0.5), 800)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxWin() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Victory jingle — richer with harmonics and FM shimmer
|
|
|
|
|
const melody = [
|
|
|
|
|
[262, 0.12], [330, 0.12], [392, 0.12],
|
|
|
|
|
[523, 0.2], [392, 0.1], [440, 0.1], [523, 0.1],
|
|
|
|
|
[659, 0.25], [523, 0.1], [587, 0.1], [659, 0.1],
|
|
|
|
|
[784, 0.4],
|
|
|
|
|
] as [number, number][]
|
|
|
|
|
let offset = 0
|
|
|
|
|
for (const [freq, dur] of melody) {
|
|
|
|
|
tone(freq, 'square', dur + 0.05, d, t + offset)
|
|
|
|
|
tone(freq * 0.5, 'triangle', dur + 0.05, d, t + offset)
|
|
|
|
|
tone(freq * 1.005, 'sawtooth', dur + 0.03, d, t + offset) // chorus detune
|
|
|
|
|
fmImpact(freq, 2, dur * 0.5, d, t + offset) // shimmer
|
|
|
|
|
offset += dur
|
|
|
|
|
}
|
|
|
|
|
// Cymbal crash + bass
|
|
|
|
|
noise(0.4, d, t + offset - 0.1)
|
|
|
|
|
bodyThump(60, 0.3, d, t + offset - 0.1, 0.2)
|
|
|
|
|
reverbTail(0.8, d, t + offset)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxWinAnnounce(winnerName: string) {
|
|
|
|
|
setTimeout(() => announce(`${winnerName} wins!`, 0.3, 0.7), 200)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxRoundStart() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Bell-like tones with FM shimmer
|
|
|
|
|
fmImpact(440, 2, 0.2, d, t)
|
|
|
|
|
fmImpact(554, 2, 0.2, d, t + 0.15)
|
|
|
|
|
fmImpact(659, 2, 0.25, d, t + 0.3)
|
|
|
|
|
tone(440, 'triangle', 0.15, d, t)
|
|
|
|
|
tone(554, 'triangle', 0.15, d, t + 0.15)
|
|
|
|
|
tone(659, 'triangle', 0.2, d, t + 0.3)
|
|
|
|
|
reverbTail(0.3, d, t + 0.3)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === SILLY / FUNNY SOUNDS ===
|
|
|
|
|
|
|
|
|
|
export function sfxBoing() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Spring with resonance
|
|
|
|
|
sweep(200, 900, 'sine', 0.12, d)
|
|
|
|
|
fmImpact(400, 3, 0.1, d, t)
|
|
|
|
|
setTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80)
|
|
|
|
|
setTimeout(() => sweep(400, 550, 'sine', 0.06, d), 140)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxWomp() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Sad trombone — richer with vibrato
|
|
|
|
|
const notes = [[294, 0.25], [277, 0.25], [262, 0.25], [247, 0.5]] as [number, number][]
|
|
|
|
|
let off = 0
|
|
|
|
|
for (const [freq, dur] of notes) {
|
|
|
|
|
tone(freq, 'sawtooth', dur, d, t + off)
|
|
|
|
|
tone(freq * 0.5, 'triangle', dur, d, t + off)
|
|
|
|
|
off += dur
|
|
|
|
|
}
|
|
|
|
|
reverbTail(0.6, d, t + off)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxSlideUp() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
sweep(200, 2000, 'sine', 0.25, d)
|
|
|
|
|
sweep(210, 2100, 'sine', 0.25, d) // chorus
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxSlideDown() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
sweep(2000, 100, 'sine', 0.35, d)
|
|
|
|
|
sweep(2020, 110, 'sine', 0.35, d)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxBonk() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
bodyThump(200, 0.06, d, t, 0.2)
|
|
|
|
|
fmImpact(800, 3, 0.06, d, t)
|
|
|
|
|
highSnap(5000, 0.02, d, t)
|
|
|
|
|
reverbTail(0.1, d, t + 0.02)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxSplat() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
bodyThump(100, 0.1, d, t, 0.2)
|
|
|
|
|
noise(0.12, d, t)
|
|
|
|
|
highSnap(1500, 0.06, d, t)
|
|
|
|
|
sweep(400, 80, 'sine', 0.1, d)
|
|
|
|
|
reverbTail(0.2, d, t + 0.05)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxZap() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Electric arc
|
|
|
|
|
sweep(100, 4000, 'sawtooth', 0.06, d)
|
|
|
|
|
fmImpact(1000, 7, 0.1, d, t)
|
|
|
|
|
setTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40)
|
|
|
|
|
setTimeout(() => sweep(600, 5000, 'sawtooth', 0.05, d), 80)
|
|
|
|
|
highSnap(6000, 0.03, d, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxZoomWhoosh() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
// Layered Doppler whoosh
|
|
|
|
|
sweep(80, 1800, 'sawtooth', 0.2, d)
|
|
|
|
|
sweep(100, 2200, 'sine', 0.18, d) // higher layer
|
|
|
|
|
noise(0.15, d, t)
|
|
|
|
|
// Doppler pass
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
sweep(1800, 150, 'sawtooth', 0.12, d)
|
|
|
|
|
sweep(2200, 200, 'sine', 0.1, d)
|
|
|
|
|
}, 150)
|
|
|
|
|
reverbTail(0.3, d, t + 0.15)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxRapidPunch() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
const ht = t + i * 0.04
|
|
|
|
|
bodyThump(120 - i * 10, 0.04, d, ht, 0.15)
|
|
|
|
|
highSnap(2500 + i * 500, 0.02, d, ht)
|
|
|
|
|
noise(0.02, d, ht)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxPowerUp() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const freq = 300 + i * 100
|
|
|
|
|
fmImpact(freq, 2, 0.1, d, t + i * 0.06)
|
|
|
|
|
tone(freq, 'triangle', 0.08, d, t + i * 0.06)
|
|
|
|
|
}
|
|
|
|
|
reverbTail(0.3, d, t + 0.3)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxCoin() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
fmImpact(988, 1.5, 0.1, d, t)
|
|
|
|
|
fmImpact(1319, 1.5, 0.15, d, t + 0.06)
|
|
|
|
|
tone(988, 'triangle', 0.06, d, t)
|
|
|
|
|
tone(1319, 'triangle', 0.12, d, t + 0.06)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxFail() {
|
|
|
|
|
const d = sfxGain ?? getCtx().destination
|
|
|
|
|
const c = getCtx(); const t = c.currentTime
|
|
|
|
|
sweep(600, 80, 'sawtooth', 0.25, d)
|
|
|
|
|
bodyThump(80, 0.2, d, t + 0.15, 0.15)
|
|
|
|
|
setTimeout(() => noise(0.08, d), 180)
|
|
|
|
|
reverbTail(0.3, d, t + 0.2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pick a random silly sound
|
|
|
|
|
export function sfxRandomSilly() {
|
|
|
|
|
const fns = [sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp]
|
|
|
|
|
fns[Math.floor(Math.random() * fns.length)]()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === BACKGROUND MUSIC ===
|
|
|
|
|
// Rich, full arcade fighting soundtrack with 6 simultaneous layers
|
|
|
|
|
|
|
|
|
|
const MUSIC_BPM = 170
|
|
|
|
|
const MUSIC_BEAT = 60 / MUSIC_BPM
|
|
|
|
|
const BARS = 8
|
|
|
|
|
const STEPS = 16 // per bar
|
|
|
|
|
|
|
|
|
|
// Shared delay effect for fullness
|
|
|
|
|
let delayNode: DelayNode | null = null
|
|
|
|
|
let delayGain: GainNode | null = null
|
|
|
|
|
|
|
|
|
|
function getMusicDelay(): GainNode {
|
|
|
|
|
if (delayGain && delayNode) return delayGain
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
delayNode = c.createDelay(0.5)
|
|
|
|
|
delayNode.delayTime.value = 0.18 // 8th note delay
|
|
|
|
|
delayGain = c.createGain()
|
|
|
|
|
delayGain.gain.value = 0.25
|
|
|
|
|
const feedback = c.createGain()
|
|
|
|
|
feedback.gain.value = 0.3
|
|
|
|
|
delayNode.connect(feedback)
|
|
|
|
|
feedback.connect(delayNode) // feedback loop
|
|
|
|
|
delayNode.connect(delayGain)
|
|
|
|
|
delayGain.connect(musicGain!)
|
|
|
|
|
return delayGain
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chorus tone: 2 detuned oscillators for width
|
|
|
|
|
function chorusTone(freq: number, type: OscillatorType, dur: number, dest: AudioNode, t: number, vol: number = 0.2) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
for (const detune of [-8, 8]) { // slight detune for stereo width
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = type
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
osc.detune.value = detune
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(vol, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g)
|
|
|
|
|
g.connect(dest)
|
|
|
|
|
osc.start(t)
|
|
|
|
|
osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FM lead with 2-operator FM + detuned chorus
|
|
|
|
|
function fmLead(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
// Carrier
|
|
|
|
|
const car = c.createOscillator()
|
|
|
|
|
car.type = 'sawtooth'
|
|
|
|
|
car.frequency.value = freq
|
|
|
|
|
// Modulator
|
|
|
|
|
const mod = c.createOscillator()
|
|
|
|
|
mod.type = 'sine'
|
|
|
|
|
mod.frequency.value = freq * 2
|
|
|
|
|
const modG = c.createGain()
|
|
|
|
|
modG.gain.value = 150
|
|
|
|
|
mod.connect(modG)
|
|
|
|
|
modG.connect(car.frequency)
|
|
|
|
|
// Detuned double for width
|
|
|
|
|
const car2 = c.createOscillator()
|
|
|
|
|
car2.type = 'sawtooth'
|
|
|
|
|
car2.frequency.value = freq
|
|
|
|
|
car2.detune.value = 10
|
|
|
|
|
// Output
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.16, t)
|
|
|
|
|
g.gain.setValueAtTime(0.16, t + dur * 0.7)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
car.connect(g)
|
|
|
|
|
car2.connect(g)
|
|
|
|
|
g.connect(dest)
|
|
|
|
|
// Send to delay for fullness
|
|
|
|
|
if (delayNode) g.connect(delayNode)
|
|
|
|
|
car.start(t); car.stop(t + dur)
|
|
|
|
|
car2.start(t); car2.stop(t + dur)
|
|
|
|
|
mod.start(t); mod.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Thick distorted sub-bass with overtones
|
|
|
|
|
function thickBass(freq: number, dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
// Sub
|
|
|
|
|
const sub = c.createOscillator()
|
|
|
|
|
sub.type = 'sine'
|
|
|
|
|
sub.frequency.value = freq / 2
|
|
|
|
|
const subG = c.createGain()
|
|
|
|
|
subG.gain.setValueAtTime(0.25, t)
|
|
|
|
|
subG.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
sub.connect(subG); subG.connect(dest)
|
|
|
|
|
sub.start(t); sub.stop(t + dur)
|
|
|
|
|
// Main with distortion
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sawtooth'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const dist = c.createWaveShaper()
|
|
|
|
|
const curve = new Float32Array(256)
|
|
|
|
|
for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = (Math.PI + 6) * x / (Math.PI + 6 * Math.abs(x)) }
|
|
|
|
|
dist.curve = curve
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.2, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(dist); dist.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
// Octave up for bite
|
|
|
|
|
const oct = c.createOscillator()
|
|
|
|
|
oct.type = 'square'
|
|
|
|
|
oct.frequency.value = freq * 2
|
|
|
|
|
const octG = c.createGain()
|
|
|
|
|
octG.gain.setValueAtTime(0.06, t)
|
|
|
|
|
octG.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.5)
|
|
|
|
|
oct.connect(octG); octG.connect(dest)
|
|
|
|
|
oct.start(t); oct.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chord pad for harmonic fullness
|
|
|
|
|
function chordPad(freqs: number[], dur: number, dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
for (const freq of freqs) {
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'triangle'
|
|
|
|
|
osc.frequency.value = freq
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.04, t)
|
|
|
|
|
g.gain.setValueAtTime(0.04, t + dur * 0.8)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
osc.connect(g); g.connect(dest)
|
|
|
|
|
osc.start(t); osc.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Heavy layered drums
|
|
|
|
|
function kick(dest: AudioNode, t: number) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
// Body
|
|
|
|
|
const o = c.createOscillator(); o.type = 'sine'
|
|
|
|
|
o.frequency.setValueAtTime(150, t)
|
|
|
|
|
o.frequency.exponentialRampToValueAtTime(40, t + 0.12)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.35, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.15)
|
|
|
|
|
o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.15)
|
|
|
|
|
// Click
|
|
|
|
|
noise(0.015, dest, t)
|
|
|
|
|
// Sub thump
|
|
|
|
|
const s = c.createOscillator(); s.type = 'sine'; s.frequency.value = 50
|
|
|
|
|
const sg = c.createGain()
|
|
|
|
|
sg.gain.setValueAtTime(0.2, t)
|
|
|
|
|
sg.gain.exponentialRampToValueAtTime(0.001, t + 0.1)
|
|
|
|
|
s.connect(sg); sg.connect(dest); s.start(t); s.stop(t + 0.1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function snare(dest: AudioNode, t: number) {
|
|
|
|
|
noise(0.08, dest, t)
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const o = c.createOscillator(); o.type = 'triangle'; o.frequency.value = 200
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.2, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.06)
|
|
|
|
|
o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.06)
|
|
|
|
|
// Body
|
|
|
|
|
tone(180, 'square', 0.03, dest, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hihat(dest: AudioNode, t: number, open: boolean = false) {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const dur = open ? 0.08 : 0.025
|
|
|
|
|
const bufSize = Math.max(1, Math.floor(c.sampleRate * dur))
|
|
|
|
|
const buf = c.createBuffer(1, bufSize, c.sampleRate)
|
|
|
|
|
const d = buf.getChannelData(0)
|
|
|
|
|
for (let i = 0; i < bufSize; i++) d[i] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = open ? 6000 : 8000
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(open ? 0.12 : 0.08, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
|
|
|
|
|
src.connect(hp); hp.connect(g); g.connect(dest)
|
|
|
|
|
src.start(t); src.stop(t + dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === TRACK DATA ===
|
|
|
|
|
// Each track has: bass, lead, arp patterns (128 steps = 8 bars x 16 steps), chords (8 bars), drums (128 steps), bpm
|
|
|
|
|
|
|
|
|
|
interface MusicTrack {
|
|
|
|
|
name: string
|
|
|
|
|
bpm: number
|
|
|
|
|
bass: number[]
|
|
|
|
|
lead: number[]
|
|
|
|
|
arp: number[]
|
|
|
|
|
chords: number[][]
|
|
|
|
|
drums: number[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track 1: "Neon Fury" — Cm, relentless, 225bpm
|
|
|
|
|
const track1: MusicTrack = {
|
|
|
|
|
name: 'Neon Fury', bpm: 225,
|
|
|
|
|
bass: [
|
|
|
|
|
131,165,196,131, 165,196,220,196, 131,196,220,262, 220,196,165,131,
|
|
|
|
|
208,262,311,208, 262,311,349,311, 196,262,330,262, 311,262,208,196,
|
|
|
|
|
156,208,262,311, 262,208,156,208, 196,262,330,392, 330,262,196,262,
|
|
|
|
|
196,247,294,330, 294,247,196,247, 262,330,392,440, 392,330,262,196,
|
|
|
|
|
131,131,165,196, 220,262,220,196, 208,208,262,311, 349,311,262,208,
|
|
|
|
|
156,156,208,262, 311,349,311,262, 196,196,262,330, 392,440,392,330,
|
|
|
|
|
131,196,262,330, 392,330,262,196, 208,311,415,466, 415,311,262,208,
|
|
|
|
|
196,262,330,392, 440,523,440,392, 262,330,440,523, 587,523,440,330,
|
|
|
|
|
],
|
|
|
|
|
lead: [
|
|
|
|
|
523,622,784,622, 523,622,784,1047, 784,622,523,622, 784,1047,784,622,
|
|
|
|
|
831,784,622,523, 622,784,831,1047, 1175,1047,831,784, 622,784,831,1047,
|
|
|
|
|
622,784,831,1047, 831,784,622,784, 831,1047,1175,1319, 1175,1047,831,784,
|
|
|
|
|
784,831,1047,1319, 1175,1047,831,784, 1047,1319,1568,1760, 1568,1319,1047,831,
|
|
|
|
|
523,622,784,1047, 784,622,523,466, 622,784,1047,1319, 1047,784,622,523,
|
|
|
|
|
831,784,622,784, 831,1047,1175,1319, 1175,1047,831,784, 622,784,1047,1175,
|
|
|
|
|
784,1047,1319,1568, 1319,1047,784,622, 1047,1319,1568,1760, 1568,1319,1047,784,
|
|
|
|
|
1047,1319,1568,1760, 2093,1760,1568,1319, 1568,1760,2093,1760, 1568,1319,1047,831,
|
|
|
|
|
],
|
|
|
|
|
arp: [
|
|
|
|
|
262,330,392,523, 392,330,262,330, 392,523,662,523, 392,330,262,330,
|
|
|
|
|
415,523,622,831, 622,523,415,523, 622,831,1047,831, 622,523,415,523,
|
|
|
|
|
311,392,466,622, 466,392,311,392, 466,622,831,622, 466,392,311,392,
|
|
|
|
|
392,494,587,784, 587,494,392,494, 587,784,1047,784, 587,494,392,494,
|
|
|
|
|
262,392,523,784, 523,392,262,392, 415,523,622,831, 831,622,523,415,
|
|
|
|
|
311,466,622,831, 622,466,311,466, 392,587,784,1047, 1047,784,587,392,
|
|
|
|
|
262,523,784,1047, 1319,1047,784,523, 415,622,831,1175, 1175,831,622,415,
|
|
|
|
|
392,784,1047,1319, 1568,1319,1047,784, 523,1047,1319,1568, 1760,1568,1319,1047,
|
|
|
|
|
],
|
|
|
|
|
chords: [[131,156,196],[208,262,311],[156,196,233],[196,247,294],[131,156,196],[156,196,233],[208,262,311],[196,247,294]],
|
|
|
|
|
drums: [
|
|
|
|
|
1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3, 1,3,1,3, 2,3,1,1, 1,1,4,3, 2,1,1,3,
|
|
|
|
|
1,3,1,3, 2,1,1,3, 1,1,4,1, 2,1,1,1, 1,1,1,3, 2,1,4,1, 2,1,1,1, 2,2,1,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,2,1,
|
|
|
|
|
1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,1, 2,2,2,1, 2,2,2,2, 2,2,2,2,
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track 2: "Dark Circuit" — Em, relentless grind, 235bpm
|
|
|
|
|
const track2: MusicTrack = {
|
|
|
|
|
name: 'Dark Circuit', bpm: 235,
|
|
|
|
|
bass: [
|
|
|
|
|
82,110,82,110, 82,110,147,110, 110,147,110,147, 110,82,110,82,
|
|
|
|
|
98,131,98,131, 98,131,165,131, 131,165,131,165, 131,98,131,98,
|
|
|
|
|
82,82,110,147, 165,147,110,82, 98,98,131,165, 196,165,131,98,
|
|
|
|
|
110,147,165,196, 165,147,110,82, 131,165,196,220, 196,165,131,98,
|
|
|
|
|
82,110,147,196, 147,110,82,110, 98,131,165,220, 165,131,98,131,
|
|
|
|
|
82,110,147,165, 196,165,147,110, 98,131,165,196, 220,196,165,131,
|
|
|
|
|
82,147,196,220, 247,220,196,147, 98,165,220,247, 294,247,220,165,
|
|
|
|
|
110,147,196,247, 294,330,294,247, 82,110,196,247, 330,294,247,196,
|
|
|
|
|
],
|
|
|
|
|
lead: [
|
|
|
|
|
659,784,880,784, 659,784,880,1047, 784,880,1047,880, 784,659,784,880,
|
|
|
|
|
784,880,1047,880, 784,880,1047,1175, 1047,1175,1319,1175, 1047,880,784,880,
|
|
|
|
|
659,784,880,1047, 880,784,659,784, 880,1047,1175,1319, 1175,1047,880,784,
|
|
|
|
|
880,1047,1175,1319, 1175,1047,880,784, 1047,1175,1319,1568, 1319,1175,1047,880,
|
|
|
|
|
659,784,880,1047, 1175,1047,880,784, 784,880,1047,1175, 1319,1175,1047,880,
|
|
|
|
|
880,1047,1175,1047, 880,784,880,1047, 1047,1175,1319,1175, 1047,880,1047,1175,
|
|
|
|
|
659,880,1175,1568, 1175,880,659,880, 784,1047,1319,1760, 1319,1047,784,1047,
|
|
|
|
|
880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 1760,1568,1319,1047,
|
|
|
|
|
],
|
|
|
|
|
arp: [
|
|
|
|
|
330,392,494,659, 494,392,330,392, 494,659,880,659, 494,392,330,392,
|
|
|
|
|
392,494,587,784, 587,494,392,494, 587,784,1047,784, 587,494,392,494,
|
|
|
|
|
330,494,659,880, 1175,880,659,494, 392,587,784,1047, 1319,1047,784,587,
|
|
|
|
|
494,659,880,1175, 1175,880,659,494, 587,784,1047,1319, 1319,1047,784,587,
|
|
|
|
|
330,392,494,659, 880,659,494,392, 392,494,587,784, 1047,784,587,494,
|
|
|
|
|
330,494,659,880, 659,494,330,494, 392,587,784,1047, 784,587,392,587,
|
|
|
|
|
330,659,880,1175, 1568,1175,880,659, 392,784,1047,1319, 1568,1319,1047,784,
|
|
|
|
|
494,880,1175,1568, 1760,1568,1175,880, 330,880,1175,1760, 2093,1760,1175,880,
|
|
|
|
|
],
|
|
|
|
|
chords: [[165,196,247],[196,247,294],[131,165,196],[147,175,220],[165,196,247],[131,165,196],[196,247,294],[147,175,220]],
|
|
|
|
|
drums: [
|
|
|
|
|
1,3,1,3, 2,3,1,1, 1,3,1,3, 2,1,1,3, 1,1,1,3, 2,3,1,1, 1,1,4,1, 2,1,1,1,
|
|
|
|
|
1,3,1,1, 2,1,1,3, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1,
|
|
|
|
|
1,1,1,3, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1,
|
|
|
|
|
1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,2, 2,2,2,1, 2,2,2,2, 2,2,2,2,
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track 3: "Pixel Blitz" — F major, frantic energy, 245bpm
|
|
|
|
|
const track3: MusicTrack = {
|
|
|
|
|
name: 'Pixel Blitz', bpm: 245,
|
|
|
|
|
bass: [
|
|
|
|
|
175,220,262,175, 220,262,330,262, 233,294,349,233, 294,349,440,349,
|
|
|
|
|
208,262,330,208, 262,330,392,330, 262,330,392,262, 330,392,440,392,
|
|
|
|
|
175,175,220,262, 330,262,220,175, 233,233,294,349, 440,349,294,233,
|
|
|
|
|
208,208,262,330, 392,330,262,208, 262,330,392,440, 523,440,392,330,
|
|
|
|
|
175,220,262,330, 392,330,262,220, 233,294,349,440, 523,440,349,294,
|
|
|
|
|
208,262,330,392, 440,392,330,262, 262,330,392,440, 523,587,523,440,
|
|
|
|
|
175,262,349,440, 523,440,349,262, 233,349,440,523, 587,523,440,349,
|
|
|
|
|
208,330,440,523, 587,523,440,330, 175,349,440,523, 587,698,587,523,
|
|
|
|
|
],
|
|
|
|
|
lead: [
|
|
|
|
|
698,784,880,784, 698,784,880,1047, 880,1047,1175,1047, 880,784,698,784,
|
|
|
|
|
784,880,1047,880, 784,880,1047,1175, 1047,1175,1319,1175, 1047,880,784,880,
|
|
|
|
|
698,784,880,1047, 1175,1047,880,784, 880,1047,1175,1319, 1568,1319,1175,1047,
|
|
|
|
|
1047,1175,1319,1568, 1319,1175,1047,880, 1175,1319,1568,1760, 1568,1319,1175,1047,
|
|
|
|
|
698,880,1047,1319, 1047,880,698,880, 784,1047,1175,1568, 1175,1047,784,1047,
|
|
|
|
|
880,1047,1175,1047, 880,784,880,1047, 1175,1319,1568,1319, 1175,1047,1175,1319,
|
|
|
|
|
698,880,1175,1568, 1760,1568,1175,880, 784,1047,1319,1760, 2093,1760,1319,1047,
|
|
|
|
|
880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 2349,2093,1760,1319,
|
|
|
|
|
],
|
|
|
|
|
arp: [
|
|
|
|
|
349,440,523,698, 523,440,349,440, 523,698,932,698, 523,440,349,440,
|
|
|
|
|
466,587,698,932, 698,587,466,587, 698,932,1175,932, 698,587,466,587,
|
|
|
|
|
415,523,659,880, 1175,880,659,523, 523,659,784,1047, 1319,1047,784,659,
|
|
|
|
|
523,659,784,1047, 1319,1047,784,659, 698,880,1047,1319, 1568,1319,1047,880,
|
|
|
|
|
349,523,698,932, 1175,932,698,523, 466,698,932,1175, 1568,1175,932,698,
|
|
|
|
|
415,659,880,1175, 1568,1175,880,659, 523,784,1047,1319, 1760,1319,1047,784,
|
|
|
|
|
349,698,1047,1319, 1568,1319,1047,698, 466,932,1175,1568, 1760,1568,1175,932,
|
|
|
|
|
523,1047,1319,1760, 2093,1760,1319,1047, 698,1175,1568,2093, 2349,2093,1568,1175,
|
|
|
|
|
],
|
|
|
|
|
chords: [[175,220,262],[233,294,349],[208,262,330],[262,330,392],[175,220,262],[208,262,330],[233,294,349],[262,330,392]],
|
|
|
|
|
drums: [
|
|
|
|
|
1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1, 1,1,1,3, 2,1,1,1, 1,1,4,1, 2,1,1,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1,
|
|
|
|
|
1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,2, 2,2,2,2, 2,2,2,2, 2,2,2,2,
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track 4: "Skull Crusher" — Am, insane thrash, 260bpm
|
|
|
|
|
const track4: MusicTrack = {
|
|
|
|
|
name: 'Skull Crusher', bpm: 260,
|
|
|
|
|
bass: [
|
|
|
|
|
110,131,110,131, 110,131,165,131, 131,165,131,165, 131,110,131,110,
|
|
|
|
|
98,110,98,131, 131,165,131,110, 110,131,165,196, 165,131,110,131,
|
|
|
|
|
110,110,131,165, 196,220,196,165, 98,98,131,165, 196,220,196,165,
|
|
|
|
|
110,131,165,196, 220,247,220,196, 98,131,165,196, 220,247,294,247,
|
|
|
|
|
110,131,165,196, 220,196,165,131, 98,110,131,165, 196,165,131,110,
|
|
|
|
|
110,110,131,165, 196,220,247,220, 98,98,131,165, 196,247,294,247,
|
|
|
|
|
110,131,196,247, 294,330,294,247, 98,131,196,247, 330,349,330,247,
|
|
|
|
|
110,165,220,294, 330,349,330,294, 110,165,247,330, 392,349,330,247,
|
|
|
|
|
],
|
|
|
|
|
lead: [
|
|
|
|
|
880,1047,880,1047, 1175,1047,880,1047, 1047,1175,1047,1175, 1319,1175,1047,1175,
|
|
|
|
|
784,880,784,880, 1047,880,784,880, 880,1047,880,1047, 1175,1047,880,1047,
|
|
|
|
|
880,1047,1175,1319, 1175,1047,880,1047, 1047,1175,1319,1568, 1319,1175,1047,1175,
|
|
|
|
|
1175,1319,1568,1760, 1568,1319,1175,1047, 1319,1568,1760,2093, 1760,1568,1319,1175,
|
|
|
|
|
880,1047,1175,1319, 1568,1319,1175,1047, 784,880,1047,1175, 1319,1175,1047,880,
|
|
|
|
|
880,1047,1319,1568, 1319,1047,880,1047, 1047,1175,1568,1760, 1568,1175,1047,1175,
|
|
|
|
|
880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 2349,2093,1760,1319,
|
|
|
|
|
1175,1568,2093,2349, 2093,1760,1568,1175, 880,1319,1760,2349, 2637,2349,1760,1319,
|
|
|
|
|
],
|
|
|
|
|
arp: [
|
|
|
|
|
220,262,330,440, 587,440,330,262, 196,247,294,392, 523,392,294,247,
|
|
|
|
|
220,330,440,587, 784,587,440,330, 196,294,392,523, 698,523,392,294,
|
|
|
|
|
220,330,440,587, 880,587,440,330, 196,294,392,523, 784,523,392,294,
|
|
|
|
|
220,440,587,880, 1175,880,587,440, 196,392,523,784, 1047,784,523,392,
|
|
|
|
|
220,262,330,440, 587,440,330,262, 196,247,294,392, 523,392,294,247,
|
|
|
|
|
220,330,440,587, 880,587,440,330, 196,294,392,523, 784,523,392,294,
|
|
|
|
|
220,440,880,1175, 1568,1175,880,440, 196,392,784,1047, 1568,1047,784,392,
|
|
|
|
|
220,440,880,1568, 2093,1568,880,440, 220,880,1175,1760, 2349,1760,1175,880,
|
|
|
|
|
],
|
|
|
|
|
chords: [[110,131,165],[98,131,147],[131,165,196],[147,175,220],[110,131,165],[131,165,196],[98,131,147],[147,175,220]],
|
|
|
|
|
drums: [
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1,
|
|
|
|
|
1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1,
|
|
|
|
|
1,1,1,1, 2,2,4,2, 1,1,1,1, 2,2,4,2, 2,2,2,2, 2,2,2,2, 2,2,2,2, 2,2,2,2,
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === TRACK GENERATOR ===
|
|
|
|
|
// Procedurally generate tracks from musical parameters for variety
|
|
|
|
|
function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number): MusicTrack {
|
|
|
|
|
// Deterministic RNG
|
|
|
|
|
let s = seed
|
|
|
|
|
const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff }
|
|
|
|
|
|
|
|
|
|
// Build scale note lookup: root freq across octaves
|
|
|
|
|
const allNotes: number[] = []
|
|
|
|
|
for (let oct = -1; oct < 6; oct++) {
|
|
|
|
|
for (const semi of scaleIntervals) {
|
|
|
|
|
allNotes.push(Math.round(root * Math.pow(2, oct + semi / 12)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const sLen = scaleIntervals.length
|
|
|
|
|
const note = (degree: number) => {
|
|
|
|
|
const idx = Math.max(0, Math.min(allNotes.length - 1, degree + sLen)) // offset by 1 octave
|
|
|
|
|
return allNotes[idx]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bass: degrees 0-6, mostly stepwise, bar-level progressions
|
|
|
|
|
const bassProgs = [
|
|
|
|
|
[0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0],
|
|
|
|
|
[0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2],
|
|
|
|
|
[0,0,0,2, 4,4,2,0, 3,3,3,5, 7,5,3,2],
|
|
|
|
|
[0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0],
|
|
|
|
|
]
|
|
|
|
|
const bass: number[] = []
|
|
|
|
|
for (let bar = 0; bar < BARS; bar++) {
|
|
|
|
|
const prog = bassProgs[bar % bassProgs.length]
|
|
|
|
|
const lift = Math.floor(bar / 2) // gradually ascend
|
|
|
|
|
for (let step = 0; step < STEPS; step++) {
|
|
|
|
|
bass.push(note(prog[step % prog.length] + lift))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Lead: degrees 7-20 (octave 2-4), melodic contours
|
|
|
|
|
const leadShapes = [
|
|
|
|
|
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,-2],
|
|
|
|
|
[0,4,7,11, 9,7,4,2, 0,4,9,11, 14,11,9,7],
|
|
|
|
|
[7,9,11,14, 11,9,7,4, 9,11,14,16, 14,11,9,7],
|
|
|
|
|
[0,2,4,2, 7,4,2,0, 4,7,9,11, 9,7,4,2],
|
|
|
|
|
]
|
|
|
|
|
const lead: number[] = []
|
|
|
|
|
for (let bar = 0; bar < BARS; bar++) {
|
|
|
|
|
const shape = leadShapes[bar % leadShapes.length]
|
|
|
|
|
const octShift = sLen + (bar >= 4 ? sLen : 0) // higher octave in second half
|
|
|
|
|
for (let step = 0; step < STEPS; step++) {
|
|
|
|
|
const degree = shape[step % shape.length] + octShift
|
|
|
|
|
// Add slight variation
|
|
|
|
|
const vary = rng() < 0.15 ? (rng() < 0.5 ? 1 : -1) : 0
|
|
|
|
|
lead.push(note(Math.max(sLen, degree + vary)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Arp: degrees in octave 1-3, arpeggiated patterns
|
|
|
|
|
const arpPatterns = [
|
|
|
|
|
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,2],
|
|
|
|
|
[0,4,7,11, 7,4,0,4, 7,11,14,11, 7,4,0,4],
|
|
|
|
|
[0,7,4,11, 7,14,11,7, 4,11,7,14, 11,4,7,0],
|
|
|
|
|
]
|
|
|
|
|
const arp: number[] = []
|
|
|
|
|
for (let bar = 0; bar < BARS; bar++) {
|
|
|
|
|
const pat = arpPatterns[bar % arpPatterns.length]
|
|
|
|
|
const shift = Math.floor(sLen * 0.5) + Math.floor(bar / 3)
|
|
|
|
|
for (let step = 0; step < STEPS; step++) {
|
|
|
|
|
arp.push(note(pat[step % pat.length] + shift))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chords: triads from scale degrees
|
|
|
|
|
const chordDegs = [0, 3, 2, 4, 0, 5, 3, 4]
|
|
|
|
|
const chords: number[][] = []
|
|
|
|
|
for (const d of chordDegs) {
|
|
|
|
|
chords.push([note(d), note(d + 2), note(d + 4)])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drums: base pattern repeated, with builds in later bars
|
|
|
|
|
const drums: number[] = []
|
|
|
|
|
for (let bar = 0; bar < BARS; bar++) {
|
|
|
|
|
for (let step = 0; step < STEPS; step++) {
|
|
|
|
|
let d = drumStyle[step % drumStyle.length]
|
|
|
|
|
// Build: add fills in bars 6-7
|
|
|
|
|
if (bar >= 6 && step >= 12 && d === 3) d = 2
|
|
|
|
|
if (bar >= 7 && step >= 14) d = d === 3 ? 2 : d
|
|
|
|
|
// Extra hats in later bars
|
|
|
|
|
if (bar >= 4 && d === 0 && rng() < 0.2) d = 3
|
|
|
|
|
drums.push(d)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { name, bpm, bass, lead, arp, chords, drums }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drum pattern presets
|
|
|
|
|
const DRUMS_HEAVY = [1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3]
|
|
|
|
|
const DRUMS_GROOVE = [1,0,3,1, 2,0,3,0, 1,0,3,1, 2,0,3,3]
|
|
|
|
|
const DRUMS_CHILL = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,0,0,3]
|
|
|
|
|
const DRUMS_FRANTIC = [1,1,1,3, 2,1,1,1, 1,1,4,1, 2,1,1,1]
|
|
|
|
|
const DRUMS_MARCH = [1,0,1,0, 2,0,1,0, 1,0,1,0, 2,0,4,0]
|
|
|
|
|
const DRUMS_SWING = [1,0,3,0, 2,3,0,3, 1,0,3,1, 2,0,3,0]
|
|
|
|
|
const DRUMS_HALFTIME = [1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0]
|
|
|
|
|
const DRUMS_DNB = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,3,1,3]
|
|
|
|
|
|
|
|
|
|
// Scale presets (semitone intervals)
|
|
|
|
|
const SCALE_MINOR = [0,2,3,5,7,8,10]
|
|
|
|
|
const SCALE_MAJOR = [0,2,4,5,7,9,11]
|
|
|
|
|
const SCALE_DORIAN = [0,2,3,5,7,9,10]
|
|
|
|
|
const SCALE_BLUES = [0,3,5,6,7,10]
|
|
|
|
|
const SCALE_PHRYGIAN = [0,1,3,5,7,8,10]
|
|
|
|
|
const SCALE_MIXOLYDIAN = [0,2,4,5,7,9,10]
|
|
|
|
|
const SCALE_HARMMINOR = [0,2,3,5,7,8,11]
|
|
|
|
|
const SCALE_PENTATONIC = [0,2,4,7,9]
|
|
|
|
|
const SCALE_JAPANESE = [0,1,5,7,8]
|
|
|
|
|
const SCALE_ARABIC = [0,1,4,5,7,8,11]
|
|
|
|
|
|
|
|
|
|
// Track 5: "Chill Lounge" — C major, relaxed, 155bpm
|
|
|
|
|
const track5 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42)
|
|
|
|
|
|
|
|
|
|
// Track 6: "Cyber Punk" — Bb minor, aggressive, 250bpm
|
|
|
|
|
const track6 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99)
|
|
|
|
|
|
|
|
|
|
// Track 7: "Retro Arcade" — G major, bouncy, 195bpm
|
|
|
|
|
const track7 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137)
|
|
|
|
|
|
|
|
|
|
// Track 8: "Boss Battle" — D minor, epic, 240bpm
|
|
|
|
|
const track8 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256)
|
|
|
|
|
|
|
|
|
|
// Track 9: "Jazz Fusion" — Eb dorian, smooth, 175bpm
|
|
|
|
|
const track9 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333)
|
|
|
|
|
|
|
|
|
|
// Track 10: "Metal Mayhem" — E phrygian, thrash, 270bpm
|
|
|
|
|
const track10 = genTrack('Metal Mayhem', 270, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666)
|
|
|
|
|
|
|
|
|
|
// Track 11: "Tropical Storm" — C mixolydian, upbeat, 185bpm
|
|
|
|
|
const track11 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808)
|
|
|
|
|
|
|
|
|
|
// Track 12: "Haunted Circus" — Bb harmonic minor, creepy, 200bpm
|
|
|
|
|
const track12 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313)
|
|
|
|
|
|
|
|
|
|
// Track 13: "Space Opera" — Ab major, majestic, 165bpm
|
|
|
|
|
const track13 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001)
|
|
|
|
|
|
|
|
|
|
// Track 14: "Funk Machine" — D mixolydian, groovy, 210bpm
|
|
|
|
|
const track14 = genTrack('Funk Machine', 210, 147, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 420)
|
|
|
|
|
|
|
|
|
|
// Track 15: "Viking Raid" — A minor, epic march, 225bpm
|
|
|
|
|
const track15 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793)
|
|
|
|
|
|
|
|
|
|
// Track 16: "Synthwave Dream" — F minor, dreamy, 170bpm
|
|
|
|
|
const track16 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984)
|
|
|
|
|
|
|
|
|
|
// Track 17: "Drum & Bass" — G minor, frantic, 255bpm
|
|
|
|
|
const track17 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174)
|
|
|
|
|
|
|
|
|
|
// Track 18: "Western Duel" — E blues, twangy, 190bpm
|
|
|
|
|
const track18 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SWING, 1865)
|
|
|
|
|
|
|
|
|
|
// Track 19: "Samurai Storm" — B japanese, intense, 245bpm
|
|
|
|
|
const track19 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603)
|
|
|
|
|
|
|
|
|
|
// Track 20: "Disco Inferno" — Bb major, groovy, 215bpm
|
|
|
|
|
const track20 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977)
|
|
|
|
|
|
|
|
|
|
const ALL_TRACKS = [track1, track2, track3, track4, track5, track6, track7, track8, track9, track10, track11, track12, track13, track14, track15, track16, track17, track18, track19, track20]
|
|
|
|
|
let activeTrack: MusicTrack = track1
|
|
|
|
|
let barIndex = 0
|
|
|
|
|
|
|
|
|
|
function playMusicBar() {
|
|
|
|
|
if (!musicPlaying || !musicGain) return
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
getMusicDelay() // ensure delay is created
|
|
|
|
|
|
|
|
|
|
// Dynamic tempo: base BPM shifts with intensity (+30 BPM at max)
|
|
|
|
|
const dynamicBpm = activeTrack.bpm + currentIntensity * 30
|
|
|
|
|
const beat = 60 / dynamicBpm
|
|
|
|
|
const now = c.currentTime + 0.05
|
|
|
|
|
const bar = barIndex % BARS
|
|
|
|
|
const off = bar * STEPS
|
|
|
|
|
const step = beat / 2
|
|
|
|
|
|
|
|
|
|
// Switch tracks at bar boundaries based on intensity
|
|
|
|
|
// Categorize tracks by energy level
|
|
|
|
|
const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185) // Chill Lounge, Space Opera, Synthwave Dream, Jazz Fusion, Tropical Storm
|
|
|
|
|
const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230) // Retro Arcade, Western Duel, Haunted Circus, Funk Machine, Disco Inferno, Neon Fury, Viking Raid
|
|
|
|
|
const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230) // Dark Circuit, Pixel Blitz, Skull Crusher, Cyber Punk, Boss Battle, Metal Mayhem, Samurai Storm, Drum & Bass
|
|
|
|
|
if (barIndex > 0 && bar === 0) {
|
|
|
|
|
if (currentIntensity > 0.7 && activeTrack.bpm < 230) {
|
|
|
|
|
activeTrack = intenseTracks[Math.floor(Math.random() * intenseTracks.length)]
|
|
|
|
|
} else if (currentIntensity < 0.3 && activeTrack.bpm >= 200) {
|
|
|
|
|
activeTrack = chillTracks[Math.floor(Math.random() * chillTracks.length)]
|
|
|
|
|
} else if (currentIntensity >= 0.3 && currentIntensity <= 0.7 && Math.random() < 0.3) {
|
|
|
|
|
activeTrack = midTracks[Math.floor(Math.random() * midTracks.length)]
|
|
|
|
|
} else if (Math.random() < 0.2) {
|
|
|
|
|
// Random switch for variety
|
|
|
|
|
const others = ALL_TRACKS.filter(t => t !== activeTrack)
|
|
|
|
|
activeTrack = others[Math.floor(Math.random() * others.length)]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chord pad for the whole bar
|
|
|
|
|
const barDur = STEPS * step
|
|
|
|
|
chordPad(activeTrack.chords[bar].map(f => f * 2), barDur, musicGain, now)
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < STEPS; i++) {
|
|
|
|
|
const t = now + i * step
|
|
|
|
|
const idx = off + i
|
|
|
|
|
const nd = step - 0.01
|
|
|
|
|
|
|
|
|
|
// Layer 1: Bass — always plays but gets thicker with intensity
|
|
|
|
|
if (activeTrack.bass[idx] > 0) thickBass(activeTrack.bass[idx], nd, musicGain, t)
|
|
|
|
|
|
|
|
|
|
// Layer 2: FM lead — fades in above 0.3 intensity
|
|
|
|
|
if (activeTrack.lead[idx] > 0 && currentIntensity > 0.3) {
|
|
|
|
|
fmLead(activeTrack.lead[idx], nd * 0.8, musicGain, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Layer 3: Arpeggio — fades in above 0.5 intensity
|
|
|
|
|
if (activeTrack.arp[idx] > 0 && currentIntensity > 0.5) {
|
|
|
|
|
chorusTone(activeTrack.arp[idx], 'triangle', nd * 0.6, musicGain, t, 0.04 + currentIntensity * 0.04)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Layer 4: Drums — always, but intensity controls density
|
|
|
|
|
const d = activeTrack.drums[idx]
|
|
|
|
|
if (d === 1) kick(musicGain, t)
|
|
|
|
|
if (d === 2 && currentIntensity > 0.2) snare(musicGain, t)
|
|
|
|
|
if (d === 3) hihat(musicGain, t, false)
|
|
|
|
|
if (d === 4 && currentIntensity > 0.4) hihat(musicGain, t, true)
|
|
|
|
|
|
|
|
|
|
// Extra percussion at high intensity
|
|
|
|
|
if (currentIntensity > 0.8 && i % 2 === 0 && Math.random() < 0.3) {
|
|
|
|
|
hihat(musicGain, t, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
barIndex++
|
|
|
|
|
musicTimeout = window.setTimeout(playMusicBar, barDur * 1000 - 50)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function startMusic() {
|
|
|
|
|
getCtx()
|
|
|
|
|
if (musicPlaying) return
|
|
|
|
|
// Pick a random track each time
|
|
|
|
|
activeTrack = ALL_TRACKS[Math.floor(Math.random() * ALL_TRACKS.length)]
|
|
|
|
|
musicPlaying = true
|
|
|
|
|
barIndex = 0
|
|
|
|
|
playMusicBar()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stopMusic() {
|
|
|
|
|
musicPlaying = false
|
|
|
|
|
if (musicTimeout) {
|
|
|
|
|
clearTimeout(musicTimeout)
|
|
|
|
|
musicTimeout = null
|
|
|
|
|
}
|
|
|
|
|
// Clean up delay
|
|
|
|
|
if (delayNode) { delayNode.disconnect(); delayNode = null }
|
|
|
|
|
if (delayGain) { delayGain.disconnect(); delayGain = null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setMusicVolume(v: number) {
|
|
|
|
|
if (musicGain) musicGain.gain.value = Math.max(0, Math.min(1, v))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setSfxVolume(v: number) {
|
|
|
|
|
if (sfxGain) sfxGain.gain.value = Math.max(0, Math.min(1, v))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === CROWD SOUNDS ===
|
|
|
|
|
// Procedural crowd reactions using layered noise + filtered tones
|
|
|
|
|
|
|
|
|
|
export function sfxCrowdOoh() {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const d = sfxGain ?? c.destination
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
// Rising "ooh" — filtered noise sweep
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sine'
|
|
|
|
|
osc.frequency.setValueAtTime(200 + i * 60, t)
|
|
|
|
|
osc.frequency.linearRampToValueAtTime(350 + i * 80, t + 0.4)
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.06, t)
|
|
|
|
|
g.gain.linearRampToValueAtTime(0.12, t + 0.15)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.6)
|
|
|
|
|
osc.connect(g); g.connect(d)
|
|
|
|
|
osc.start(t + i * 0.03); osc.stop(t + 0.6)
|
|
|
|
|
}
|
|
|
|
|
noise(0.3, d, t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxCrowdGasp() {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const d = sfxGain ?? c.destination
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
// Sharp intake — high noise burst + quick sine chirps
|
|
|
|
|
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.15))
|
|
|
|
|
const buf = c.createBuffer(1, bufSize, c.sampleRate)
|
|
|
|
|
const data = buf.getChannelData(0)
|
|
|
|
|
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2000; bp.Q.value = 0.8
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.18, t)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.2)
|
|
|
|
|
src.connect(bp); bp.connect(g); g.connect(d)
|
|
|
|
|
src.start(t); src.stop(t + 0.2)
|
|
|
|
|
// Multiple pitched gasps
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
const osc = c.createOscillator()
|
|
|
|
|
osc.type = 'sine'
|
|
|
|
|
osc.frequency.value = 400 + Math.random() * 300
|
|
|
|
|
const og = c.createGain()
|
|
|
|
|
og.gain.setValueAtTime(0.04, t + i * 0.02)
|
|
|
|
|
og.gain.exponentialRampToValueAtTime(0.001, t + 0.25)
|
|
|
|
|
osc.connect(og); og.connect(d)
|
|
|
|
|
osc.start(t + i * 0.02); osc.stop(t + 0.25)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxCrowdCheer() {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const d = sfxGain ?? c.destination
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
// Layered noise + sine clusters = roaring crowd
|
|
|
|
|
for (let layer = 0; layer < 3; layer++) {
|
|
|
|
|
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.8))
|
|
|
|
|
const buf = c.createBuffer(1, bufSize, c.sampleRate)
|
|
|
|
|
const data = buf.getChannelData(0)
|
|
|
|
|
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const bp = c.createBiquadFilter(); bp.type = 'bandpass'
|
|
|
|
|
bp.frequency.value = 600 + layer * 400; bp.Q.value = 0.5
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.01, t)
|
|
|
|
|
g.gain.linearRampToValueAtTime(0.1, t + 0.15)
|
|
|
|
|
g.gain.setValueAtTime(0.1, t + 0.5)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.8)
|
|
|
|
|
src.connect(bp); bp.connect(g); g.connect(d)
|
|
|
|
|
src.start(t + layer * 0.05); src.stop(t + 0.8)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxApplause() {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const d = sfxGain ?? c.destination
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
// Crackling filtered noise = many hands clapping
|
|
|
|
|
for (let burst = 0; burst < 6; burst++) {
|
|
|
|
|
const delay = burst * 0.12 + Math.random() * 0.05
|
|
|
|
|
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.06))
|
|
|
|
|
const buf = c.createBuffer(1, bufSize, c.sampleRate)
|
|
|
|
|
const data = buf.getChannelData(0)
|
|
|
|
|
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3000 + Math.random() * 2000
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(0.08 + Math.random() * 0.04, t + delay)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + delay + 0.08)
|
|
|
|
|
src.connect(hp); hp.connect(g); g.connect(d)
|
|
|
|
|
src.start(t + delay); src.stop(t + delay + 0.08)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfxDrumRoll() {
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
const d = sfxGain ?? c.destination
|
|
|
|
|
const t = c.currentTime
|
|
|
|
|
// Rapid snare hits building in intensity
|
|
|
|
|
const hits = 16
|
|
|
|
|
for (let i = 0; i < hits; i++) {
|
|
|
|
|
const hitTime = t + i * 0.04
|
|
|
|
|
const vol = 0.05 + (i / hits) * 0.15
|
|
|
|
|
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.03))
|
|
|
|
|
const buf = c.createBuffer(1, bufSize, c.sampleRate)
|
|
|
|
|
const data = buf.getChannelData(0)
|
|
|
|
|
for (let i2 = 0; i2 < bufSize; i2++) data[i2] = Math.random() * 2 - 1
|
|
|
|
|
const src = c.createBufferSource(); src.buffer = buf
|
|
|
|
|
const g = c.createGain()
|
|
|
|
|
g.gain.setValueAtTime(vol, hitTime)
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, hitTime + 0.04)
|
|
|
|
|
src.connect(g); g.connect(d)
|
|
|
|
|
src.start(hitTime); src.stop(hitTime + 0.04)
|
|
|
|
|
// Body tone
|
|
|
|
|
tone(180 + i * 5, 'triangle', 0.025, d, hitTime)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function announceCrowdReaction(type: 'cheer' | 'gasp' | 'ooh' | 'applause') {
|
|
|
|
|
const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause }
|
|
|
|
|
fns[type]()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === MUSIC INTENSITY ===
|
|
|
|
|
// Dynamically adjust music volume/energy based on fight state
|
|
|
|
|
|
|
|
|
|
let currentIntensity = 0.5
|
|
|
|
|
|
|
|
|
|
export function setMusicIntensity(level: number) {
|
|
|
|
|
// level: 0.0 (calm) to 1.0 (maximum hype)
|
|
|
|
|
currentIntensity = Math.max(0, Math.min(1, level))
|
|
|
|
|
if (!musicGain) return
|
|
|
|
|
const baseVol = 0.06
|
|
|
|
|
const maxVol = 0.18
|
|
|
|
|
const targetVol = baseVol + (maxVol - baseVol) * currentIntensity
|
|
|
|
|
const c = getCtx()
|
|
|
|
|
musicGain.gain.cancelScheduledValues(c.currentTime)
|
|
|
|
|
musicGain.gain.setTargetAtTime(targetVol, c.currentTime, 0.3)
|
|
|
|
|
}
|