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