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); | |
| } | |