Spaces:
Running
Running
// Lightweight Web Audio synth for SFX | |
import { CFG } from './config.js'; | |
let ctx = null; | |
let master = null; | |
let musicBus = null; | |
let musicState = null; | |
function ensureContext() { | |
if (!ctx) { | |
const AC = window.AudioContext || window.webkitAudioContext; | |
ctx = new AC(); | |
master = ctx.createGain(); | |
master.gain.value = (CFG.audio && CFG.audio.master != null) ? CFG.audio.master : 0.6; | |
master.connect(ctx.destination); | |
// Dedicated bus for background music so we can control it separately | |
musicBus = ctx.createGain(); | |
musicBus.gain.value = (CFG.audio && CFG.audio.musicVol != null) ? CFG.audio.musicVol : 0.18; | |
musicBus.connect(master); | |
} | |
if (ctx.state === 'suspended') ctx.resume(); | |
} | |
export function initAudio() { | |
ensureContext(); | |
} | |
export function resumeAudio() { | |
ensureContext(); | |
} | |
// ------------------------------- | |
// Background music (8-bit style) | |
// ------------------------------- | |
function midiToFreq(m) { | |
return 440 * Math.pow(2, (m - 69) / 12); | |
} | |
function envGain(at, g, a = 0.004, d = 0.18, level = 1.0) { | |
g.gain.cancelScheduledValues(at); | |
g.gain.setValueAtTime(0.0001, at); | |
g.gain.linearRampToValueAtTime(level, at + a); | |
g.gain.exponentialRampToValueAtTime(0.0001, at + d); | |
} | |
function note(at, midi, dur = 0.15, type = 'square', gainMul = 0.25, dest = musicBus) { | |
const o = ctx.createOscillator(); | |
o.type = type; | |
o.frequency.setValueAtTime(midiToFreq(midi), at); | |
const g = ctx.createGain(); | |
envGain(at, g, 0.003, Math.max(0.06, dur), gainMul); | |
o.connect(g).connect(dest); | |
o.start(at); | |
o.stop(at + dur + 0.02); | |
} | |
function kick(at, vol = 0.5) { | |
const o = ctx.createOscillator(); o.type = 'sine'; | |
const g = ctx.createGain(); | |
// Pitch decay for punchy kick | |
o.frequency.setValueAtTime(130, at); | |
o.frequency.exponentialRampToValueAtTime(48, at + 0.12); | |
envGain(at, g, 0.002, 0.16, vol * 0.9); | |
o.connect(g).connect(musicBus); | |
o.start(at); o.stop(at + 0.2); | |
} | |
function snare(at, vol = 0.35) { | |
const len = Math.floor(ctx.sampleRate * 0.12); | |
const b = ctx.createBuffer(1, len, ctx.sampleRate); | |
const ch = b.getChannelData(0); | |
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len); | |
const src = ctx.createBufferSource(); src.buffer = b; | |
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.9; | |
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 600; | |
const g = ctx.createGain(); envGain(at, g, 0.001, 0.13, vol); | |
src.connect(hp).connect(bp).connect(g).connect(musicBus); | |
src.start(at); src.stop(at + 0.14); | |
} | |
function hat(at, vol = 0.15) { | |
const len = Math.floor(ctx.sampleRate * 0.04); | |
const b = ctx.createBuffer(1, len, ctx.sampleRate); | |
const ch = b.getChannelData(0); | |
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len); | |
const src = ctx.createBufferSource(); src.buffer = b; | |
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 4000; | |
const g = ctx.createGain(); envGain(at, g, 0.0006, 0.06, vol); | |
src.connect(hp).connect(g).connect(musicBus); | |
src.start(at); src.stop(at + 0.06); | |
} | |
// Scale: natural minor (Aeolian) | |
const SCALE = [0, 2, 3, 5, 7, 8, 10]; | |
function degreeToMidi(rootMidi, deg, octaveOffset = 0) { | |
const idx = ((deg % 7) + 7) % 7; | |
const oct = Math.floor(deg / 7) + octaveOffset; | |
return rootMidi + SCALE[idx] + 12 * oct; | |
} | |
function makeMusicState() { | |
const tempo = (CFG.audio && CFG.audio.musicTempo) || 138; | |
const spb = 60 / tempo; // seconds per quarter note | |
return { | |
running: false, | |
tempo, | |
spb, | |
stepDur: spb / 4, // 16th notes | |
nextTime: 0, | |
step: 0, | |
timer: null, | |
lookaheadMs: 25, | |
scheduleHorizon: 0.2, | |
// musical state | |
rootMidi: 45, // A2 base root | |
barLen: 16, // 16th steps per bar | |
// choose a fresh progression occasionally | |
progression: pickProgression(), | |
barInProg: 0 | |
}; | |
} | |
function pickProgression() { | |
// Common minor progressions (degrees in natural minor) | |
const progs = [ | |
[0, 5, 2, 6], // i - VI - III - VII | |
[0, 3, 6, 2], // i - iv - VII - III | |
[0, 5, 6, 4], // i - VI - VII - v | |
[0, 2, 5, 6] // i - III - VI - VII | |
]; | |
return progs[(Math.random() * progs.length) | 0]; | |
} | |
function currentChordDegrees(ms) { | |
const deg = ms.progression[ms.barInProg % ms.progression.length]; | |
return [deg, deg + 2, deg + 4]; | |
} | |
function scheduleMusic(ms) { | |
const now = ctx.currentTime; | |
while (ms.nextTime < now + ms.scheduleHorizon) { | |
const t = ms.nextTime; | |
const stepInBar = ms.step % ms.barLen; | |
const barIdx = Math.floor(ms.step / ms.barLen); | |
if (stepInBar === 0 && barIdx % 4 === 0 && Math.random() < 0.5) { | |
// Occasionally change progression for variety | |
ms.progression = pickProgression(); | |
} | |
if (stepInBar === 0) { | |
ms.barInProg = (ms.barInProg + 1) % ms.progression.length; | |
} | |
const triad = currentChordDegrees(ms); | |
// Drums | |
if (stepInBar === 0 || stepInBar === 8) kick(t, 0.5); | |
if (stepInBar === 4 || stepInBar === 12) snare(t, 0.32); | |
if (stepInBar % 2 === 0) hat(t, (stepInBar % 4 === 0) ? 0.12 : 0.10); | |
// Bass (triangle) on 8ths | |
if (stepInBar % 2 === 0) { | |
const bassDeg = (stepInBar < 8) ? triad[0] : (Math.random() < 0.5 ? triad[0] : triad[2]); | |
const bassMidi = degreeToMidi(ms.rootMidi, bassDeg - 7, 0); // keep it low | |
note(t, bassMidi, ms.stepDur * 1.6, 'triangle', 0.22); | |
} | |
// Arpeggio (square) on 16ths | |
{ | |
const arpIdx = (stepInBar % 8); | |
const choice = [triad[0], triad[1], triad[2], triad[1], triad[0], triad[1], triad[2], triad[1]][arpIdx]; | |
const octaveLift = (stepInBar >= 8) ? 1 : 0; | |
const arpMidi = degreeToMidi(ms.rootMidi + 12, choice, octaveLift); | |
note(t, arpMidi, ms.stepDur * 0.9, 'square', 0.14); | |
} | |
// Occasional lead blip | |
if (stepInBar === 7 && Math.random() < 0.5) { | |
const leadDeg = triad[2] + 2; // a step above the fifth | |
const leadMidi = degreeToMidi(ms.rootMidi + 24, leadDeg, 0); | |
note(t, leadMidi, ms.stepDur * 2.5, 'square', 0.12); | |
} | |
ms.nextTime += ms.stepDur; | |
ms.step++; | |
} | |
} | |
function tickScheduler() { | |
if (!musicState || !musicState.running) return; | |
scheduleMusic(musicState); | |
} | |
export function startMusic() { | |
if (CFG.audio && CFG.audio.musicEnabled === false) return; | |
ensureContext(); | |
if (musicState && musicState.running) return; // already running | |
if (!musicState) musicState = makeMusicState(); | |
// Recreate in case tempo changed | |
const tempo = (CFG.audio && CFG.audio.musicTempo) || musicState.tempo || 138; | |
musicState.tempo = tempo; | |
musicState.spb = 60 / tempo; | |
musicState.stepDur = musicState.spb / 4; | |
musicState.nextTime = ctx.currentTime + 0.05; | |
musicState.step = 0; | |
musicState.running = true; | |
// Smoothly set bus gain to configured level | |
const target = (CFG.audio && CFG.audio.musicVol != null) ? CFG.audio.musicVol : 0.18; | |
musicBus.gain.cancelScheduledValues(ctx.currentTime); | |
musicBus.gain.setValueAtTime(musicBus.gain.value, ctx.currentTime); | |
musicBus.gain.linearRampToValueAtTime(target, ctx.currentTime + 0.25); | |
if (musicState.timer) clearInterval(musicState.timer); | |
musicState.timer = setInterval(tickScheduler, musicState.lookaheadMs); | |
} | |
export function stopMusic(fade = 0.4) { | |
if (!musicState || !musicState.running) return; | |
musicState.running = false; | |
if (musicState.timer) { clearInterval(musicState.timer); musicState.timer = null; } | |
// Fade out the bus quickly | |
const t = ctx.currentTime; | |
musicBus.gain.cancelScheduledValues(t); | |
musicBus.gain.setValueAtTime(musicBus.gain.value, t); | |
musicBus.gain.linearRampToValueAtTime(0.0001, t + fade); | |
} | |
// Simple gunshot: noise burst + filtered click + low thump | |
export function playGunshot() { | |
ensureContext(); | |
const t0 = ctx.currentTime; | |
// White noise burst | |
const noiseBuf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.12), ctx.sampleRate); | |
const data = noiseBuf.getChannelData(0); | |
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * 0.9; | |
const noise = ctx.createBufferSource(); | |
noise.buffer = noiseBuf; | |
const hp = ctx.createBiquadFilter(); | |
hp.type = 'highpass'; | |
hp.frequency.value = 800; | |
const lp = ctx.createBiquadFilter(); | |
lp.type = 'lowpass'; | |
lp.frequency.setValueAtTime(6000, t0); | |
lp.frequency.exponentialRampToValueAtTime(1200, t0 + 0.12); | |
const nGain = ctx.createGain(); | |
nGain.gain.setValueAtTime(0.0, t0); | |
nGain.gain.linearRampToValueAtTime((CFG.audio?.gunshotVol ?? 0.9) * 0.7, t0 + 0.002); | |
nGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.12); | |
noise.connect(hp).connect(lp).connect(nGain).connect(master); | |
noise.start(t0); | |
noise.stop(t0 + 0.13); | |
// Low thump | |
const osc = ctx.createOscillator(); | |
osc.type = 'sine'; | |
osc.frequency.setValueAtTime(160, t0); | |
osc.frequency.exponentialRampToValueAtTime(60, t0 + 0.12); | |
const oGain = ctx.createGain(); | |
oGain.gain.setValueAtTime(0.0, t0); | |
oGain.gain.linearRampToValueAtTime((CFG.audio?.gunshotVol ?? 0.9) * 0.3, t0 + 0.005); | |
oGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.14); | |
osc.connect(oGain).connect(master); | |
osc.start(t0); | |
osc.stop(t0 + 0.16); | |
} | |
// Headshot: bright, short bell with slight pitch down | |
export function playHeadshot() { | |
ensureContext(); | |
const t0 = ctx.currentTime; | |
const baseVol = (CFG.audio?.headshotVol ?? 0.8); | |
// Two detuned triangles | |
const makeVoice = (freq, detune) => { | |
const o = ctx.createOscillator(); | |
o.type = 'triangle'; | |
o.frequency.setValueAtTime(freq, t0); | |
o.detune.setValueAtTime(detune, t0); | |
o.frequency.exponentialRampToValueAtTime(freq * 0.75, t0 + 0.18); | |
const g = ctx.createGain(); | |
g.gain.setValueAtTime(0.0, t0); | |
g.gain.linearRampToValueAtTime(baseVol * 0.45, t0 + 0.005); | |
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.22); | |
o.connect(g); | |
return { o, g }; | |
}; | |
const v1 = makeVoice(1100, 0); | |
const v2 = makeVoice(1100 * 1.5, 8); | |
const bp = ctx.createBiquadFilter(); | |
bp.type = 'bandpass'; | |
bp.frequency.setValueAtTime(1500, t0); | |
bp.Q.value = 3; | |
v1.g.connect(bp); | |
v2.g.connect(bp); | |
bp.connect(master); | |
v1.o.start(t0); v1.o.stop(t0 + 0.24); | |
v2.o.start(t0); v2.o.stop(t0 + 0.24); | |
// Tiny click to emphasize | |
const click = ctx.createBufferSource(); | |
const buf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.01), ctx.sampleRate); | |
const ch = buf.getChannelData(0); | |
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length); | |
click.buffer = buf; | |
const cGain = ctx.createGain(); | |
cGain.gain.value = baseVol * 0.25; | |
click.connect(cGain).connect(master); | |
click.start(t0); | |
} | |
// Explosion: bass thump + noise burst with decay | |
export function playExplosion() { | |
ensureContext(); | |
const t0 = ctx.currentTime; | |
const masterVol = (CFG.audio?.master ?? 0.6); | |
const base = 0.9 * masterVol; | |
// Low boom | |
const boom = ctx.createOscillator(); | |
boom.type = 'sine'; | |
boom.frequency.setValueAtTime(120, t0); | |
boom.frequency.exponentialRampToValueAtTime(45, t0 + 0.5); | |
const bGain = ctx.createGain(); | |
bGain.gain.setValueAtTime(0.0001, t0); | |
bGain.gain.linearRampToValueAtTime(base * 0.8, t0 + 0.02); | |
bGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.8); | |
boom.connect(bGain).connect(master); | |
boom.start(t0); boom.stop(t0 + 0.9); | |
// Noise blast (band-limited) | |
const len = Math.floor(ctx.sampleRate * 0.4); | |
const buf = ctx.createBuffer(1, len, ctx.sampleRate); | |
const ch = buf.getChannelData(0); | |
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len); | |
const noise = ctx.createBufferSource(); | |
noise.buffer = buf; | |
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600; | |
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 80; | |
const nGain = ctx.createGain(); | |
nGain.gain.setValueAtTime(0.0001, t0); | |
nGain.gain.linearRampToValueAtTime(base * 0.7, t0 + 0.01); | |
nGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.45); | |
noise.connect(hp).connect(lp).connect(nGain).connect(master); | |
noise.start(t0); noise.stop(t0 + 0.5); | |
} | |
// Shimmery pickup chime for powerups | |
export function playPowerupPickup() { | |
ensureContext(); | |
const t0 = ctx.currentTime; | |
const vol = (CFG.audio?.pickupVol ?? 1.0); | |
// Bell-like "gling": bright metallic ping with tiny upward gliss and sparkle | |
const makeGling = (at, freq, dur, level) => { | |
const o = ctx.createOscillator(); o.type = 'sine'; | |
const m = ctx.createOscillator(); m.type = 'sine'; | |
const mg = ctx.createGain(); mg.gain.value = freq * 1.15; // FM index | |
m.connect(mg).connect(o.frequency); | |
// Slight upward glide for a cheerful attack | |
o.frequency.setValueAtTime(freq * 0.94, at); | |
o.frequency.exponentialRampToValueAtTime(freq, at + 0.04); | |
m.frequency.setValueAtTime(freq * 1.6, at); | |
m.frequency.exponentialRampToValueAtTime(freq * 1.9, at + 0.04); | |
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 12; bp.frequency.value = freq * 1.15; | |
const g = ctx.createGain(); | |
g.gain.setValueAtTime(0.0001, at); | |
g.gain.linearRampToValueAtTime(level * vol, at + 0.008); | |
g.gain.exponentialRampToValueAtTime(0.0001, at + dur); | |
o.connect(bp).connect(g).connect(master); | |
o.start(at); m.start(at); | |
o.stop(at + dur + 0.02); m.stop(at + dur + 0.02); | |
}; | |
makeGling(t0 + 0.00, 1800, 0.28, 0.55); | |
makeGling(t0 + 0.02, 2400, 0.22, 0.40); | |
// Sparkle tail | |
const len = Math.floor(ctx.sampleRate * 0.06); | |
const b = ctx.createBuffer(1, len, ctx.sampleRate); | |
const ch = b.getChannelData(0); | |
for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len); | |
const src = ctx.createBufferSource(); src.buffer = b; | |
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3200; | |
const g2 = ctx.createGain(); | |
const t1 = t0 + 0.015; | |
g2.gain.setValueAtTime(0.0001, t1); | |
g2.gain.linearRampToValueAtTime(0.18 * vol, t1 + 0.01); | |
g2.gain.exponentialRampToValueAtTime(0.0001, t1 + 0.08); | |
src.connect(hp).connect(g2).connect(master); | |
src.start(t1); src.stop(t1 + 0.10); | |
} | |
// FM metal ping helper | |
function fmPing(t, { freq = 650, mod = 1200, index = 1.2, dur = 0.14, gain = 0.28, bpFreq = 1800, q = 8 }) { | |
const o = ctx.createOscillator(); o.type = 'sine'; | |
const m = ctx.createOscillator(); m.type = 'sine'; | |
const mg = ctx.createGain(); mg.gain.value = freq * index; | |
m.connect(mg).connect(o.frequency); | |
o.frequency.setValueAtTime(freq, t); | |
m.frequency.setValueAtTime(mod, t); | |
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = q; bp.frequency.value = bpFreq; | |
const g = ctx.createGain(); | |
g.gain.setValueAtTime(0.0001, t); | |
g.gain.linearRampToValueAtTime(gain * (CFG.audio?.reloadVol ?? 0.7), t + 0.006); | |
g.gain.exponentialRampToValueAtTime(0.0001, t + dur); | |
o.connect(bp).connect(g).connect(master); | |
o.start(t); m.start(t); | |
o.stop(t + dur + 0.02); m.stop(t + dur + 0.02); | |
} | |
function tick(t, len = 0.012, gain = 0.18) { | |
const src = ctx.createBufferSource(); | |
const b = ctx.createBuffer(1, Math.floor(ctx.sampleRate * len), ctx.sampleRate); | |
const ch = b.getChannelData(0); | |
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length); | |
src.buffer = b; | |
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2000; | |
const g = ctx.createGain(); g.gain.value = gain * (CFG.audio?.reloadVol ?? 0.7); | |
src.connect(hp).connect(g).connect(master); | |
src.start(t); src.stop(t + len + 0.005); | |
} | |
// Start-of-reload metallic cue (short, crisp) | |
export function playReloadStart() { | |
ensureContext(); | |
const t0 = ctx.currentTime; | |
fmPing(t0 + 0.0, { freq: 900, mod: 1800, index: 1.4, dur: 0.10, gain: 0.22, bpFreq: 2200, q: 10 }); | |
tick(t0 + 0.01, 0.01, 0.12); | |
} | |
// End-of-reload latch (deeper metallic clack) | |
export function playReloadEnd() { | |
ensureContext(); | |
const t0 = ctx.currentTime; | |
fmPing(t0, { freq: 520, mod: 900, index: 1.0, dur: 0.16, gain: 0.32, bpFreq: 1500, q: 7 }); | |
fmPing(t0 + 0.02, { freq: 780, mod: 1400, index: 0.9, dur: 0.12, gain: 0.18, bpFreq: 1900, q: 9 }); | |
tick(t0 + 0.005, 0.012, 0.2); | |
} | |