diff --git a/public/js/core/audio.js b/public/js/core/audio.js new file mode 100644 index 0000000..92d4b43 --- /dev/null +++ b/public/js/core/audio.js @@ -0,0 +1,197 @@ +/* ============================================================ + DSP ARCADE — chiptune audio engine (Web Audio API) + Procedural 8-bit SFX + looping background music. No files. + ============================================================ */ +(function () { + 'use strict'; + + let actx = null; + let master = null; + let musicGain = null; + let sfxGain = null; + let muted = false; + let musicTimer = null; + let musicStep = 0; + let currentTrack = null; + + function ensure() { + if (actx) return actx; + actx = new (window.AudioContext || window.webkitAudioContext)(); + master = actx.createGain(); + master.gain.value = 0.5; + master.connect(actx.destination); + musicGain = actx.createGain(); + musicGain.gain.value = 0.35; + musicGain.connect(master); + sfxGain = actx.createGain(); + sfxGain.gain.value = 0.6; + sfxGain.connect(master); + return actx; + } + + function resume() { + const a = ensure(); + if (a.state === 'suspended') a.resume(); + } + + // Unlock on first user gesture + window.addEventListener('keydown', resume, { once: false }); + window.addEventListener('pointerdown', resume, { once: false }); + + // ---------- SFX ---------- + function blip(freq, dur, type = 'square', vol = 0.5, slide = 0) { + const a = ensure(); + const t = a.currentTime; + const o = a.createOscillator(); + const g = a.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t); + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur); + g.gain.setValueAtTime(vol, t); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + o.connect(g); g.connect(sfxGain); + o.start(t); o.stop(t + dur + 0.02); + } + + function noise(dur, vol = 0.4, filterFreq = 1000) { + const a = ensure(); + const t = a.currentTime; + const len = Math.floor(a.sampleRate * dur); + const buf = a.createBuffer(1, len, a.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + const src = a.createBufferSource(); + src.buffer = buf; + const f = a.createBiquadFilter(); + f.type = 'lowpass'; + f.frequency.value = filterFreq; + const g = a.createGain(); + g.gain.setValueAtTime(vol, t); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + src.connect(f); f.connect(g); g.connect(sfxGain); + src.start(t); + } + + const SFX = { + shoot: () => { blip(880, 0.08, 'square', 0.35, -600); }, + laser: () => { blip(1200, 0.1, 'sawtooth', 0.3, -900); }, + explode: () => { noise(0.35, 0.5, 800); blip(120, 0.3, 'triangle', 0.5, -80); }, + bigboom: () => { noise(0.6, 0.7, 500); blip(80, 0.5, 'triangle', 0.6, -50); }, + powerup: () => { blip(523, 0.08, 'square', 0.4); setTimeout(() => blip(659, 0.08, 'square', 0.4), 80); setTimeout(() => blip(784, 0.12, 'square', 0.4), 160); }, + hit: () => { blip(220, 0.06, 'square', 0.3, -100); }, + brick: () => { blip(440, 0.05, 'square', 0.3, 200); }, + wall: () => { blip(150, 0.05, 'triangle', 0.3); }, + eat: () => { blip(660, 0.07, 'square', 0.35, 300); }, + bonus: () => { blip(784, 0.07, 'square', 0.4); setTimeout(() => blip(1046, 0.1, 'square', 0.4), 70); }, + levelup: () => { [523, 659, 784, 1046].forEach((f, i) => setTimeout(() => blip(f, 0.12, 'square', 0.4), i * 90)); }, + gameover: () => { [440, 349, 293, 220, 174].forEach((f, i) => setTimeout(() => blip(f, 0.22, 'triangle', 0.5), i * 160)); }, + start: () => { [262, 392, 523, 784].forEach((f, i) => setTimeout(() => blip(f, 0.1, 'square', 0.4), i * 70)); }, + select: () => { blip(988, 0.06, 'square', 0.3); }, + thrust: () => { noise(0.08, 0.12, 400); }, + jump: () => { blip(300, 0.2, 'sawtooth', 0.3, 700); }, + die: () => { blip(400, 0.4, 'sawtooth', 0.4, -350); }, + }; + + function sfx(name) { + if (muted) return; + try { if (SFX[name]) SFX[name](); } catch (e) { /* audio not ready */ } + } + + // ---------- Music ---------- + const N = null; + const TRACKS = { + menu: { + bpm: 110, + bass: [65.4, N, 65.4, N, 98, N, 98, N, 73.4, N, 73.4, N, 87.3, N, 87.3, N], + lead: [N, N, 261.6, N, 329.6, N, 392, N, 349.2, N, 349.2, N, 440, N, 392, N], + }, + asteroid: { + bpm: 132, + bass: [55, N, 55, 55, N, 55, 65.4, N, 55, N, 55, 55, N, 55, 49, N], + lead: [N, 220, N, 261.6, N, 293.7, N, 329.6, N, 293.7, N, 261.6, N, 220, N, 196], + }, + breakout: { + bpm: 140, + bass: [82.4, N, 82.4, N, 98, N, 98, N, 110, N, 110, N, 98, N, 82.4, N], + lead: [329.6, N, 392, N, 440, N, 523.3, N, 440, N, 392, N, 329.6, N, 293.7, N], + }, + snake: { + bpm: 124, + bass: [73.4, N, 73.4, N, 87.3, N, 87.3, N, 98, N, 98, N, 110, N, 110, N], + lead: [N, N, 293.7, N, 349.2, N, 392, N, 440, N, 392, N, 349.2, N, 293.7, N], + }, + invader: { + bpm: 128, + bass: [61.7, N, 61.7, N, 73.4, N, 73.4, N, 82.4, N, 82.4, N, 98, N, 98, N], + lead: [N, 246.9, N, 293.7, N, 329.6, N, 392, N, 329.6, N, 293.7, N, 246.9, N, 220], + }, + }; + + function playNote(freq, when, dur, type, dest, vol) { + const a = ensure(); + const o = a.createOscillator(); + const g = a.createGain(); + o.type = type; + o.frequency.value = freq; + g.gain.setValueAtTime(vol, when); + g.gain.setValueAtTime(vol, when + dur * 0.7); + g.gain.exponentialRampToValueAtTime(0.001, when + dur); + o.connect(g); g.connect(dest); + o.start(when); o.stop(when + dur + 0.02); + } + + function scheduleMusic() { + if (!currentTrack || muted) return; + const track = TRACKS[currentTrack]; + if (!track) return; + const a = ensure(); + const spb = 60 / track.bpm / 2; + const now = a.currentTime + 0.05; + for (let i = 0; i < track.bass.length; i++) { + const step = musicStep + i; + const t = now + i * spb; + if (track.bass[i]) playNote(track.bass[i], t, spb * 0.9, 'triangle', musicGain, 0.5); + if (track.lead[i]) playNote(track.lead[i], t, spb * 0.85, 'square', musicGain, 0.22); + if (i % 2 === 1) { + const len = Math.floor(a.sampleRate * 0.03); + const buf = a.createBuffer(1, len, a.sampleRate); + const d = buf.getChannelData(0); + for (let j = 0; j < len; j++) d[j] = (Math.random() * 2 - 1) * 0.3; + const src = a.createBufferSource(); src.buffer = buf; + const f = a.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6000; + const g = a.createGain(); g.gain.value = 0.08; + src.connect(f); f.connect(g); g.connect(musicGain); + src.start(t); + } + } + musicStep += track.bass.length; + musicTimer = setTimeout(scheduleMusic, track.bpm / 60 * track.bass.length * 1000 * 0.92); + } + + function playMusic(name) { + stopMusic(); + if (!TRACKS[name]) return; + currentTrack = name; + musicStep = 0; + if (!muted) scheduleMusic(); + } + + function stopMusic() { + if (musicTimer) { clearTimeout(musicTimer); musicTimer = null; } + currentTrack = null; + } + + function setMuted(m) { + muted = m; + if (master) master.gain.value = m ? 0 : 0.5; + if (m) stopMusic(); + else if (currentTrack) scheduleMusic(); + } + + window.DSP_AUDIO = { + sfx, playMusic, stopMusic, setMuted, + isMuted: () => muted, + toggleMute: () => { setMuted(!muted); return muted; }, + resume, + }; +})(); \ No newline at end of file