Add chiptune audio engine (Web Audio API)

This commit is contained in:
2026-09-24 10:06:48 +02:00
parent 75014ab8fa
commit ce4106d168
+197
View File
@@ -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,
};
})();