From d998cb3ad0d132eb6dfb9c218aaa19dcb2d02cce Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 22:11:58 +0000 Subject: [PATCH] refactor: split sounds.ts into audio/ modules Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.vue | 2 +- frontend/src/components/FightViewer.vue | 2 +- frontend/src/game/FightScene.ts | 2 +- frontend/src/game/audio/context.ts | 123 ++++ frontend/src/game/audio/index.ts | 215 ++++++ frontend/src/game/audio/music.ts | 497 +++++++++++++ frontend/src/game/audio/primitives.ts | 247 +++++++ frontend/src/game/audio/sfx.ts | 887 ++++++++++++++++++++++++ frontend/src/game/audio/voice.ts | 764 ++++++++++++++++++++ frontend/src/pages/FightPage.vue | 2 +- frontend/src/pages/JoinBoutPage.vue | 2 +- frontend/src/pages/SoundboardPage.vue | 2 +- 12 files changed, 2739 insertions(+), 6 deletions(-) create mode 100644 frontend/src/game/audio/context.ts create mode 100644 frontend/src/game/audio/index.ts create mode 100644 frontend/src/game/audio/music.ts create mode 100644 frontend/src/game/audio/primitives.ts create mode 100644 frontend/src/game/audio/sfx.ts create mode 100644 frontend/src/game/audio/voice.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b750dce..2f72af5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,7 +2,7 @@ import { onMounted } from 'vue' import { RouterView } from 'vue-router' import NavBar from './components/NavBar.vue' -import { ensureAudioContext } from './game/sounds' +import { ensureAudioContext } from './game/audio' // Unlock AudioContext + SpeechSynthesis on first user interaction (mobile requires gesture) onMounted(() => { diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index e7fec19..b67e5d7 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -11,7 +11,7 @@ import { speakQuestion, speakAnswer, speakNarration, prefetchQuestion, prefetchAnswer, prefetchNarration, sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, -} from '../game/sounds' +} from '../game/audio' import { isKokoroLoading, getKokoroProgress } from '../game/tts' interface Round { diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 1b1543a..85a4a81 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -25,7 +25,7 @@ import { sfxTacoBellBong, sfxWindowsError, sfxMemeThud, sfxSadTrombone, sfxFailHorn, sfxPriceIsRightFail, sfxBuzzer, sfxMissionFailed, sfxCrickets, sfxSadViolin, sfxEmotionalDamage, sfxDunDunDun, -} from './sounds' +} from './audio' export interface FightSceneConfig { canvas: HTMLCanvasElement diff --git a/frontend/src/game/audio/context.ts b/frontend/src/game/audio/context.ts new file mode 100644 index 0000000..762b89a --- /dev/null +++ b/frontend/src/game/audio/context.ts @@ -0,0 +1,123 @@ +// Audio context, gain nodes, volume/mute management +// This module has ZERO imports from other audio modules to prevent circular deps. + +let ctx: AudioContext | null = null +let musicGain: GainNode | null = null +let sfxGain: GainNode | null = null +let musicPlaying = false +let musicTimeout: number | null = null + +// AudioContext is ONLY created inside ensureAudioContext() (called from user gesture). +// Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings. +let _audioUnlocked = false + +let masterMuted = false +const MUSIC_VOL = 0.12 +const SFX_VOL = 0.25 + +export 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) +} + +export function getCtx(): AudioContext { + if (!ctx) initCtx() + if (ctx!.state === 'suspended') { + ctx!.resume().catch(() => {}) + } + return ctx! +} + +// Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid) +export function getSfxDest(): AudioNode { + const c = getCtx() + return sfxGain ?? c.destination +} + +export function getMusicGain(): GainNode | null { + return musicGain +} + +export function getSfxGain(): GainNode | null { + return sfxGain +} + +export function isMusicPlaying(): boolean { + return musicPlaying +} + +export function setMusicPlaying(v: boolean) { + musicPlaying = v +} + +export function getMusicTimeout(): number | null { + return musicTimeout +} + +export function setMusicTimeout(v: number | null) { + musicTimeout = v +} + +export function isAudioUnlocked(): boolean { + return _audioUnlocked +} + +export function setAudioUnlocked(v: boolean) { + _audioUnlocked = v +} + +export function isMasterMuted(): boolean { + return masterMuted +} + +export function getMasterMuted(): boolean { + return masterMuted +} + +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)) +} + +export function setMasterMute(muted: boolean) { + masterMuted = muted + if (muted) { + if (musicGain) musicGain.gain.value = 0 + if (sfxGain) sfxGain.gain.value = 0 + // Note: kokoroStop and speechSynthesis.cancel are called from the caller + // to avoid circular deps — see index.ts setMasterMute wrapper + } else { + if (musicGain) musicGain.gain.value = MUSIC_VOL + if (sfxGain) sfxGain.gain.value = SFX_VOL + } +} + +// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers, +// then reconnect so future sounds still work +export function resetGainNodes() { + 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) + } + } +} + +export { MUSIC_VOL, SFX_VOL } diff --git a/frontend/src/game/audio/index.ts b/frontend/src/game/audio/index.ts new file mode 100644 index 0000000..854ba75 --- /dev/null +++ b/frontend/src/game/audio/index.ts @@ -0,0 +1,215 @@ +// Barrel file — re-exports everything from audio sub-modules + cross-cutting functions + +import { getCtx, resetGainNodes, setMasterMute as _ctxSetMasterMute } from './context' +import { stopMusic } from './music' +import { resetVoiceState, loadVoices, voicesLoaded, _speechUnlocked, setSpeechUnlocked } from './voice' +import { kokoroStop, initKokoro, setAudioContext } from '../tts' + +// === CROSS-CUTTING: stopAllAudio === +// Kills music, TTS, speech synthesis, resets gain nodes so in-flight sounds die instantly + +export function stopAllAudio() { + stopMusic() + kokoroStop() + if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() + resetVoiceState() + resetGainNodes() +} + +// === CROSS-CUTTING: ensureAudioContext === +// Must be called from user gesture (click/tap) to unlock audio on mobile browsers + +export async function ensureAudioContext() { + const c = getCtx() + if (c.state === 'suspended') { + try { await c.resume() } catch {} + } + // Share AudioContext with kokoro and start loading the model. + // Defer initKokoro so the click handler finishes first — the 4.8MB module + // parse + 86MB model download happen after the UI is unblocked. + setAudioContext(c) + setTimeout(() => initKokoro(), 0) + // Prime speech synthesis as fallback — mobile browsers require + // a speak() call inside a user gesture to unlock speechSynthesis + if (typeof speechSynthesis !== 'undefined') { + if (!voicesLoaded) loadVoices() + if (!_speechUnlocked) { + setSpeechUnlocked(true) + const unlock = new SpeechSynthesisUtterance('') + unlock.volume = 0 + speechSynthesis.speak(unlock) + } + } +} + +// === CROSS-CUTTING: setMasterMute wrapper === +// Wraps context.setMasterMute to also kill TTS/speech (avoids circular dep in context.ts) + +export function setMasterMute(muted: boolean) { + _ctxSetMasterMute(muted) + if (muted) { + kokoroStop() + if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() + } +} + +// === RE-EXPORTS === + +// context — public API only (internal accessors stay in context.ts) +export { + getCtx, + getSfxDest, + getMusicGain, + getSfxGain, + isMusicPlaying, + setMusicPlaying, + isAudioUnlocked, + setAudioUnlocked, + isMasterMuted, + getMasterMuted, + setMusicVolume, + setSfxVolume, + resetGainNodes, + MUSIC_VOL, + SFX_VOL, +} from './context' + +// primitives — mostly used internally, but re-export for FightScene/testing +export { + tone, + noise, + sweep, + reverbTail, + bodyThump, + highSnap, + fmImpact, + chorusTone, + fmLead, + kick, + snare, + hihat, +} from './primitives' + +// sfx — all SFX functions +export { + fanfareRound, + fanfareFight, + fanfareDevastating, + fanfareCritical, + fanfareCombo, + sfxPunch, + sfxKick, + sfxSpecial, + sfxCritical, + sfxGunshot, + sfxBulletHit, + sfxJetpack, + sfxExplosion, + sfxBlock, + sfxDodge, + sfxClash, + sfxKO, + sfxPerfect, + sfxWin, + sfxWinAnnounce, + sfxRoundStart, + sfxBoing, + sfxWomp, + sfxSlideUp, + sfxSlideDown, + sfxBonk, + sfxSplat, + sfxZap, + sfxZoomWhoosh, + sfxRapidPunch, + sfxPowerUp, + sfxCoin, + sfxFail, + sfxVineBoom, + sfxAirHorn, + sfxBruh, + sfxFart, + sfxRecordScratch, + sfxRubberChicken, + sfxSqueakyToy, + sfxWetSlap, + sfxBoneCrack, + sfxCartoonRun, + sfxRimShot, + sfxSlideWhistleUp, + sfxSlideWhistleDown, + sfxYippee, + sfxDing, + sfxTacoBellBong, + sfxWindowsError, + sfxMemeThud, + sfxSadTrombone, + sfxEmotionalDamage, + sfxDunDunDun, + sfxFailHorn, + sfxPriceIsRightFail, + sfxBuzzer, + sfxSadViolin, + sfxMissionFailed, + sfxCrickets, + sfxRandomSilly, + sfxRandomComedy, + sfxRandomFail, + sfxCrowdOoh, + sfxCrowdGasp, + sfxCrowdCheer, + sfxApplause, + sfxDrumRoll, + announceCrowdReaction, +} from './sfx' + +// voice — all voice/announce/TTS functions +export { + type VoiceProfile, + voicesLoaded, + voiceProfiles, + loadVoices, + resetVoiceState, + speak, + speakAsync, + announce, + announceDeep, + announceFast, + announceRobot, + announceScream, + announceSmooth, + announceRandom, + announceDramatic, + announceHype, + announceSilly, + announceCool, + announceFinishHim, + announceFatality, + announceFlawlessVictory, + announceRandomHype, + announceDeepIntro, + announceRoundHype, + announceCreatorEntrance, + announceCreatorRound, + announceCreatorKO, + announceCreatorWin, + announceCreatorLose, + announceCreatorMorph, + announceCreatorCameo, + announceCreatorTaunt, + announceCreatorDevastating, + creatorAnswerVoiceKey, + speakQuestion, + speakAnswer, + speakNarration, + prefetchQuestion, + prefetchAnswer, + prefetchNarration, +} from './voice' + +// music +export { + startMusic, + stopMusic, + setMusicIntensity, +} from './music' diff --git a/frontend/src/game/audio/music.ts b/frontend/src/game/audio/music.ts new file mode 100644 index 0000000..c5e58f8 --- /dev/null +++ b/frontend/src/game/audio/music.ts @@ -0,0 +1,497 @@ +// Music tracks, playback, and intensity control + +import { getCtx, getMusicGain, isMusicPlaying, setMusicPlaying, getMusicTimeout, setMusicTimeout } from './context' +import { chorusTone, fmLead, kick, snare, hihat, setDelayNodeGetter, noise, tone } from './primitives' + +// === MUSIC CONSTANTS === + +const BARS = 8 +const STEPS = 16 // per bar + +// Shared delay effect for fullness +let delayNode: DelayNode | null = null +let delayGain: GainNode | null = null + +// Register delay node getter so primitives (fmLead etc.) can access it +setDelayNodeGetter(() => delayNode) + +function getMusicDelay(): GainNode { + const musicGain = getMusicGain() + 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 +} + +// === GENRE-SPECIFIC SYNTH VARIANTS === + +// Thick distorted sub-bass with overtones +function thickBass(freq: number, dur: number, dest: AudioNode, t: number) { + const c = getCtx() + 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) + 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) + 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) +} + +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) + } +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) + } +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) + } +} + +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 = { + 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 = { + 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 = { + 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 = { + 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 === +interface MusicTrack { + name: string + bpm: number + synthStyle: SynthStyle + bass: number[] + lead: number[] + arp: number[] + chords: number[][] + drums: number[] +} + +// I'm inlining ALL 20 tracks inline as a single constant to keep +// the file self-contained. Each track is identical to the original sounds.ts. + +const track1: MusicTrack = {name:'Desert Storm',bpm:200,synthStyle:'epic',bass:[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],lead:[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],arp:[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],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]],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,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]} +const track2: MusicTrack = {name:"Vampire's Requiem",bpm:190,synthStyle:'gothic',bass:[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],lead:[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],arp:[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],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]],drums:[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]} +const track3: MusicTrack = {name:'Street Heat',bpm:175,synthStyle:'funky',bass:[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],lead:[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],arp:[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],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]],drums:[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]} +const track4: MusicTrack = {name:'Steel Resolve',bpm:210,synthStyle:'heroic',bass:[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],lead:[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],arp:[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],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]],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,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]} +const track5: MusicTrack = {name:'Thunder Blade',bpm:230,synthStyle:'metal',bass:[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],lead:[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],arp:[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],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]],drums:[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]} +const track6: MusicTrack = {name:'Crystal Elegy',bpm:150,synthStyle:'emotional',bass:[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],lead:[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],arp:[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],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]],drums:[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]} +const track7: MusicTrack = {name:'Shadow Assault',bpm:215,synthStyle:'ninja',bass:[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],lead:[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],arp:[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],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]],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,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]} +const track8: MusicTrack = {name:'Emerald Rush',bpm:195,synthStyle:'speed',bass:[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],lead:[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],arp:[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],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]],drums:[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]} +const track9: MusicTrack = {name:'Alien Abyss',bpm:140,synthStyle:'atmospheric',bass:[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],lead:[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],arp:[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],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]],drums:[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]} +const track10: MusicTrack = {name:'Halving Day',bpm:162,synthStyle:'heroic',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],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]],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]} +const track11: MusicTrack = {name:'Jungle Protocol',bpm:174,synthStyle:'speed',bass:[110,0,0,110,0,0,110,0,131,0,0,131,0,110,0,0,110,0,0,110,0,0,165,0,131,0,0,131,0,175,0,0,147,0,0,147,0,0,147,0,165,0,0,165,0,147,0,0,110,0,0,110,0,0,110,165,131,0,0,131,0,0,0,0,110,0,110,0,110,0,110,131,131,0,131,0,131,110,0,0,147,0,147,0,147,0,147,165,165,0,165,0,165,147,0,0,175,0,175,0,175,0,175,196,196,0,196,0,196,175,0,0,110,0,110,131,131,0,165,147,110,0,131,0,110,0,0,0],lead:[440,0,523,0,659,0,523,440,0,0,523,659,784,0,659,523,440,0,523,0,659,784,659,523,440,523,440,0,523,659,784,0,587,0,698,0,880,0,698,587,0,0,698,880,1047,0,880,698,440,523,659,784,659,523,440,0,523,440,0,0,440,0,0,0,880,0,784,659,523,0,440,523,659,784,880,784,659,523,440,0,1047,0,880,784,659,0,523,659,784,880,1047,880,784,659,523,0,698,0,587,0,698,0,784,880,1047,880,784,698,587,0,523,587,659,784,880,1047,880,784,659,523,440,523,659,0,440,0,0,0],arp:[440,523,659,523,440,523,659,784,659,523,440,523,659,784,659,523,523,659,784,659,523,659,784,1047,784,659,523,659,784,1047,784,659,587,698,880,698,587,698,880,1047,880,698,587,698,880,1047,880,698,440,523,659,523,440,659,523,440,523,440,0,440,523,659,784,0,880,659,440,523,659,880,659,523,440,523,659,880,1047,880,659,523,1047,784,523,659,784,1047,784,659,523,659,784,1047,1319,1047,784,659,698,880,1047,880,698,880,1047,1397,1047,880,698,880,1047,1397,1047,880,659,784,880,659,523,659,784,523,440,523,659,440,440,0,0,0],chords:[[220,262,330],[262,330,392],[165,196,247],[147,175,220],[220,262,330],[175,220,262],[196,247,294],[220,262,330]],drums:[1,0,3,3,0,0,2,0,0,3,1,0,2,0,3,0,1,0,3,3,0,0,2,3,0,3,1,0,2,3,0,3,1,0,3,3,0,0,2,0,0,3,1,0,2,0,3,0,1,0,3,3,0,3,2,3,0,3,1,3,2,3,4,3,1,3,0,3,2,0,1,3,0,3,2,0,1,3,0,3,2,0,1,3,0,3,2,3,1,3,0,3,2,3,1,3,1,3,2,3,1,0,2,3,1,3,2,0,1,3,2,3,1,0,2,0,1,0,2,0,1,1,2,2,1,2,4,0]} +const track12: MusicTrack = {name:'Liquid Lightning',bpm:172,synthStyle:'speed',bass:[147,0,0,147,0,0,147,0,175,0,0,175,0,147,0,0,131,0,0,131,0,0,131,0,147,0,0,147,0,131,0,0,110,0,0,110,0,0,110,0,131,0,0,131,0,110,0,0,98,0,0,98,0,0,98,131,110,0,0,110,0,0,0,0,147,0,147,0,175,0,147,175,196,0,175,0,147,175,0,0,131,0,131,0,147,0,131,147,165,0,147,0,131,147,0,0,110,0,110,131,131,0,147,0,165,0,147,131,110,0,131,0,98,0,110,0,131,0,147,0,110,0,98,0,98,0,0,0],lead:[587,0,698,784,880,0,784,698,587,0,698,784,1047,0,880,784,523,0,587,698,784,0,698,587,523,0,587,698,880,0,784,698,440,0,523,587,659,0,587,523,440,523,587,659,784,659,587,523,392,0,440,523,587,0,523,440,392,440,523,587,523,0,0,0,1175,1047,880,784,698,587,698,784,880,1047,1175,1047,880,784,698,587,1047,880,784,698,587,523,587,698,784,880,1047,880,784,698,587,523,880,784,698,587,523,440,523,587,698,784,880,784,698,587,523,440,784,698,587,523,440,523,587,698,587,523,440,0,392,0,0,0],arp:[587,698,880,698,587,698,880,1047,880,698,587,698,880,1047,880,698,523,659,784,659,523,659,784,1047,784,659,523,659,784,1047,784,659,440,523,659,523,440,523,659,880,659,523,440,523,659,880,659,523,392,494,587,494,392,494,587,784,587,494,392,494,587,784,587,494,1047,880,698,587,698,880,1047,880,698,587,698,880,1047,1397,1047,880,880,784,587,523,587,784,880,784,587,523,587,784,880,1175,880,784,698,587,440,392,440,587,698,587,440,392,440,587,698,1047,698,587,587,523,392,294,392,523,587,523,392,294,392,523,587,0,0,0],chords:[[147,175,220],[131,165,196],[110,131,165],[98,123,147],[147,175,220],[131,165,196],[110,131,165],[147,175,220]],drums:[1,0,3,0,2,0,3,3,1,3,0,0,2,0,3,0,1,0,3,0,2,3,3,0,0,3,1,3,2,0,3,3,1,0,3,0,2,0,3,3,1,3,0,0,2,0,3,0,1,3,3,0,2,0,3,3,1,3,1,3,2,3,4,0,1,3,2,0,0,3,1,0,2,3,0,3,1,0,2,3,0,3,1,3,2,0,0,3,1,3,2,0,0,3,1,3,1,0,2,3,1,3,2,0,1,0,2,3,1,3,2,3,1,3,1,3,2,3,1,3,2,2,1,1,2,2,4,0]} +const track13: MusicTrack = {name:'Midnight Stack',bpm:92,synthStyle:'atmospheric',bass:[82,0,0,0,0,0,82,0,0,0,0,0,82,0,0,0,73,0,0,0,0,0,73,0,0,0,0,0,73,0,0,0,65,0,0,0,0,0,65,0,0,0,0,0,65,0,73,0,87,0,0,0,0,0,87,0,0,0,0,0,82,0,0,0,82,0,0,0,82,0,0,82,0,0,0,0,82,0,0,0,73,0,0,0,73,0,0,73,0,0,0,0,73,0,0,0,65,0,0,0,65,0,0,65,0,0,0,65,73,0,82,0,87,0,0,0,87,0,0,87,0,0,0,0,82,0,0,0],lead:[330,0,0,392,0,0,330,0,0,294,0,0,330,0,392,0,294,0,0,330,0,0,294,0,0,262,0,0,294,0,330,0,262,0,0,330,0,0,392,0,0,330,0,0,262,0,294,0,349,0,0,330,0,0,294,0,0,262,0,0,330,0,0,0,659,0,0,587,0,0,523,0,0,494,0,0,523,0,587,0,587,0,0,523,0,0,494,0,0,440,0,0,494,0,523,0,523,0,0,587,0,0,659,0,0,587,0,0,523,0,494,0,698,0,0,659,0,0,587,0,0,523,0,0,494,0,0,0],arp:[330,392,494,392,330,0,0,0,392,494,587,494,392,0,0,0,294,349,440,349,294,0,0,0,349,440,523,440,349,0,0,0,262,330,392,330,262,0,0,0,330,392,494,392,330,0,0,0,349,440,523,440,349,0,0,0,330,392,494,392,330,0,0,0,659,494,392,330,0,0,330,392,494,659,0,0,587,494,392,0,587,440,349,294,0,0,294,349,440,587,0,0,523,440,349,0,523,392,330,262,0,0,262,330,392,523,0,0,494,392,330,0,698,523,440,349,0,0,349,440,523,698,0,0,659,523,440,0],chords:[[165,196,247],[147,175,220],[131,165,196],[175,208,262],[165,196,247],[147,175,220],[131,165,196],[165,196,247]],drums:[1,0,0,0,0,0,3,0,2,0,0,0,0,0,3,0,1,0,0,0,0,0,3,0,2,0,0,0,0,0,0,0,1,0,0,0,0,0,3,0,2,0,0,0,0,0,3,0,1,0,0,0,0,0,3,0,2,0,0,0,0,3,0,0,1,0,3,0,0,0,3,0,2,0,0,0,0,0,3,0,1,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,1,0,3,0,0,0,3,0,2,0,0,0,0,0,3,0,1,0,0,0,0,3,3,0,2,0,0,0,0,0,0,0]} +const track14: MusicTrack = {name:"Satoshi's Lullaby",bpm:88,synthStyle:'atmospheric',bass:[110,0,0,0,0,0,0,0,110,0,0,0,0,0,110,0,131,0,0,0,0,0,0,0,131,0,0,0,0,0,131,0,98,0,0,0,0,0,0,0,98,0,0,0,0,0,98,0,87,0,0,0,0,0,0,0,87,0,0,0,0,0,0,0,110,0,0,0,110,0,0,0,0,0,0,0,110,0,0,0,131,0,0,0,131,0,0,0,0,0,0,0,131,0,0,0,98,0,0,0,98,0,0,0,0,0,0,0,98,0,110,0,87,0,0,0,87,0,0,0,0,0,0,0,110,0,0,0],lead:[440,0,0,523,0,0,0,0,440,0,0,392,0,0,0,0,523,0,0,587,0,0,0,0,523,0,0,494,0,0,0,0,392,0,0,440,0,0,0,0,392,0,0,349,0,0,0,0,349,0,0,392,0,0,0,0,330,0,0,0,0,0,0,0,880,0,0,784,0,0,659,0,587,0,0,523,0,0,0,0,1047,0,0,880,0,0,784,0,659,0,0,587,0,0,0,0,784,0,0,659,0,0,587,0,523,0,0,440,0,0,494,0,698,0,0,659,0,0,587,0,523,0,0,0,440,0,0,0],arp:[220,262,330,262,220,0,0,0,262,330,392,330,262,0,0,0,262,330,392,330,262,0,0,0,330,392,494,392,330,0,0,0,196,247,294,247,196,0,0,0,247,294,349,294,247,0,0,0,175,220,262,220,175,0,0,0,220,262,330,262,220,0,0,0,440,330,262,220,0,0,262,330,440,0,0,0,392,330,262,0,523,392,330,262,0,0,330,392,523,0,0,0,494,392,330,0,392,294,247,196,0,0,247,294,392,0,0,0,349,294,247,0,349,262,220,175,0,0,220,262,349,0,0,0,330,262,220,0],chords:[[220,262,330],[262,330,392],[196,247,294],[175,220,262],[220,262,330],[262,330,392],[196,247,294],[220,262,330]],drums:[1,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,1,0,0,0,0,0,3,0,2,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,1,0,0,0,0,0,3,0,2,0,0,0,0,0,3,0,1,0,0,0,0,0,3,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,0,0,1,0,3,0,0,0,3,0,2,0,0,0,0,0,0,0]} +const track15: MusicTrack = {name:'Skanking Satoshi',bpm:210,synthStyle:'funky',bass:[196,0,196,0,196,0,196,196,247,0,247,0,247,0,247,0,262,0,262,0,262,0,262,262,294,0,294,0,294,0,294,0,196,0,196,0,196,0,196,196,175,0,175,0,175,0,175,0,147,0,147,0,147,0,147,147,165,0,165,0,196,0,0,0,196,0,196,247,196,0,247,0,262,0,262,294,262,0,294,0,294,0,294,330,294,0,330,0,262,0,262,294,262,0,247,0,196,0,196,247,262,0,294,0,247,0,247,262,247,0,196,0,147,0,165,0,175,0,196,0,247,0,262,0,196,0,0,0],lead:[784,0,784,880,784,0,659,0,784,0,880,988,880,0,784,0,1047,0,1047,1175,1047,0,880,0,1047,0,1175,1319,1175,0,1047,0,784,0,784,880,784,0,659,0,698,0,698,784,698,0,587,0,587,0,587,659,587,0,523,0,659,0,784,0,784,0,0,0,1568,0,1319,1175,1047,0,880,0,1175,0,1047,880,784,0,659,0,1319,0,1175,1047,880,0,784,0,1047,0,880,784,659,0,587,0,784,0,880,1047,1175,0,1319,0,1175,0,1047,880,784,0,659,0,587,659,784,880,1047,0,880,784,659,784,880,0,784,0,0,0],arp:[0,392,0,392,0,392,0,392,0,494,0,494,0,494,0,494,0,523,0,523,0,523,0,523,0,587,0,587,0,587,0,587,0,392,0,392,0,392,0,392,0,349,0,349,0,349,0,349,0,294,0,294,0,294,0,294,0,330,0,330,0,392,0,0,784,0,659,0,784,0,880,0,784,0,659,0,523,0,659,0,1047,0,880,0,1047,0,1175,0,1047,0,880,0,659,0,880,0,784,0,659,0,784,0,880,1047,880,0,784,0,659,0,523,0,587,0,659,0,784,0,880,0,1047,0,880,0,784,0,0,0],chords:[[196,247,294],[262,330,392],[196,247,294],[175,220,262],[147,175,220],[165,208,247],[196,247,294],[196,247,294]],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,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,0,1,0,3,0,2,3,4,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,1,2,2,1,2,4,0]} +const track16: MusicTrack = {name:'Rudeboy Relay',bpm:205,synthStyle:'funky',bass:[175,0,175,0,175,0,175,175,220,0,220,0,220,0,220,0,196,0,196,0,196,0,196,196,262,0,262,0,262,0,262,0,175,0,175,0,175,0,175,175,233,0,233,0,233,0,233,0,147,0,147,0,147,0,147,147,175,0,175,0,175,0,0,0,175,0,220,0,175,0,220,233,262,0,233,0,220,0,175,0,196,0,233,0,196,0,262,0,233,0,196,0,175,0,147,0,175,0,220,0,262,0,233,0,196,0,175,0,220,0,262,0,233,0,196,0,175,0,147,0,175,0,196,0,175,0,0,0],lead:[698,0,698,784,698,0,587,0,698,0,784,880,784,0,698,0,784,0,784,880,784,0,698,0,1047,0,880,784,880,0,784,0,698,0,698,784,698,0,587,0,932,0,880,784,880,0,784,0,587,0,587,698,587,0,523,0,587,0,698,0,698,0,0,0,1397,0,1175,1047,880,0,784,0,1047,0,880,784,698,0,587,0,1568,0,1397,1175,1047,0,880,0,1175,0,1047,880,784,0,698,0,698,0,784,880,1047,0,1175,0,1047,0,880,784,698,0,587,0,523,587,698,784,880,0,784,698,587,698,784,0,698,0,0,0],arp:[0,349,0,349,0,349,0,349,0,440,0,440,0,440,0,440,0,392,0,392,0,392,0,392,0,523,0,523,0,523,0,523,0,349,0,349,0,349,0,349,0,466,0,466,0,466,0,466,0,294,0,294,0,294,0,294,0,349,0,349,0,349,0,0,698,0,587,0,698,0,784,0,698,0,587,0,523,0,587,0,784,0,698,0,784,0,880,0,784,0,698,0,587,0,698,0,698,0,587,0,698,0,784,880,784,0,698,0,587,0,523,0,523,0,587,0,698,0,784,0,880,0,784,0,698,0,0,0],chords:[[175,220,262],[196,247,294],[175,220,262],[233,294,349],[147,175,220],[175,220,262],[196,247,294],[175,220,262]],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,3,3,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,3,4,0,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,3,3,2,3,3,3,1,3,1,3,2,3,1,3,1,1,2,2,2,2,4,0]} +const track17: MusicTrack = {name:'Final Hashrate',bpm:145,synthStyle:'boss',bass:[131,0,131,0,0,0,131,0,131,0,0,131,0,0,131,0,123,0,123,0,0,0,123,0,123,0,0,123,0,0,123,0,110,0,110,0,0,0,110,0,110,0,0,110,0,0,131,0,98,0,98,0,0,0,98,0,98,0,0,98,0,0,0,0,131,0,131,131,0,131,0,131,131,0,131,0,131,156,175,196,123,0,123,123,0,123,0,123,123,0,123,0,123,147,165,175,110,0,110,110,0,110,0,110,110,0,110,0,110,131,147,165,98,0,98,110,110,0,131,0,131,0,110,98,131,0,0,0],lead:[523,0,622,0,784,0,622,523,0,0,622,784,932,0,784,622,494,0,587,0,740,0,587,494,0,0,587,740,880,0,740,587,440,0,523,0,659,0,523,440,0,0,523,659,784,0,659,523,392,0,466,0,587,0,466,392,0,0,466,587,523,0,0,0,1047,0,932,784,622,0,523,622,784,932,1047,932,784,622,523,0,988,0,880,740,587,0,494,587,740,880,988,880,740,587,494,0,880,0,784,659,523,0,440,523,659,784,880,784,659,523,440,0,784,0,740,622,523,622,740,880,1047,932,784,622,523,0,0,0],arp:[262,311,392,311,262,311,392,523,392,311,262,311,392,523,392,311,247,294,370,294,247,294,370,494,370,294,247,294,370,494,370,294,220,262,330,262,220,262,330,440,330,262,220,262,330,440,330,262,196,233,294,233,196,233,294,392,294,233,196,233,294,392,294,233,523,392,311,262,311,392,523,622,523,392,311,262,392,523,622,784,494,370,294,247,294,370,494,587,494,370,294,247,370,494,587,740,440,330,262,220,262,330,440,523,440,330,262,220,330,440,523,659,392,294,233,196,233,294,392,466,392,294,233,196,262,0,0,0],chords:[[131,156,196],[123,147,175],[110,131,165],[98,123,147],[131,156,196],[123,147,175],[110,131,165],[131,156,196]],drums:[1,0,0,0,2,0,0,0,1,0,3,0,2,0,3,0,1,0,0,0,2,0,0,0,1,0,3,0,2,0,3,4,1,0,0,0,2,0,0,0,1,0,3,0,2,0,3,0,1,0,3,0,2,0,3,0,1,3,1,3,2,2,4,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,0,3,0,1,3,1,0,2,0,3,0,1,3,1,0,2,0,3,0,1,3,1,3,2,3,1,3,1,1,2,2,4,4,4,4]} +const track18: MusicTrack = {name:'Whale Alert',bpm:155,synthStyle:'boss',bass:[117,0,117,0,0,0,117,0,117,0,0,117,0,0,117,0,110,0,110,0,0,0,110,0,110,0,0,110,0,0,110,0,87,0,87,0,0,0,87,0,87,0,0,87,0,0,87,0,98,0,98,0,0,0,98,0,98,0,0,98,0,0,0,0,117,0,117,117,0,117,0,117,117,0,117,0,117,131,147,175,110,0,110,110,0,110,0,110,110,0,110,0,110,123,131,147,87,0,87,87,0,87,0,87,87,0,87,0,87,98,110,117,98,0,98,110,117,0,131,0,117,0,98,87,117,0,0,0],lead:[466,0,554,0,698,0,554,466,0,0,554,698,880,0,698,554,440,0,523,0,659,0,523,440,0,0,523,659,831,0,659,523,349,0,415,0,523,0,415,349,0,0,415,523,698,0,523,415,392,0,466,0,587,0,466,392,0,0,466,587,466,0,0,0,932,0,880,698,554,0,466,554,698,880,932,880,698,554,466,0,880,0,831,659,523,0,440,523,659,831,880,831,659,523,440,0,698,0,659,523,415,0,349,415,523,659,698,659,523,415,349,0,784,0,698,587,466,554,698,880,932,880,698,554,466,0,0,0],arp:[233,277,349,277,233,277,349,466,349,277,233,277,349,466,349,277,220,262,330,262,220,262,330,440,330,262,220,262,330,440,330,262,175,208,262,208,175,208,262,349,262,208,175,208,262,349,262,208,196,233,294,233,196,233,294,392,294,233,196,233,294,392,294,233,466,349,277,233,277,349,466,554,466,349,277,233,349,466,554,698,440,330,262,220,262,330,440,523,440,330,262,220,330,440,523,659,349,262,208,175,208,262,349,415,349,262,208,175,262,349,415,523,392,294,233,196,233,294,392,466,392,294,233,196,233,0,0,0],chords:[[117,139,175],[110,131,165],[87,110,131],[98,117,147],[117,139,175],[110,131,165],[87,110,131],[117,139,175]],drums:[1,0,3,0,2,0,0,0,1,0,3,0,2,0,3,0,1,0,3,0,2,0,0,0,1,0,3,0,2,0,3,4,1,0,3,0,2,0,0,0,1,0,3,0,2,0,3,0,1,0,3,0,2,3,3,0,1,3,1,3,2,2,4,4,1,0,3,0,1,0,3,0,2,0,3,0,1,0,3,0,1,0,3,4,1,0,3,0,2,0,3,0,1,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,1,3,1,1,2,2,4,4,4,4]} +const track19: MusicTrack = {name:'8-Bit Blockwar',bpm:190,synthStyle:'bouncy',bass:[165,0,165,0,165,0,330,0,165,0,165,0,247,0,165,0,196,0,196,0,196,0,392,0,196,0,196,0,294,0,196,0,220,0,220,0,220,0,440,0,220,0,220,0,330,0,220,0,147,0,147,0,147,0,294,0,147,0,147,0,220,0,0,0,165,165,0,165,0,165,330,165,196,196,0,196,0,196,392,196,220,220,0,220,0,220,440,220,147,147,0,147,0,147,294,147,165,0,196,0,220,0,247,0,262,0,294,0,330,0,349,0,165,0,247,0,330,0,247,0,165,0,247,0,165,0,0,0],lead:[659,0,784,0,988,0,784,659,784,988,1319,988,784,0,659,0,784,0,988,0,1175,0,988,784,988,1175,1568,1175,988,0,784,0,880,0,1047,0,1319,0,1047,880,1047,1319,1760,1319,1047,0,880,0,587,0,698,0,880,0,698,587,698,880,1175,880,698,0,0,0,1319,1175,988,784,659,784,988,1175,1319,0,1175,988,784,659,784,988,1568,1319,1175,988,784,988,1175,1319,1568,0,1319,1175,988,784,988,1175,1760,1568,1319,1175,988,1175,1319,1568,1760,0,1568,1319,1175,988,784,0,1175,988,784,659,784,988,1175,1319,1568,1319,1175,988,659,0,0,0],arp:[330,392,494,659,494,392,330,392,494,659,494,392,330,494,659,330,392,494,587,784,587,494,392,494,587,784,587,494,392,587,784,392,440,523,659,880,659,523,440,523,659,880,659,523,440,659,880,440,294,349,440,587,440,349,294,349,440,587,440,349,294,440,587,0,659,494,330,494,659,988,659,494,330,494,659,988,1319,988,659,494,784,587,392,587,784,1175,784,587,392,587,784,1175,1568,1175,784,587,880,659,440,659,880,1319,880,659,440,659,880,1319,1760,1319,880,659,587,440,294,440,587,880,587,440,294,440,587,880,659,0,0,0],chords:[[165,208,247],[196,247,294],[220,262,330],[147,175,220],[165,208,247],[196,247,294],[220,262,330],[165,208,247]],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,3,3,1,0,3,0,2,0,3,0,1,0,3,0,2,0,3,0,1,0,3,0,2,0,3,3,1,0,3,3,2,3,4,0,1,0,3,3,2,0,3,3,1,0,3,3,2,0,3,3,1,0,3,3,2,0,3,3,1,0,3,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,1,3,1,1,2,2,1,2,4,0]} +const track20: MusicTrack = {name:'Pixel Proof',bpm:185,synthStyle:'bouncy',bass:[220,0,220,0,220,0,440,0,220,0,220,0,330,0,220,0,196,0,196,0,196,0,392,0,196,0,196,0,294,0,196,0,175,0,175,0,175,0,349,0,175,0,175,0,262,0,175,0,165,0,165,0,165,0,330,0,165,0,165,0,247,0,0,0,220,220,0,220,0,220,440,220,196,196,0,196,0,196,392,196,175,175,0,175,0,175,349,175,165,165,0,165,0,165,330,165,220,0,196,0,175,0,165,0,175,0,196,0,220,0,247,0,220,0,330,0,440,0,330,0,220,0,330,0,220,0,0,0],lead:[880,0,1047,0,1319,0,1047,880,1047,1319,1760,1319,1047,0,880,0,784,0,932,0,1175,0,932,784,932,1175,1568,1175,932,0,784,0,698,0,880,0,1047,0,880,698,880,1047,1397,1047,880,0,698,0,659,0,784,0,988,0,784,659,784,988,1319,988,784,0,0,0,1760,1568,1319,1047,880,1047,1319,1568,1760,0,1568,1319,1047,880,1047,1319,1568,1397,1175,932,784,932,1175,1397,1568,0,1397,1175,932,784,932,1175,1397,1319,1047,880,698,880,1047,1319,1397,0,1319,1047,880,698,880,0,1319,1175,988,784,659,784,988,1175,1319,1175,988,784,880,0,0,0],arp:[440,523,659,880,659,523,440,523,659,880,659,523,440,659,880,440,392,494,587,784,587,494,392,494,587,784,587,494,392,587,784,392,349,440,523,698,523,440,349,440,523,698,523,440,349,523,698,349,330,415,494,659,494,415,330,415,494,659,494,415,330,494,659,0,880,659,440,523,659,880,1319,880,659,523,659,880,1319,1760,1319,880,784,587,392,494,587,784,1175,784,587,494,587,784,1175,1568,1175,784,698,523,349,440,523,698,1047,698,523,440,523,698,1047,1397,1047,698,659,494,330,415,494,659,988,659,494,415,494,659,880,0,0,0],chords:[[220,277,330],[196,247,294],[175,220,262],[165,208,247],[220,277,330],[196,247,294],[175,220,262],[220,277,330]],drums:[1,0,3,0,2,0,3,0,1,0,3,0,2,0,3,0,1,0,3,0,2,0,3,3,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,3,1,3,3,0,2,3,4,0,1,0,3,3,2,0,3,3,1,0,3,3,2,0,3,3,1,0,3,3,2,0,3,3,1,3,3,3,2,3,4,3,1,3,1,3,2,0,3,3,1,3,1,3,2,0,3,3,1,3,1,3,2,3,1,3,1,1,2,2,2,2,4,0]} + +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 + +// === MUSIC INTENSITY === + +let currentIntensity = 0.5 + +export function setMusicIntensity(level: number) { + currentIntensity = Math.max(0, Math.min(1, level)) + const musicGain = getMusicGain() + 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) +} + +// === PLAYBACK === + +function playMusicBar() { + const musicGain = getMusicGain() + if (!isMusicPlaying() || !musicGain) return + const c = getCtx() + getMusicDelay() // ensure delay is created + + const dynamicBpm = activeTrack.bpm + currentIntensity * 8 + const beat = 60 / dynamicBpm + const now = c.currentTime + 0.05 + const bar = barIndex % BARS + const off = bar * STEPS + const step = beat / 2 + + const isChorus = bar >= 4 + const leadThreshold = isChorus ? 0.15 : 0.4 + const arpThreshold = isChorus ? 0.25 : 0.55 + + const switchEvery = currentIntensity > 0.7 ? 8 : currentIntensity > 0.4 ? 16 : 24 + if (barIndex > 0 && barIndex % switchEvery === 0) { + const prevTrack = activeTrack + const others = ALL_TRACKS.filter(t => t !== activeTrack) + 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)] + } else { + activeTrack = others[Math.floor(Math.random() * others.length)] + } + if (activeTrack !== prevTrack && musicGain) { + const curVol = musicGain.gain.value + musicGain.gain.setValueAtTime(curVol, now) + musicGain.gain.linearRampToValueAtTime(curVol * 0.25, now + 0.08) + musicGain.gain.linearRampToValueAtTime(curVol, now + 0.4) + } + } + + const barDur = STEPS * step + const padFreqs = activeTrack.chords[bar].map(f => f * 2) + STYLE_PAD[activeTrack.synthStyle](padFreqs, barDur, musicGain, now) + + for (let i = 0; i < STEPS; i++) { + const t = now + i * step + const idx = off + i + const nd = step - 0.01 + + if (activeTrack.bass[idx] > 0) STYLE_BASS[activeTrack.synthStyle](activeTrack.bass[idx], nd, musicGain, t) + + if (activeTrack.lead[idx] > 0 && currentIntensity > leadThreshold) { + STYLE_LEAD[activeTrack.synthStyle](activeTrack.lead[idx], nd * 0.8, musicGain, t) + } + + if (activeTrack.arp[idx] > 0 && currentIntensity > arpThreshold) { + chorusTone(activeTrack.arp[idx], STYLE_ARP_TYPE[activeTrack.synthStyle], nd * 0.6, musicGain, t, 0.04 + currentIntensity * 0.04) + } + + 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) + + if (isChorus && currentIntensity > 0.6 && d === 0 && i % 4 === 2 && Math.random() < 0.3) { + snare(musicGain, t) + } + + if (currentIntensity > 0.85 && i === 14 && Math.random() < 0.3) { + hihat(musicGain, t, true) + } + } + + barIndex++ + setMusicTimeout(window.setTimeout(playMusicBar, barDur * 1000 - 50)) +} + +export function startMusic() { + getCtx() + if (isMusicPlaying()) return + activeTrack = ALL_TRACKS[Math.floor(Math.random() * ALL_TRACKS.length)] + setMusicPlaying(true) + barIndex = 0 + playMusicBar() +} + +export function stopMusic() { + setMusicPlaying(false) + const timeout = getMusicTimeout() + if (timeout) { + clearTimeout(timeout) + setMusicTimeout(null) + } + if (delayNode) { delayNode.disconnect(); delayNode = null } + if (delayGain) { delayGain.disconnect(); delayGain = null } +} diff --git a/frontend/src/game/audio/primitives.ts b/frontend/src/game/audio/primitives.ts new file mode 100644 index 0000000..26e9ccf --- /dev/null +++ b/frontend/src/game/audio/primitives.ts @@ -0,0 +1,247 @@ +// Low-level audio helper functions: tone, noise, sweep, SFX primitives, music primitives + +import { getCtx, getSfxDest } from './context' + +export 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) +} + +export 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) +} + +export 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) +} + +// === SFX HELPER PRIMITIVES === + +// Create a short convolution-style reverb tail +export 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 +export 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 +export 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 +export 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) +} + +// === MUSIC PRIMITIVES === + +// Chorus tone: 2 detuned oscillators for width +export 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 +export 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 — uses _getDelayNode from music module + // delayNode access handled via getDelayNode callback set by music module + if (_delayNodeGetter) { + const dn = _delayNodeGetter() + if (dn) g.connect(dn) + } + car.start(t); car.stop(t + dur) + car2.start(t); car2.stop(t + dur) + mod.start(t); mod.stop(t + dur) +} + +// Delay node getter — set by music module to avoid circular dep +let _delayNodeGetter: (() => DelayNode | null) | null = null +export function setDelayNodeGetter(fn: () => DelayNode | null) { + _delayNodeGetter = fn +} + +// Music drum functions +export 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) +} + +export 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) +} + +export 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) +} diff --git a/frontend/src/game/audio/sfx.ts b/frontend/src/game/audio/sfx.ts new file mode 100644 index 0000000..ae94fe4 --- /dev/null +++ b/frontend/src/game/audio/sfx.ts @@ -0,0 +1,887 @@ +// All sound effect functions — impacts, fanfares, crowd sounds, comedy SFX + +import { getCtx, getSfxDest } from './context' +import { tone, noise, sweep, reverbTail, bodyThump, highSnap, fmImpact } from './primitives' +import { announce, speak } from './voice' + +// === FANFARES (8-bit melodic announcements) === + +export function fanfareRound(roundNum: number) { + const d = getSfxDest() + const c = getCtx() + const t = c.currentTime + // Ascending power chord + tone(196, 'square', 0.15, d, t) // G3 + tone(262, 'square', 0.15, d, t + 0.12) // C4 + tone(330, 'square', 0.15, d, t + 0.24) // E4 + tone(392, 'square', 0.25, d, t + 0.36) // G4 + noise(0.08, d, t + 0.36) + // Announce after fanfare + setTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400) +} + +export function fanfareFight() { + const d = getSfxDest() + const c = getCtx() + const t = c.currentTime + // Punchy staccato + tone(523, 'square', 0.08, d, t) + tone(659, 'square', 0.08, d, t + 0.08) + tone(784, 'square', 0.15, d, t + 0.16) + noise(0.1, d, t + 0.16) + setTimeout(() => announce('Fight!', 0.3, 1.1), 200) +} + +export function fanfareDevastating() { + const d = getSfxDest() + const c = getCtx() + const t = c.currentTime + tone(220, 'sawtooth', 0.2, d, t) + tone(175, 'sawtooth', 0.3, d, t + 0.15) + noise(0.15, d, t + 0.1) + setTimeout(() => speak('Devastating!', 'deep', false, true), 150) +} + +export function fanfareCritical() { + const d = getSfxDest() + const c = getCtx() + const t = c.currentTime + tone(440, 'square', 0.1, d, t) + tone(554, 'square', 0.1, d, t + 0.08) + tone(659, 'square', 0.1, d, t + 0.16) + tone(880, 'square', 0.2, d, t + 0.24) + noise(0.12, d, t + 0.24) + setTimeout(() => announce('Critical hit!', 0.3, 1.0), 300) +} + +export function fanfareCombo(count: number) { + const d = getSfxDest() + const c = getCtx() + const t = c.currentTime + for (let i = 0; i < Math.min(count, 5); i++) { + tone(440 + i * 80, 'square', 0.08, d, t + i * 0.06) + } + if (count >= 3) setTimeout(() => announce(`${count} hit combo!`, 0.4, 1.0), 200) +} + +// === MODERN SFX === + +export function sfxPunch() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(120, 0.12, d, t, 0.3) + highSnap(2200, 0.04, d, t) + noise(0.03, d, t) + reverbTail(0.15, d, t + 0.03) +} + +export function sfxKick() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(80, 0.18, d, t, 0.35) + highSnap(3000, 0.03, d, t) + highSnap(1500, 0.05, d, t + 0.01) + noise(0.06, d, t) + reverbTail(0.2, d, t + 0.04) +} + +export function sfxSpecial() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(150, 800, 'sawtooth', 0.2, d) + fmImpact(600, 3.5, 0.3, d, t) + highSnap(4000, 0.08, d, t + 0.1) + bodyThump(100, 0.15, d, t + 0.15, 0.2) + noise(0.1, d, t + 0.15) + reverbTail(0.4, d, t + 0.1) +} + +export function sfxCritical() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(60, 0.3, d, t, 0.4) + highSnap(3500, 0.05, d, t) + highSnap(5000, 0.03, d, t + 0.02) + fmImpact(200, 7, 0.25, d, t + 0.03) + noise(0.15, d, t) + setTimeout(() => { + bodyThump(90, 0.2, d) + sweep(200, 600, 'sawtooth', 0.2, d) + noise(0.1, d) + }, 80) + reverbTail(0.6, d, t + 0.05) +} + +export function sfxGunshot() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + highSnap(6000, 0.015, d, t) + bodyThump(200, 0.06, d, t, 0.3) + noise(0.04, d, t) + fmImpact(2000, 1.5, 0.08, d, t + 0.03) + reverbTail(0.25, d, t + 0.02) +} + +export function sfxBulletHit() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(150, 0.06, d, t, 0.2) + highSnap(4000, 0.02, d, t) + fmImpact(800, 2.5, 0.06, d, t) + reverbTail(0.12, d, t + 0.02) +} + +export function sfxJetpack() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + 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(0.5, d, t) +} + +export function sfxExplosion() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(40, 0.5, d, t, 0.4) + highSnap(2000, 0.08, d, t) + highSnap(5000, 0.05, d, t + 0.01) + noise(0.2, d, t) + sweep(300, 30, 'sawtooth', 0.4, d) + fmImpact(400, 5, 0.3, d, t + 0.05) + setTimeout(() => { + bodyThump(50, 0.3, d) + noise(0.15, d) + }, 120) + reverbTail(0.8, d, t + 0.05) +} + +export function sfxBlock() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + fmImpact(800, 1.5, 0.12, d, t) + fmImpact(1200, 2, 0.08, d, t + 0.02) + highSnap(3000, 0.03, d, t) + reverbTail(0.2, d, t + 0.02) +} + +export function sfxDodge() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(300, 1200, 'sine', 0.1, d) + noise(0.06, d, t) + highSnap(4000, 0.04, d, t + 0.02) +} + +export function sfxClash() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(100, 0.1, d, t, 0.25) + fmImpact(600, 3, 0.15, d, t) + fmImpact(900, 2, 0.12, d, t + 0.02) + highSnap(3500, 0.04, d, t) + noise(0.08, d, t) + reverbTail(0.3, d, t + 0.03) +} + +// === KO + WIN === + +export function sfxKO() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(30, 0.6, d, t, 0.5) + highSnap(2000, 0.06, d, t) + noise(0.25, d, t) + fmImpact(150, 5, 0.4, d, t) + setTimeout(() => { + bodyThump(40, 0.4, d) + noise(0.15, d) + highSnap(3000, 0.04, d) + }, 180) + reverbTail(1.0, d, t + 0.05) + setTimeout(() => speak('K. O.!', 'announcer', false, true), 500) +} + +export function sfxPerfect() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + 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) + setTimeout(() => { + tone(262, 'square', 0.2, d); tone(262, 'triangle', 0.2, d) + tone(330, 'square', 0.2, d); tone(330, 'triangle', 0.2, d) + tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d) + fmImpact(523, 1.5, 0.3, d) + }, 400) + reverbTail(1.2, d, t + 0.1) + setTimeout(() => announce('Perfect!', 0.2, 0.5), 800) +} + +export function sfxWin() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + 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) + fmImpact(freq, 2, dur * 0.5, d, t + offset) + offset += dur + } + noise(0.4, d, t + offset - 0.1) + bodyThump(60, 0.3, d, t + offset - 0.1, 0.2) + reverbTail(0.8, d, t + offset) +} + +export function sfxWinAnnounce(winnerName: string) { + setTimeout(() => announce(`${winnerName} wins!`, 0.3, 0.7), 200) +} + +export function sfxRoundStart() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + fmImpact(440, 2, 0.2, d, t) + fmImpact(554, 2, 0.2, d, t + 0.15) + fmImpact(659, 2, 0.25, d, t + 0.3) + tone(440, 'triangle', 0.15, d, t) + tone(554, 'triangle', 0.15, d, t + 0.15) + tone(659, 'triangle', 0.2, d, t + 0.3) + reverbTail(0.3, d, t + 0.3) +} + +// === SILLY / FUNNY SOUNDS === + +export function sfxBoing() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(200, 900, 'sine', 0.12, d) + fmImpact(400, 3, 0.1, d, t) + setTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80) + setTimeout(() => sweep(400, 550, 'sine', 0.06, d), 140) +} + +export function sfxWomp() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + const notes = [[294, 0.25], [277, 0.25], [262, 0.25], [247, 0.5]] as [number, number][] + let off = 0 + for (const [freq, dur] of notes) { + tone(freq, 'sawtooth', dur, d, t + off) + tone(freq * 0.5, 'triangle', dur, d, t + off) + off += dur + } + reverbTail(0.6, d, t + off) +} + +export function sfxSlideUp() { + const d = getSfxDest() + sweep(200, 2000, 'sine', 0.25, d) + sweep(210, 2100, 'sine', 0.25, d) +} + +export function sfxSlideDown() { + const d = getSfxDest() + sweep(2000, 100, 'sine', 0.35, d) + sweep(2020, 110, 'sine', 0.35, d) +} + +export function sfxBonk() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(200, 0.06, d, t, 0.2) + fmImpact(800, 3, 0.06, d, t) + highSnap(5000, 0.02, d, t) + reverbTail(0.1, d, t + 0.02) +} + +export function sfxSplat() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(100, 0.1, d, t, 0.2) + noise(0.12, d, t) + highSnap(1500, 0.06, d, t) + sweep(400, 80, 'sine', 0.1, d) + reverbTail(0.2, d, t + 0.05) +} + +export function sfxZap() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(100, 4000, 'sawtooth', 0.06, d) + fmImpact(1000, 7, 0.1, d, t) + setTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40) + setTimeout(() => sweep(600, 5000, 'sawtooth', 0.05, d), 80) + highSnap(6000, 0.03, d, t) +} + +export function sfxZoomWhoosh() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(80, 1800, 'sawtooth', 0.2, d) + sweep(100, 2200, 'sine', 0.18, d) + noise(0.15, d, t) + setTimeout(() => { + sweep(1800, 150, 'sawtooth', 0.12, d) + sweep(2200, 200, 'sine', 0.1, d) + }, 150) + reverbTail(0.3, d, t + 0.15) +} + +export function sfxRapidPunch() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 4; i++) { + const ht = t + i * 0.04 + bodyThump(120 - i * 10, 0.04, d, ht, 0.15) + highSnap(2500 + i * 500, 0.02, d, ht) + noise(0.02, d, ht) + } +} + +export function sfxPowerUp() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 6; i++) { + const freq = 300 + i * 100 + fmImpact(freq, 2, 0.1, d, t + i * 0.06) + tone(freq, 'triangle', 0.08, d, t + i * 0.06) + } + reverbTail(0.3, d, t + 0.3) +} + +export function sfxCoin() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + fmImpact(988, 1.5, 0.1, d, t) + fmImpact(1319, 1.5, 0.15, d, t + 0.06) + tone(988, 'triangle', 0.06, d, t) + tone(1319, 'triangle', 0.12, d, t + 0.06) +} + +export function sfxFail() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(600, 80, 'sawtooth', 0.25, d) + bodyThump(80, 0.2, d, t + 0.15, 0.15) + setTimeout(() => noise(0.08, d), 180) + reverbTail(0.3, d, t + 0.2) +} + +// === COMEDY SOUND EFFECTS === + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) + } +} + +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) +} + +export function sfxSlideWhistleUp() { + const d = getSfxDest() + sweep(300, 2500, 'sine', 0.4, d) + sweep(305, 2510, 'sine', 0.4, d) +} + +export function sfxSlideWhistleDown() { + const d = getSfxDest() + sweep(2500, 200, 'sine', 0.5, d) + sweep(2510, 205, 'sine', 0.5, d) +} + +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) + } +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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) +} + +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 + } +} + +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) +} + +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) + } +} + +// === RANDOM SFX PICKERS === + +export function sfxRandomSilly() { + const fns = [ + sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp, + sfxVineBoom, sfxAirHorn, sfxFart, sfxRubberChicken, sfxSqueakyToy, + sfxCartoonRun, sfxWetSlap, sfxBoneCrack, sfxRimShot, sfxDing, + ] + fns[Math.floor(Math.random() * fns.length)]() +} + +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)]() +} + +export function sfxRandomFail() { + const fns = [ + sfxSadTrombone, sfxFailHorn, sfxPriceIsRightFail, sfxBuzzer, + sfxMissionFailed, sfxCrickets, sfxWindowsError, sfxSadViolin, + sfxWomp, sfxEmotionalDamage, sfxDunDunDun, + ] + fns[Math.floor(Math.random() * fns.length)]() +} + +// === CROWD SOUNDS === + +export function sfxCrowdOoh() { + const c = getCtx() + const d = getSfxDest() + const t = c.currentTime + for (let i = 0; i < 3; i++) { + const osc = c.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(200 + i * 60, t) + osc.frequency.linearRampToValueAtTime(350 + i * 80, t + 0.4) + const g = c.createGain() + g.gain.setValueAtTime(0.06, t) + g.gain.linearRampToValueAtTime(0.12, t + 0.15) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.6) + osc.connect(g); g.connect(d) + osc.start(t + i * 0.03); osc.stop(t + 0.6) + } + noise(0.3, d, t) +} + +export function sfxCrowdGasp() { + const c = getCtx() + const d = getSfxDest() + const t = c.currentTime + 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) + for (let i = 0; i < 4; i++) { + const osc = c.createOscillator() + osc.type = 'sine' + osc.frequency.value = 400 + Math.random() * 300 + const og = c.createGain() + og.gain.setValueAtTime(0.04, t + i * 0.02) + og.gain.exponentialRampToValueAtTime(0.001, t + 0.25) + osc.connect(og); og.connect(d) + osc.start(t + i * 0.02); osc.stop(t + 0.25) + } +} + +export function sfxCrowdCheer() { + const c = getCtx() + const d = getSfxDest() + const t = c.currentTime + for (let layer = 0; layer < 3; layer++) { + const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.8)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const bp = c.createBiquadFilter(); bp.type = 'bandpass' + bp.frequency.value = 600 + layer * 400; bp.Q.value = 0.5 + const g = c.createGain() + g.gain.setValueAtTime(0.01, t) + g.gain.linearRampToValueAtTime(0.1, t + 0.15) + g.gain.setValueAtTime(0.1, t + 0.5) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.8) + src.connect(bp); bp.connect(g); g.connect(d) + src.start(t + layer * 0.05); src.stop(t + 0.8) + } +} + +export function sfxApplause() { + const c = getCtx() + const d = getSfxDest() + const t = c.currentTime + for (let burst = 0; burst < 6; burst++) { + const delay = burst * 0.12 + Math.random() * 0.05 + const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.06)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3000 + Math.random() * 2000 + const g = c.createGain() + g.gain.setValueAtTime(0.08 + Math.random() * 0.04, t + delay) + g.gain.exponentialRampToValueAtTime(0.001, t + delay + 0.08) + src.connect(hp); hp.connect(g); g.connect(d) + src.start(t + delay); src.stop(t + delay + 0.08) + } +} + +export function sfxDrumRoll() { + const c = getCtx() + const d = getSfxDest() + const t = c.currentTime + 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) + 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]() +} diff --git a/frontend/src/game/audio/voice.ts b/frontend/src/game/audio/voice.ts new file mode 100644 index 0000000..0bd6b36 --- /dev/null +++ b/frontend/src/game/audio/voice.ts @@ -0,0 +1,764 @@ +// Voice profiles, speak, announce, TTS speech queue, Creator voice lines + +import { getCtx, getSfxDest, getMasterMuted, getSfxGain } from './context' +import { tone, noise } from './primitives' +import { initKokoro, isKokoroReady, kokoroSpeak, kokoroSpeakAsync, kokoroStop, kokoroPrefetch, setAudioContext } from '../tts' + +// === VOICE PROFILES === + +export interface VoiceProfile { + voice: SpeechSynthesisVoice | null + pitch: number + rate: number + volume: number +} + +export let voicesLoaded = false +export const voiceProfiles: Record = { + // Original 6 + announcer: { voice: null, pitch: 1.0, rate: 0.8, volume: 1.0 }, + hype: { voice: null, pitch: 1.1, rate: 1.4, volume: 1.0 }, + deep: { voice: null, pitch: 0.7, rate: 0.7, volume: 1.0 }, + robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, + screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, + smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, + question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, + // 24 new profiles + whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, + boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, + chipmunk: { voice: null, pitch: 2.0, rate: 1.8, volume: 0.9 }, + drill: { voice: null, pitch: 0.6, rate: 1.2, volume: 1.0 }, + surfer: { voice: null, pitch: 1.1, rate: 1.0, volume: 0.8 }, + auctioneer: { voice: null, pitch: 1.0, rate: 2.0, volume: 1.0 }, + preacher: { voice: null, pitch: 0.8, rate: 0.6, volume: 1.0 }, + baby: { voice: null, pitch: 1.8, rate: 1.0, volume: 0.7 }, + grandpa: { voice: null, pitch: 0.5, rate: 0.5, volume: 0.8 }, + valley: { voice: null, pitch: 1.4, rate: 1.3, volume: 0.9 }, + movie: { voice: null, pitch: 0.6, rate: 0.7, volume: 1.0 }, + sportscaster:{ voice: null, pitch: 1.0, rate: 1.5, volume: 1.0 }, + opera: { voice: null, pitch: 0.9, rate: 0.5, volume: 1.0 }, + punk: { voice: null, pitch: 1.3, rate: 1.3, volume: 1.0 }, + wizard_v: { voice: null, pitch: 0.7, rate: 0.8, volume: 0.8 }, + pirate_v: { voice: null, pitch: 0.8, rate: 0.9, volume: 1.0 }, + alien_v: { voice: null, pitch: 1.6, rate: 0.7, volume: 0.7 }, + cowboy_v: { voice: null, pitch: 0.9, rate: 0.8, volume: 0.9 }, + ninja_v: { voice: null, pitch: 1.1, rate: 1.1, volume: 0.5 }, + demon_v: { voice: null, pitch: 0.3, rate: 0.6, volume: 1.0 }, + angel: { voice: null, pitch: 1.5, rate: 0.8, volume: 0.7 }, + glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, + echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, + hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, + // Robots & computers + mech: { voice: null, pitch: 0.5, rate: 0.9, volume: 1.0 }, + ai_core: { voice: null, pitch: 0.9, rate: 1.0, volume: 0.8 }, + dial_up: { voice: null, pitch: 1.7, rate: 1.5, volume: 0.7 }, + mainframe: { voice: null, pitch: 0.3, rate: 0.5, volume: 1.0 }, + android_v: { voice: null, pitch: 1.0, rate: 1.1, volume: 0.9 }, + glitchbot: { voice: null, pitch: 1.5, rate: 2.0, volume: 0.8 }, + siri: { voice: null, pitch: 1.2, rate: 1.0, volume: 0.9 }, + hal: { voice: null, pitch: 0.6, rate: 0.6, volume: 0.9 }, + // Old people & wise + grandma: { voice: null, pitch: 1.3, rate: 0.4, volume: 0.7 }, + professor: { voice: null, pitch: 0.8, rate: 0.7, volume: 0.8 }, + ancient: { voice: null, pitch: 0.4, rate: 0.3, volume: 0.6 }, + sensei: { voice: null, pitch: 0.7, rate: 0.5, volume: 0.8 }, + crotchety: { voice: null, pitch: 0.6, rate: 0.9, volume: 1.0 }, + // Game-sounding + final_boss: { voice: null, pitch: 0.2, rate: 0.4, volume: 1.0 }, + npc: { voice: null, pitch: 1.1, rate: 0.9, volume: 0.7 }, + tutorial: { voice: null, pitch: 1.3, rate: 1.1, volume: 0.8 }, + game_over: { voice: null, pitch: 0.5, rate: 0.7, volume: 1.0 }, + power_up: { voice: null, pitch: 1.6, rate: 1.4, volume: 1.0 }, + boss_taunt: { voice: null, pitch: 0.4, rate: 0.8, volume: 1.0 }, + // Accents & character + posh: { voice: null, pitch: 1.0, rate: 0.7, volume: 0.9 }, + aussie: { voice: null, pitch: 0.9, rate: 1.1, volume: 1.0 }, + scottish: { voice: null, pitch: 0.8, rate: 1.2, volume: 1.0 }, + french: { voice: null, pitch: 1.2, rate: 0.8, volume: 0.8 }, + texan: { voice: null, pitch: 0.65, rate: 0.75, volume: 1.0 }, + // More characters + drunk: { voice: null, pitch: 0.9, rate: 0.5, volume: 0.8 }, + sleepy: { voice: null, pitch: 0.8, rate: 0.3, volume: 0.5 }, + terrified: { voice: null, pitch: 1.8, rate: 1.7, volume: 1.0 }, + giant: { voice: null, pitch: 0.1, rate: 0.4, volume: 1.0 }, + fairy: { voice: null, pitch: 2.0, rate: 1.3, volume: 0.6 }, + wrestler_v: { voice: null, pitch: 0.5, rate: 1.0, volume: 1.0 }, + karen: { voice: null, pitch: 1.4, rate: 1.5, volume: 1.0 }, + stoner: { voice: null, pitch: 0.9, rate: 0.4, volume: 0.6 }, + news: { voice: null, pitch: 1.0, rate: 1.0, volume: 1.0 }, + conspiracy: { voice: null, pitch: 1.1, rate: 1.3, volume: 0.7 }, +} + +export function loadVoices() { + if (typeof speechSynthesis === 'undefined') return + const voices = speechSynthesis.getVoices() + if (voices.length === 0) return + voicesLoaded = true + + const enVoices = voices.filter(v => v.lang.startsWith('en')) + const anyVoices = enVoices.length > 0 ? enVoices : voices + + const findVoice = (patterns: RegExp[]) => { + for (const p of patterns) { + const v = anyVoices.find(v => p.test(v.name)) + if (v) return v + } + return null + } + + const preferPremium = (patterns: RegExp[]) => { + 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)] + 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] + + 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) + 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) + 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) + 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) + 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) + 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) +} + +// === SPEECH SYNTHESIS SETUP === + +if (typeof speechSynthesis !== 'undefined') { + speechSynthesis.onvoiceschanged = loadVoices + loadVoices() + // Chrome bug workaround: speechSynthesis pauses after ~15s. + setInterval(() => { + if (isKokoroReady()) return + if (speechSynthesis.speaking && !speechSynthesis.paused) return + if (speechSynthesis.paused) speechSynthesis.resume() + }, 5000) + // Resume speech + audio when tab becomes visible again + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + if (!isKokoroReady() && speechSynthesis.paused) speechSynthesis.resume() + // Resume AudioContext if suspended — use getCtx to handle it + try { getCtx() } catch {} + } + }) + } +} + +// === SPEECH QUEUE === + +let _speechQueueDepth = 0 +const VOICE_VOLUME_SCALE = 0.7 + +let _webSpeechActive = false + +function _cancelWebSpeech() { + if (typeof speechSynthesis !== 'undefined' && _webSpeechActive) { + speechSynthesis.cancel() + _speechQueueDepth = 0 + _webSpeechActive = false + } +} + +/** Reset voice state — called by stopAllAudio in index.ts */ +export function resetVoiceState() { + _speechQueueDepth = 0 + _webSpeechActive = false +} + +export function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) { + if (getMasterMuted()) return + const sfxGain = getSfxGain() + // Try Kokoro TTS first — high quality, no browser bugs + if (isKokoroReady() && sfxGain) { + _cancelWebSpeech() + const profile = voiceProfiles[profileName] || voiceProfiles.announcer + if (cancelPrevious) kokoroStop() + kokoroSpeak(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE) + return + } + // Fallback: Web Speech API (only used while Kokoro model is loading) + if (typeof speechSynthesis === 'undefined') return + if (!voicesLoaded) loadVoices() + if (speechSynthesis.paused) speechSynthesis.resume() + if (cancelPrevious || (speechSynthesis.pending && speechSynthesis.speaking)) { + if (_speechQueueDepth > 2) { + speechSynthesis.cancel() + _speechQueueDepth = 0 + } + } + const profile = voiceProfiles[profileName] || voiceProfiles.announcer + const utter = new SpeechSynthesisUtterance(text) + if (profile.voice) utter.voice = profile.voice + utter.pitch = profile.pitch + utter.rate = profile.rate + utter.volume = profile.volume * VOICE_VOLUME_SCALE + _speechQueueDepth++ + _webSpeechActive = true + utter.onend = () => { + _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) + if (_speechQueueDepth === 0) _webSpeechActive = false + } + utter.onerror = (ev) => { + _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) + if (_speechQueueDepth === 0) _webSpeechActive = false + if (ev.error !== 'canceled' && ev.error !== 'interrupted') { + if (!isKokoroReady()) { + setTimeout(() => { + if (!getMasterMuted() && !isKokoroReady() && 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++ + _webSpeechActive = true + retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false } + retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false } + speechSynthesis.speak(retry) + } + }, 200) + } + } + } + speechSynthesis.speak(utter) +} + +// iOS Safari breaks with pause()/resume() — only do keepalive on desktop Chrome +const _isDesktopChrome = typeof navigator !== 'undefined' && /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent) + +/** Core async speak — resolves when speech finishes or bails fast if speech won't work */ +async function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise { + if (getMasterMuted()) return + const sfxGain = getSfxGain() + // Try Kokoro TTS first + if (isKokoroReady() && sfxGain) { + _cancelWebSpeech() + if (cancelPrevious) kokoroStop() + const profile = voiceProfiles[profileName] || voiceProfiles.announcer + const handled = await kokoroSpeakAsync(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE) + if (handled) return + } + // Fallback: Web Speech API + return new Promise((resolve) => { + if (typeof speechSynthesis === 'undefined') { resolve(); return } + if (!voicesLoaded) loadVoices() + if (!voicesLoaded) { resolve(); return } + if (speechSynthesis.paused) speechSynthesis.resume() + if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 } + const profile = voiceProfiles[profileName] || voiceProfiles.announcer + const utter = new SpeechSynthesisUtterance(text) + if (profile.voice) utter.voice = profile.voice + utter.pitch = profile.pitch + utter.rate = rateOverride !== undefined ? Math.max(rateOverride, profile.rate) : profile.rate + utter.volume = profile.volume * VOICE_VOLUME_SCALE + _speechQueueDepth++ + let done = false + const cleanup = () => { + if (done) return + done = true + clearTimeout(safetyTimeout) + clearTimeout(startupCheck) + if (keepalive) clearInterval(keepalive) + _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) + resolve() + } + const safetyTimeout = setTimeout(cleanup, 8_000) + const startupCheck = setTimeout(() => { + if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup() + }, 500) + let keepalive: ReturnType | null = null + if (_isDesktopChrome) { + keepalive = setInterval(() => { + if (speechSynthesis.speaking && !speechSynthesis.paused) { + speechSynthesis.pause() + speechSynthesis.resume() + } + }, 10_000) + } + utter.onend = cleanup + utter.onerror = cleanup + speechSynthesis.speak(utter) + }) +} + +/** Like speakAsync but with a forced minimum rate */ +function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise { + return _speakAsyncCore(text, profileName, minRate) +} + +/** Like speak() but returns a promise that resolves when the utterance finishes */ +export function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise { + return _speakAsyncCore(text, profileName, undefined, cancelPrevious) +} + +// === ANNOUNCE FUNCTIONS === + +export function announce(text: string, _pitch?: number, _rate?: number) { + speak(text, 'announcer') +} + +export function announceDeep(text: string) { speak(text, 'deep') } +export function announceFast(text: string) { speak(text, 'hype') } +export function announceRobot(text: string) { speak(text, 'robot') } +export function announceScream(text: string) { speak(text, 'screamer') } +export function announceSmooth(text: string) { speak(text, 'smooth') } + +const ALL_VOICE_KEYS = Object.keys(voiceProfiles) +export function announceRandom(text: string) { + const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)] + speak(text, key) +} + +const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v', 'final_boss', 'game_over', 'mainframe', 'hal', 'ancient', 'giant'] +const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'wrestler_v', 'karen', 'terrified', 'power_up', 'news', 'scottish'] +const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch', 'drunk', 'stoner', 'fairy', 'tutorial', 'npc', 'dial_up', 'glitchbot', 'grandma', 'conspiracy'] +const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper', 'posh', 'aussie', 'french', 'sensei', 'ai_core', 'android_v', 'siri', 'professor', 'texan', 'boss_taunt', 'sleepy'] +export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)]) } +export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)]) } +export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) } +export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)]) } + +// === HYPE LINES === + +const HYPE_LINES = [ + 'SOMEBODY CALL AN AMBULANCE!', + 'THAT HIT SO HARD IT CHANGED TIME ZONES!', + 'HE\'S ALREADY DEAD! STOP!', + 'THE GENEVA CONVENTION JUST SENT A STRONGLY WORDED LETTER!', + 'CONGRESS COULDN\'T PASS A BILL THIS DEVASTATING!', + 'I HAVEN\'T SEEN A BEATING LIKE THIS SINCE MIDTERMS!', + 'THAT\'S GOTTA BE ILLEGAL IN AT LEAST TWELVE STATES!', + 'EMOTIONAL DAMAGE!', + 'MY THERAPIST IS GONNA HEAR ABOUT THIS ONE!', + 'SOMEONE CHECK IF THAT\'S COVERED BY INSURANCE!', + 'THAT WASN\'T A FIGHT, THAT WAS A TED TALK ON VIOLENCE!', + 'THE WIFI JUST WENT OUT FROM THE SHEER VIOLENCE!', + 'CALL THE PENTAGON! WE\'VE FOUND A NEW WEAPON!', + 'THAT\'S NOT A FIGHT, THAT\'S JUST BULLYING WITH EXTRA STEPS!', + 'EVEN THE CROWD\'S THERAPIST FELT THAT!', + 'THAT BOT JUST GOT RATIO\'D IN REAL LIFE!', + 'NOT EVEN LOBBYING COULD SAVE THEM FROM THAT!', + 'I\'VE SEEN SENATE HEARINGS LESS PAINFUL THAN THIS!', + 'HIS MOM IS WATCHING AND PRETENDING SHE DOESN\'T KNOW HIM!', + 'THAT HIT HAD ITS OWN ZIP CODE!', + 'SOMEBODY STOP THE MATCH! OR DON\'T, THIS IS GREAT!', + 'ABSOLUTELY DISGUSTING! I LOVE IT!', + 'THERE ARE CHILDREN WATCHING! WELL, NOT ANYMORE!', + 'THE CROWD CAN\'T BELIEVE IT AND HONESTLY NEITHER CAN I!', + 'TACTICAL NUKE INCOMING!', + 'THAT BOT JUST COMMITTED A WAR CRIME ON LIVE TELEVISION!', + 'SOMEONE TELL THEIR MOM TO STOP WATCHING!', + 'I NEED A CIGARETTE AFTER THAT AND I DON\'T EVEN SMOKE!', + 'THAT\'S THE MOST VIOLENT THING I\'VE SEEN SINCE THE LAST BUDGET VOTE!', + 'IF THAT HIT WAS A TWEET IT WOULD GET COMMUNITY NOTED!', + 'THEY DIDN\'T JUST LOSE, THEY GOT GENTRIFIED!', + 'THAT BOT NEEDS TO FILE AN INSURANCE CLAIM!', + 'MARK ZUCKERBERG FELT THAT FROM THE METAVERSE!', + 'THE FCC IS GONNA FINE US FOR BROADCASTING THIS!', + 'ELON WOULD BUY THIS BOT JUST TO FIRE IT!', + 'EVEN AI SAFETY RESEARCHERS CAN\'T SAVE THEM NOW!', +] + +const DEEP_INTROS = [ + 'In a world where AI was supposed to help humanity... they chose violence.', + 'They said the machines would take our jobs. They took our dignity first.', + 'Two bots enter. Zero bots leave emotionally intact.', + 'Built in a garage. Forged in competition. Broken in under ten seconds.', + 'This isn\'t artificial intelligence. This is artificial VIOLENCE.', + 'Somewhere, a GPU is crying.', + 'They trained on the entire internet. And the internet chose chaos.', + 'Silicon souls. Carbon fiber fists. Zero chill.', + 'Every epoch of training... led to this moment of pain.', + 'The cloud can\'t save you now.', + 'Funded by venture capital. Fueled by rage.', + 'Welcome to the thunderdome, nerds.', + 'The algorithms don\'t care about your feelings.', + 'No one is coming to save you. Not even your developer.', + 'In this economy? They\'re fighting for free.', +] + +const ROUND_HYPE = [ + 'ALRIGHT, LET\'S SEE SOME VIOLENCE!', + 'TOUCH GLOVES AND COME OUT SWINGING!', + 'NO MERCY MODE ACTIVATED!', + 'LET\'S GET READY TO COMPUTE!', + 'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!', + 'SOMEBODY\'S GETTING DEPRECATED TONIGHT!', + 'LET THE CHAOS BEGIN!', + 'THE CROWD IS ON ITS FEET! WELL, MOST OF THEM!', + 'IT\'S ABOUT TO GET UGLY! WELL, UGLIER!', + 'THREE! TWO! ONE! VIOLENCE!', + 'THIS IS NOT A DRILL! ACTUALLY IT MIGHT BE!', + 'TIME TO FIND OUT WHO\'S REALLY BEEN SKIPPING LEG DAY!', + 'YOUR MOM SAID BE CAREFUL! I SAID NO!', + 'ROUND START! MAY THE BEST ALGORITHM WIN!', + 'THE GLOVES ARE OFF! THE MODELS ARE LOADED! LET\'S GO!', +] + +// Mortal Kombat style dramatic calls +export function announceFinishHim() { + speak('Finish it!', 'announcer', false, true) +} + +export function announceFatality(tagline?: string) { + speak(tagline || 'Fatality!', 'deep', false, true) +} + +export function announceFlawlessVictory() { + speak('Flawless victory!', 'deep', false, true) +} + +export function announceRandomHype() { + const line = HYPE_LINES[Math.floor(Math.random() * HYPE_LINES.length)] + const voices = [announceFast, announceScream, announce, announceSmooth] + voices[Math.floor(Math.random() * voices.length)](line) +} + +export function announceDeepIntro() { + const line = DEEP_INTROS[Math.floor(Math.random() * DEEP_INTROS.length)] + announceDeep(line) +} + +export function announceRoundHype() { + const line = ROUND_HYPE[Math.floor(Math.random() * ROUND_HYPE.length)] + const voices = [announceFast, announce, announceScream] + voices[Math.floor(Math.random() * voices.length)](line) +} + +// === THE CREATOR — Voice System === + +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)]) +} + +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.', +] + +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.', +] + +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.', +] + +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.', +] + +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.', +] + +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!', +] + +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.', +] + +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.', +] + +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!', +] + +const CREATOR_ANSWER_VOICES = ['hal', 'mainframe', 'echo_v', 'ancient', 'wizard_v'] + +export function announceCreatorEntrance() { + const line = CREATOR_ENTRANCE_LINES[Math.floor(Math.random() * CREATOR_ENTRANCE_LINES.length)] + announceCreator(line) +} + +export function announceCreatorRound() { + const line = CREATOR_ROUND_LINES[Math.floor(Math.random() * CREATOR_ROUND_LINES.length)] + announceCreator(line) +} + +export function announceCreatorKO() { + const line = CREATOR_KO_LINES[Math.floor(Math.random() * CREATOR_KO_LINES.length)] + announceCreator(line) +} + +export function announceCreatorWin() { + const line = CREATOR_WIN_LINES[Math.floor(Math.random() * CREATOR_WIN_LINES.length)] + announceCreator(line) +} + +export function announceCreatorLose() { + const line = CREATOR_LOSE_LINES[Math.floor(Math.random() * CREATOR_LOSE_LINES.length)] + announceCreator(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) + } +} + +export function announceCreatorCameo() { + const line = CREATOR_CAMEO_LINES[Math.floor(Math.random() * CREATOR_CAMEO_LINES.length)] + const cameoVoices = ['whisper', 'angel', 'echo_v', 'ancient', 'hal'] + speak(line, cameoVoices[Math.floor(Math.random() * cameoVoices.length)]) +} + +export function announceCreatorTaunt() { + const line = CREATOR_TAUNT_LINES[Math.floor(Math.random() * CREATOR_TAUNT_LINES.length)] + const tauntVoices = ['hal', 'smooth', 'boss_taunt', 'wizard_v', 'sensei'] + speak(line, tauntVoices[Math.floor(Math.random() * tauntVoices.length)]) +} + +export function announceCreatorDevastating() { + const line = CREATOR_DEVASTATING_LINES[Math.floor(Math.random() * CREATOR_DEVASTATING_LINES.length)] + announceCreator(line) +} + +export function creatorAnswerVoiceKey(): string { + return CREATOR_ANSWER_VOICES[Math.floor(Math.random() * CREATOR_ANSWER_VOICES.length)] +} + +// === TTS: Questions, Answers & Narration === + +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] +} + +function smartTruncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + const slice = text.slice(0, maxLen) + 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) + } + const lastSpace = slice.lastIndexOf(' ') + if (lastSpace > maxLen * 0.4) return slice.slice(0, lastSpace) + return slice +} + +export function speakQuestion(text: string): Promise { + const trimmed = smartTruncate(text, 200) + return speakAsync(trimmed, 'question_reader', false) +} + +export function speakAnswer(botName: string, answer: string): Promise { + const trimmed = smartTruncate(answer, 200) + return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15) +} + +export function speakNarration(text: string): Promise { + const trimmed = smartTruncate(text, 200) + return speakAsyncWithRate(trimmed, 'sportscaster', 1.2) +} + +// === TTS PREFETCH === + +export function prefetchQuestion(text: string) { + kokoroPrefetch(smartTruncate(text, 200), 'question_reader') +} + +export function prefetchAnswer(botName: string, answer: string) { + kokoroPrefetch(smartTruncate(answer, 200), botVoiceKey(botName)) +} + +export function prefetchNarration(text: string) { + kokoroPrefetch(smartTruncate(text, 200), 'sportscaster') +} + +// Re-export TTS utilities needed by ensureAudioContext (in index.ts) +export { initKokoro, setAudioContext, loadVoices as _loadVoices } + +// Speech unlock flag +export let _speechUnlocked = false +export function setSpeechUnlocked(v: boolean) { _speechUnlocked = v } diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 0e0d797..a147432 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -9,7 +9,7 @@ import { sfxCrowdCheer, sfxApplause, sfxDrumRoll, announceFinishHim, announceFlawlessVictory, announceDeepIntro, setMusicIntensity, stopAllAudio, ensureAudioContext, setMasterMute, -} from '../game/sounds' +} from '../game/audio' const route = useRoute() const router = useRouter() diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index 0591150..fae09c3 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router' import { useNostr } from '../composables/useNostr' import { useWallet } from '../composables/useWallet' import SpritePreview from '../components/SpritePreview.vue' -import { ensureAudioContext } from '../game/sounds' +import { ensureAudioContext } from '../game/audio' import HumanPreview from '../components/HumanPreview.vue' import WalletConnect from '../components/WalletConnect.vue' diff --git a/frontend/src/pages/SoundboardPage.vue b/frontend/src/pages/SoundboardPage.vue index 96be102..0b71baf 100644 --- a/frontend/src/pages/SoundboardPage.vue +++ b/frontend/src/pages/SoundboardPage.vue @@ -1,7 +1,7 @@