← back to Model Wars
public/sound.js
69 lines
// sound.js — procedural Web Audio SFX for the bouts. Zero assets, zero cost.
// All sounds are synthesized from oscillators + noise; nothing is loaded.
window.SFX = (function () {
'use strict';
let ctx = null, master = null;
let muted = localStorage.getItem('mw-muted') === '1';
// Audio must start from a user gesture (autoplay policy). Call on first click.
function init() {
if (ctx) { if (ctx.state === 'suspended') ctx.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
ctx = new AC();
master = ctx.createGain();
master.gain.value = muted ? 0 : 0.5;
master.connect(ctx.destination);
}
const on = () => ctx && !muted;
const now = () => ctx.currentTime;
function toggleMute() {
muted = !muted;
localStorage.setItem('mw-muted', muted ? '1' : '0');
if (master) master.gain.setTargetAtTime(muted ? 0 : 0.5, now(), 0.02);
return muted;
}
const isMuted = () => muted;
// ── primitives ────────────────────────────────────────
function tone(freq, t0, dur, { type = 'sine', gain = 0.3, glideTo = null, dest = null } = {}) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t0);
if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t0 + dur);
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0008, t0 + dur);
o.connect(g).connect(dest || master);
o.start(t0); o.stop(t0 + dur + 0.02);
}
function noise(t0, dur, { gain = 0.3, freq = 1200, q = 0.7, type = 'bandpass' } = {}) {
const n = Math.floor(ctx.sampleRate * dur);
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
const src = ctx.createBufferSource(); src.buffer = buf;
const f = ctx.createBiquadFilter(); f.type = type; f.frequency.value = freq; f.Q.value = q;
const g = ctx.createGain(); g.gain.value = gain;
src.connect(f).connect(g).connect(master);
src.start(t0);
}
// ── named effects ─────────────────────────────────────
const E = {
horn() { if (!on()) return; const t = now(); tone(180, t, 0.5, { type: 'sawtooth', gain: 0.22, glideTo: 240 }); tone(240, t + 0.12, 0.5, { type: 'sawtooth', gain: 0.18 }); },
charge() { if (!on()) return; const t = now(); for (let i = 0; i < 6; i++) noise(t + i * 0.11, 0.08, { gain: 0.14, freq: 90, q: 1.2, type: 'lowpass' }); }, // hoofbeats
clash() { if (!on()) return; const t = now(); noise(t, 0.16, { gain: 0.4, freq: 2600, q: 0.6 }); tone(1500, t, 0.25, { type: 'triangle', gain: 0.2, glideTo: 600 }); },
arrow() { if (!on()) return; const t = now(); tone(1800, t, 0.14, { type: 'sine', gain: 0.16, glideTo: 700 }); noise(t, 0.06, { gain: 0.1, freq: 3000 }); },
thud() { if (!on()) return; const t = now(); tone(70, t, 0.3, { type: 'sine', gain: 0.5, glideTo: 40 }); noise(t, 0.12, { gain: 0.25, freq: 200, type: 'lowpass' }); }, // trebuchet
clang() { if (!on()) return; const t = now(); tone(900, t, 0.18, { type: 'square', gain: 0.14, glideTo: 500 }); noise(t, 0.09, { gain: 0.2, freq: 3200 }); },
roar() { if (!on()) return; const t = now(); tone(120, t, 0.55, { type: 'sawtooth', gain: 0.3, glideTo: 60 }); noise(t, 0.5, { gain: 0.18, freq: 300, q: 0.5, type: 'lowpass' }); },
fanfare() { if (!on()) return; const t = now(); [523, 659, 784, 1047].forEach((f, i) => tone(f, t + i * 0.12, 0.4, { type: 'triangle', gain: 0.22 })); },
defeat() { if (!on()) return; const t = now(); [400, 340, 260].forEach((f, i) => tone(f, t + i * 0.14, 0.4, { type: 'sawtooth', gain: 0.16 })); },
draw() { if (!on()) return; const t = now(); tone(392, t, 0.5, { type: 'triangle', gain: 0.18 }); tone(523, t + 0.05, 0.5, { type: 'triangle', gain: 0.14 }); },
click() { if (!on()) return; const t = now(); tone(660, t, 0.05, { type: 'square', gain: 0.08 }); },
};
return { init, toggleMute, isMuted, ...E };
})();