// ═══ audio.js ═══ // ============================================================ // AUDIO.JS — Web Audio API sound effects // ============================================================ let _audioCtx = null; let _activeNodes = 0; const _MAX_NODES = 12; function getAudioCtx() { if (!_audioCtx) { try { _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { _audioCtx = null; } } // Resume if suspended (browser autoplay policy) if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume(); return _audioCtx; } // ── PRIMITIVE SYNTH ─────────────────────────────────────────── // type: 'sine'|'square'|'sawtooth'|'triangle' function playTone(freq, type, gainPeak, duration, opts = {}) { const ctx = getAudioCtx(); if (!ctx) return; if (_activeNodes >= _MAX_NODES) return; _activeNodes++; const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); // Optional frequency sweep osc.type = type; osc.frequency.setValueAtTime(freq, now); if (opts.freqEnd !== undefined) { osc.frequency.linearRampToValueAtTime(opts.freqEnd, now + duration); } const attack = opts.attack ?? 0.005; const decay = opts.decay ?? duration * 0.3; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(gainPeak, now + attack); gain.gain.exponentialRampToValueAtTime(0.001, now + duration); // Optional filter if (opts.filterFreq) { const filter = ctx.createBiquadFilter(); filter.type = opts.filterType ?? 'lowpass'; filter.frequency.setValueAtTime(opts.filterFreq, now); if (opts.filterEnd) filter.frequency.linearRampToValueAtTime(opts.filterEnd, now + duration); osc.connect(filter); filter.connect(gain); } else { osc.connect(gain); } gain.connect(ctx.destination); osc.onended = () => { _activeNodes--; }; osc.start(now); osc.stop(now + duration + 0.01); } function playNoise(gainPeak, duration, opts = {}) { const ctx = getAudioCtx(); if (!ctx) return; if (_activeNodes >= _MAX_NODES) return; _activeNodes++; const now = ctx.currentTime; const bufLen = Math.ceil(ctx.sampleRate * duration); const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1; const src = ctx.createBufferSource(); src.buffer = buf; const gain = ctx.createGain(); gain.gain.setValueAtTime(gainPeak, now); gain.gain.exponentialRampToValueAtTime(0.001, now + duration); if (opts.filterFreq) { const filter = ctx.createBiquadFilter(); filter.type = opts.filterType ?? 'bandpass'; filter.frequency.setValueAtTime(opts.filterFreq, now); filter.Q.setValueAtTime(opts.Q ?? 1, now); src.connect(filter); filter.connect(gain); } else { src.connect(gain); } gain.connect(ctx.destination); src.onended = () => { _activeNodes--; }; src.start(now); } // ── SOUND EFFECTS ───────────────────────────────────────────── function sfx_cannon() { // Deep thud + sharp crack playNoise(0.4, 0.12, { filterFreq: 180, filterType: 'lowpass', Q: 0.8 }); playTone(120, 'sawtooth', 0.3, 0.08, { freqEnd: 40 }); } function sfx_flamethrower() { // Hissy noise burst playNoise(0.25, 0.09, { filterFreq: 2200, filterType: 'bandpass', Q: 0.5 }); } function sfx_lightning() { // Sharp electric snap playNoise(0.5, 0.06, { filterFreq: 4000, filterType: 'highpass' }); playTone(800, 'sawtooth', 0.2, 0.05, { freqEnd: 200 }); } function sfx_mortar_launch() { // Low thump with whistle playTone(80, 'sine', 0.35, 0.15, { freqEnd: 300 }); playNoise(0.2, 0.15, { filterFreq: 300, filterType: 'lowpass' }); } function sfx_mortar_explode() { // Big boom playNoise(0.7, 0.3, { filterFreq: 250, filterType: 'lowpass', Q: 0.5 }); playTone(60, 'sine', 0.5, 0.25, { freqEnd: 20 }); } function sfx_laser() { // High-pitched zap playTone(1200, 'sine', 0.15, 0.05, { freqEnd: 800 }); } function sfx_freeze() { // Icy crystalline sound playTone(1800, 'sine', 0.2, 0.2, { freqEnd: 600, attack: 0.02 }); playTone(2200, 'triangle', 0.1, 0.15, { freqEnd: 900 }); } function sfx_void() { // Deep rumbling whoosh playTone(55, 'sawtooth', 0.3, 0.4, { freqEnd: 40, filterFreq: 200, filterEnd: 80 }); playNoise(0.15, 0.4, { filterFreq: 120, filterType: 'lowpass' }); } function sfx_missile_launch() { // Whoosh playNoise(0.3, 0.18, { filterFreq: 800, filterType: 'bandpass', Q: 0.6 }); playTone(300, 'sawtooth', 0.2, 0.18, { freqEnd: 100 }); } function sfx_arcane() { // Magical chime playTone(1400, 'sine', 0.15, 0.07, { freqEnd: 700 }); } function sfx_poison_launch() { // Bubbly squirt playTone(400, 'sine', 0.2, 0.1, { freqEnd: 150 }); playNoise(0.1, 0.1, { filterFreq: 600, filterType: 'bandpass' }); } function sfx_enemy_die() { // Short crunch playNoise(0.3, 0.08, { filterFreq: 600, filterType: 'bandpass', Q: 1.5 }); playTone(200, 'square', 0.15, 0.06, { freqEnd: 80 }); } function sfx_enemy_breach() { // Alarm-like hit playTone(220, 'sawtooth', 0.5, 0.3, { freqEnd: 110 }); playNoise(0.4, 0.2, { filterFreq: 400, filterType: 'lowpass' }); } function sfx_buy() { // Satisfying purchase chime playTone(600, 'sine', 0.2, 0.08); playTone(800, 'sine', 0.15, 0.08, { attack: 0.04 }); } function sfx_refund() { // Reverse chime playTone(800, 'sine', 0.15, 0.08); playTone(600, 'sine', 0.2, 0.08, { attack: 0.04 }); } function sfx_error() { // Low buzzer playTone(180, 'square', 0.2, 0.12, { freqEnd: 160 }); } function sfx_portal_open() { // Whooshy portal open playTone(300, 'sine', 0.15, 0.3, { freqEnd: 900, attack: 0.1 }); playNoise(0.08, 0.3, { filterFreq: 1200, filterType: 'bandpass' }); } function sfx_tower_damage() { // Heavy metallic impact playNoise(0.5, 0.18, { filterFreq: 350, filterType: 'lowpass' }); playTone(100, 'sawtooth', 0.35, 0.15, { freqEnd: 50 }); } // Map weapon def id → fire sfx const WEAPON_SFX = { cannon: sfx_cannon, flamethrower: sfx_flamethrower, chainlightning: sfx_lightning, mortar: sfx_mortar_launch, laser: sfx_laser, freezebomb: sfx_freeze, voidcannon: sfx_void, missilepod: sfx_missile_launch, arcaneturret: sfx_arcane, poisoncloud: sfx_poison_launch, }; function sfx_weapon_fire(defId) { const fn = WEAPON_SFX[defId]; if (fn) fn(); } // Throttle repeated sounds (e.g. flamethrower fires every 8 frames) const _sfxThrottle = {}; function sfx_weapon_fire_throttled(defId, minInterval = 100) { const now = Date.now(); if (_sfxThrottle[defId] && now - _sfxThrottle[defId] < minInterval) return; _sfxThrottle[defId] = now; sfx_weapon_fire(defId); }