/* ============================================================ * Diablo2D — audio.js : procedural WebAudio engine * Synthesized SFX + adaptive generative music. Zero assets. * ============================================================ */ 'use strict'; window.D2 = window.D2 || {}; (function (D2) { let ctx = null; let masterGain = null, sfxGain = null, musicGain = null; let noiseBuf = null; let unlocked = false; const vol = { master: 0.8, music: 0.55, sfx: 0.9 }; let activeVoices = 0; const MAX_VOICES = 28; /* ---------- core plumbing ---------- */ function unlock() { if (unlocked && ctx && ctx.state === 'running') return; try { if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); masterGain = ctx.createGain(); sfxGain = ctx.createGain(); musicGain = ctx.createGain(); sfxGain.connect(masterGain); musicGain.connect(masterGain); masterGain.connect(ctx.destination); applyVolumes(); noiseBuf = makeNoiseBuffer(); } if (ctx.state === 'suspended') ctx.resume(); unlocked = true; if (pendingMood) playMusic(pendingMood); } catch (e) { console.warn('[audio] init failed', e); } } function makeNoiseBuffer() { const len = ctx.sampleRate * 1.2; const buf = ctx.createBuffer(1, len, ctx.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; return buf; } function setVolumes(m, mu, sx) { if (m !== undefined) vol.master = m; if (mu !== undefined) vol.music = mu; if (sx !== undefined) vol.sfx = sx; applyVolumes(); } function applyVolumes() { if (!masterGain) return; masterGain.gain.value = vol.master; musicGain.gain.value = vol.music; sfxGain.gain.value = vol.sfx; } function ready() { return unlocked && ctx && ctx.state === 'running'; } function canPlay() { if (!ready()) return false; if (activeVoices >= MAX_VOICES) return false; activeVoices++; setTimeout(() => { activeViewsDec(); }, 4000); return true; } function activeViewsDec() { activeVoices = Math.max(0, activeVoices - 1); } /* ---------- synth primitives ---------- */ function outNode(dest, pan) { if (pan !== undefined && ctx.createStereoPanner) { const p = ctx.createStereoPanner(); p.pan.value = Math.max(-1, Math.min(1, pan)); p.connect(dest); return p; } return dest; } /** basic enveloped oscillator */ function tone(o) { if (!canPlay()) return; const t0 = ctx.currentTime + (o.delay || 0); const osc = ctx.createOscillator(); osc.type = o.type || 'sine'; osc.frequency.setValueAtTime(Math.max(20, o.freq), t0); if (o.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t0 + o.dur); if (o.detune) osc.detune.value = o.detune; const g = ctx.createGain(); const a = o.attack || 0.005; const v = (o.vol || 0.5); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(v, t0 + a); g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur); let node = g; if (o.filter) { const f = ctx.createBiquadFilter(); f.type = o.filter.type || 'lowpass'; f.frequency.value = o.filter.freq || 800; f.Q.value = o.filter.q || 0.8; g.connect(f); node = f; } osc.connect(g); node.connect(outNode(o.dest || sfxGain, o.pan)); osc.start(t0); osc.stop(t0 + o.dur + 0.05); osc.onended = activeViewsDec; } /** enveloped filtered noise burst */ function noise(o) { if (!canPlay()) return; const t0 = ctx.currentTime + (o.delay || 0); const src = ctx.createBufferSource(); src.buffer = noiseBuf; src.loop = true; src.playbackRate.value = o.rate || 1; const f = ctx.createBiquadFilter(); f.type = o.filterType || 'bandpass'; f.frequency.setValueAtTime(o.freq || 1000, t0); if (o.freqEnd) f.frequency.exponentialRampToValueAtTime(Math.max(30, o.freqEnd), t0 + o.dur); f.Q.value = o.q || 1; const g = ctx.createGain(); const a = o.attack || 0.004; const v = (o.vol || 0.5); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(v, t0 + a); g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur); src.connect(f); f.connect(g); g.connect(outNode(o.dest || sfxGain, o.pan)); src.start(t0); src.stop(t0 + o.dur + 0.05); src.onended = activeViewsDec; } const mtof = m => 440 * Math.pow(2, (m - 69) / 12); /* ---------- sfx recipes ---------- */ const sfxRecipes = { click() { tone({ type:'square', freq:2200, dur:.04, vol:.10 }); }, hover() { tone({ type:'sine', freq:1400, dur:.03, vol:.05 }); }, error() { tone({ type:'square', freq:160, freqEnd:110, dur:.16, vol:.16 }); }, swing() { noise({ filterType:'bandpass', freq:2600, freqEnd:500, dur:.13, vol:.22, q:1.6 }); }, hit() { noise({ filterType:'lowpass', freq:900, freqEnd:200, dur:.09, vol:.32 }); tone({ type:'sine', freq:150, freqEnd:70, dur:.08, vol:.30 }); }, crit() { noise({ filterType:'lowpass', freq:900, freqEnd:200, dur:.09, vol:.32 }); tone({ type:'sine', freq:150, freqEnd:70, dur:.08, vol:.30 }); tone({ type:'triangle', freq:1800, freqEnd:900, dur:.12, vol:.18, delay:.02 }); }, playerhurt(){ tone({ type:'sawtooth', freq:190, freqEnd:80, dur:.18, vol:.26 }); noise({ filterType:'lowpass', freq:600, freqEnd:150, dur:.14, vol:.25 }); }, shoot() { noise({ filterType:'highpass', freq:1800, freqEnd:3400, dur:.07, vol:.17 }); }, fireball() { noise({ filterType:'lowpass', freq:400, freqEnd:2400, dur:.3, vol:.24 }); tone({ type:'sawtooth', freq:120, freqEnd:60, dur:.28, vol:.12 }); }, explode() { noise({ filterType:'lowpass', freq:2500, freqEnd:60, dur:.55, vol:.45 }); tone({ type:'sine', freq:90, freqEnd:34, dur:.5, vol:.42 }); }, ice() { tone({ type:'sine', freq:2300, dur:.14, vol:.11 }); tone({ type:'sine', freq:3100, dur:.12, vol:.09, delay:.05 }); noise({ filterType:'highpass', freq:5200, dur:.18, vol:.10 }); }, lightning() { noise({ filterType:'bandpass', freq:3000, q:.6, dur:.16, vol:.26 }); tone({ type:'sawtooth', freq:800, freqEnd:200, dur:.12, vol:.10 }); }, poison() { noise({ filterType:'bandpass', freq:500, q:2, freqEnd:220, dur:.3, vol:.18 }); }, coin() { tone({ type:'sine', freq:mtof(98), dur:.09, vol:.14 }); tone({ type:'sine', freq:mtof(105), dur:.14, vol:.14, delay:.06 }); }, pickup() { tone({ type:'triangle', freq:mtof(88), dur:.1, vol:.16 }); tone({ type:'triangle', freq:mtof(95), dur:.16, vol:.16, delay:.07 }); }, legendary() { [76,83,88,95].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:.5,vol:.14,delay:i*.09})); noise({filterType:'highpass',freq:6000,dur:.7,vol:.06,delay:.1}); }, potion() { tone({ type:'sine', freq:520, freqEnd:280, dur:.12, vol:.16 }); tone({ type:'sine', freq:430, freqEnd:210, dur:.14, vol:.16, delay:.11 }); }, levelup() { [64,68,71,76].forEach((m,i)=>tone({type:'triangle',freq:mtof(m),dur:.42,vol:.2,delay:i*.11})); tone({ type:'sine', freq:mtof(88), dur:.9, vol:.12, delay:.44 }); }, die() { tone({ type:'sawtooth', freq:220, freqEnd:40, dur:.4, vol:.22 }); noise({ filterType:'lowpass', freq:800, freqEnd:100, dur:.35, vol:.2 }); }, bossdie() { noise({ filterType:'lowpass', freq:2500, freqEnd:60, dur:.55, vol:.45 }); tone({ type:'sine', freq:90, freqEnd:34, dur:.5, vol:.42 }); [52,46,40].forEach((m,i)=>tone({type:'sawtooth',freq:mtof(m),dur:1.1,vol:.22,delay:i*.16})); }, door() { tone({ type:'sawtooth', freq:90, freqEnd:130, dur:.5, vol:.12 }); noise({ filterType:'bandpass', freq:300, q:3, dur:.45, vol:.10 }); }, stairs() { tone({ type:'sine', freq:180, freqEnd:60, dur:.7, vol:.2 }); tone({ type:'sine', freq:270, freqEnd:90, dur:.7, vol:.12, delay:.1 }); }, shrine() { [57,64,69,72].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:1.4,vol:.12,delay:i*.05,attack:.3})); noise({filterType:'highpass',freq:7000,dur:1.2,vol:.05}); }, teleport() { tone({ type:'sine', freq:300, freqEnd:2400, dur:.25, vol:.16 }); noise({ filterType:'highpass', freq:1000, freqEnd:5000, dur:.25, vol:.14 }); }, buff() { [60,67,72].forEach((m,i)=>tone({type:'triangle',freq:mtof(m),dur:.7,vol:.14,delay:i*.04,attack:.05})); }, nova() { noise({ filterType:'bandpass', freq:300, freqEnd:3800, dur:.4, vol:.3, q:.7 }); tone({ type:'sawtooth', freq:70, freqEnd:200, dur:.35, vol:.16 }); }, bossroar() { tone({ type:'sawtooth', freq:110, freqEnd:38, dur:1.2, vol:.4 }); tone({ type:'square', freq:74, freqEnd:30, dur:1.2, vol:.22 }); noise({ filterType:'lowpass', freq:900, freqEnd:80, dur:1.1, vol:.3 }); }, buy() { coin(); coin(); }, sell() { coin(); }, questdone() { [67,72,76].forEach((m,i)=>tone({type:'sine',freq:mtof(m),dur:.6,vol:.16,delay:i*.1,attack:.03})); }, }; function sfx(name, opts = {}) { if (!ready()) return; const fn = sfxRecipes[name]; if (!fn) return; try { fn(opts); } catch (e) { /* never crash gameplay over audio */ } } /* ---------- generative music ---------- */ // scale = semitone offsets (minor/major/dorian etc.), patterns are step arrays of scale indices or null const MOODS = { title: { root: 45, bpm: 58, scale: [0,2,3,5,7,8,10], bass: [0,null,null,null,null,null,null,null,-2,null,null,null,null,null,null,null], pad: [[0,3,7],[ -2,2,5 ]], padBeats: 8, leadProb: .16, leadOct: 2, drums: null, bell: true }, town: { root: 48, bpm: 92, scale: [0,2,4,5,7,9,11], bass: [0,null,null,4,null,null,2,null,5,null,null,4,null,null,1,null], pad: [[0,4,7],[3,5,9],[-3,2,4],[0,4,7]], padBeats: 4, leadProb: .3, leadOct: 2, drums: null, pluck: true }, crypt: { root: 38, bpm: 62, scale: [0,2,3,5,7,8,10], bass: [0,null,null,null,null,null,0,null,-2,null,null,null,null,null,-2,null], pad: [[0,3,7],[0,3,8],[1,5,8],[0,3,7]], padBeats: 8, leadProb: .1, leadOct: 3, drums: null, choir: true }, cave: { root: 40, bpm: 76, scale: [0,2,3,5,7,9,10], bass: [0,null,null,null,5,null,null,null,3,null,null,null,7,null,null,null], pad: [[0,3,7],[5,7,10]], padBeats: 8, leadProb: .12, leadOct: 2, drums: { kick:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], snare:[], hat:[0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0] }, drip: true }, hell: { root: 33, bpm: 128, scale: [0,1,3,5,6,8,10], bass: [0,0,null,0,null,0,1,null,0,0,null,0,3,1,0,null], pad: [[0,1,6],[0,1,6]], padBeats: 8, leadProb: .2, leadOct: 1, drums: { kick:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], snare:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0], hat:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1] } }, boss: { root: 31, bpm: 142, scale: [0,1,3,5,7,8,10], bass: [0,0,3,0,0,5,3,1,0,0,3,0,6,5,3,1], pad: [[0,1,6],[-1,2,6]], padBeats: 8, leadProb: .3, leadOct: 2, drums: { kick:[1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0], snare:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], hat:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] } }, }; let pendingMood = null; let currentMood = null; let seqTimer = null; let nextNoteTime = 0; let step = 0; let barCount = 0; function playMusic(mood) { if (!MOODS[mood]) mood = 'crypt'; pendingMood = mood; if (!ready()) return; if (currentMood === mood && seqTimer) return; stopMusicNow(); currentMood = mood; step = 0; barCount = 0; nextNoteTime = ctx.currentTime + 0.08; seqTimer = setInterval(seqTick, 60); } function stopMusic(fadeSec = 0.8) { pendingMood = null; if (!ctx || !musicGain) return; const t0 = ctx.currentTime; musicGain.gain.cancelScheduledValues(t0); musicGain.gain.setValueAtTime(musicGain.gain.value, t0); musicGain.gain.linearRampToValueAtTime(0.0001, t0 + fadeSec); stopMusicNow(fadeSec); setTimeout(() => { if (musicGain && !seqTimer) musicGain.gain.value = vol.music; }, fadeSec * 1000 + 60); } function stopMusicNow(delaySec = 0) { if (seqTimer) { clearInterval(seqTimer); seqTimer = null; } currentMood = null; } function seqTick() { if (!currentMood || !ready()) return; const M = MOODS[currentMood]; const spb = 60 / M.bpm; // seconds per beat (quarter) const stepDur = spb / 4; // 16th steps while (nextNoteTime < ctx.currentTime + 0.18) { scheduleStep(M, step, nextNoteTime, stepDur); nextNoteTime += stepDur; step = (step + 1) % 16; if (step === 0) barCount++; } } function scheduleStep(M, st, time, stepDur) { const scale = M.scale; /* bass line */ const bIdx = M.bass[st]; if (bIdx !== null && bIdx !== undefined) { const midi = M.root + bIdx + (scale[0] === undefined ? 0 : 0); musicTone({ type: 'triangle', freq: mtof(midi), dur: stepDur * 2.4, vol: .17, time, filter: { type: 'lowpass', freq: 320 } }); if (M.drums) musicTone({ type: 'sawtooth', freq: mtof(midi - 12), dur: stepDur * 1.6, vol: .06, time, filter:{type:'lowpass',freq:200} }); } /* drums */ if (M.drums) { if (M.drums.kick[st]) musicKick(time); if (M.drums.snare[st]) musicNoise({ filterType: 'bandpass', freq: 1900, q: .8, dur: .09, vol: .1, time }); if (M.drums.hat[st]) musicNoise({ filterType: 'highpass', freq: 7500, dur: .03, vol: .045, time }); } /* pad chord each padBeats */ if (M.pad && st === 0) { const chord = M.pad[Math.floor(barCount / (M.padBeats / 16 || 1)) % M.pad.length] || M.pad[0]; for (const iv of chord) { const midi = M.root + 12 + iv; musicPad(midi, M.padBeats * stepDur, time); } } /* lead / bell melody */ if (Math.random() < M.leadProb && st % 2 === 0) { const deg = scale[Math.floor(Math.random() * scale.length)]; const midi = M.root + 12 * (M.leadOct || 2) + deg; if (M.bell) musicBell(midi, time); else if (M.pluck) musicPluck(midi, time); else if (M.choir) musicChoirNote(midi, time, stepDur * 6); else musicPluck(midi, time); } /* cave drip ambience */ if (M.drip && Math.random() < .05) { musicTone({ type: 'sine', freq: 1700 + Math.random() * 900, dur: .1, vol: .035, time }); } } /* music-routed voice wrappers (respect global volume via musicGain) */ function musicTone(o) { const saveDest = o.dest; o.dest = musicGain; o.time = o.time; toneAt(o); } function musicNoise(o) { o.dest = musicGain; noiseAt(o); } /* time-scheduled variants (absolute ctx time) — separate from sfx versions */ function toneAt(o) { if (!ready()) return; const t0 = o.time !== undefined ? o.time : ctx.currentTime; const osc = ctx.createOscillator(); osc.type = o.type || 'sine'; osc.frequency.setValueAtTime(Math.max(20, o.freq), t0); if (o.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t0 + o.dur); const g = ctx.createGain(); const a = o.attack || 0.01; g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(o.vol || .2, t0 + a); g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur); let node = g; if (o.filter) { const f = ctx.createBiquadFilter(); f.type = o.filter.type; f.frequency.value = o.filter.freq; g.connect(f); node = f; } osc.connect(g); node.connect(o.dest || musicGain); osc.start(t0); osc.stop(t0 + o.dur + .05); } function noiseAt(o) { if (!ready()) return; const t0 = o.time !== undefined ? o.time : ctx.currentTime; const src = ctx.createBufferSource(); src.buffer = noiseBuf; src.loop = true; const f = ctx.createBiquadFilter(); f.type = o.filterType || 'bandpass'; f.frequency.setValueAtTime(o.freq || 1000, t0); if (o.freqEnd) f.frequency.exponentialRampToValueAtTime(Math.max(30, o.freqEnd), t0 + o.dur); f.Q.value = o.q || 1; const g = ctx.createGain(); g.gain.setValueAtTime(.0001, t0); g.gain.exponentialRampToValueAtTime(o.vol || .2, t0 + (o.attack || .005)); g.gain.exponentialRampToValueAtTime(.0001, t0 + o.dur); src.connect(f); f.connect(g); g.connect(o.dest || musicGain); src.start(t0); src.stop(t0 + o.dur + .05); } function musicPad(midi, dur, time) { [-7, +7].forEach(det => { const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = mtof(midi); osc.detune.value = det; const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 620; const g = ctx.createGain(); g.gain.setValueAtTime(.0001, time); g.gain.linearRampToValueAtTime(.028, time + dur * .35); g.gain.linearRampToValueAtTime(.0001, time + dur); osc.connect(f); f.connect(g); g.connect(musicGain); osc.start(time); osc.stop(time + dur + .1); }); } function musicBell(midi, time) { toneAt({ type: 'sine', freq: mtof(midi), dur: 1.6, vol: .07, time, attack: .003 }); toneAt({ type: 'sine', freq: mtof(midi) * 2.01, dur: .9, vol: .03, time, attack: .003 }); } function musicPluck(midi, time) { toneAt({ type: 'triangle', freq: mtof(midi), dur: .3, vol: .09, time, attack: .002, filter: { type: 'lowpass', freq: 2200 } }); } function musicChoirNote(midi, time, dur) { [-5, +5].forEach(det => toneAt({ type: 'sawtooth', freq: mtof(midi), detune: det, dur, vol: .022, time, attack: dur * .3, filter: { type: 'lowpass', freq: 480 } })); } function musicKick(time) { toneAt({ type: 'sine', freq: 130, freqEnd: 38, dur: .16, vol: .3, time, attack: .002 }); } D2.audio = { unlock, ready, setVolumes, get volumes() { return { ...vol }; }, sfx, playMusic, stopMusic, get currentMood() { return currentMood || pendingMood; }, }; })(window.D2);