Back to Black Site
SOURCE / PINNED RELEASE

Made of little things.

Black Site

Release
666bc7c74ebd…
Author-recorded commit
05e95af79676…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391

Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/audio.js
'use strict';
// BLACK SITE - synthesized sound effects (WebAudio, no asset files).

const Sfx = (() => {
  let ac = null, master = null, noise = null, muffle = null, duck = null, ringBus = null, ringing = [];

  function init() {
    if (ac) { if (ac.state === 'suspended') ac.resume(); return; }
    try {
      ac = new (window.AudioContext || window.webkitAudioContext)();
      // every normal sound -> master -> muffle (lowpass) -> duck (volume) -> compressor -> out.
      // Ear ringing bypasses all of that on its own bus, so it stays loud and clear.
      master = ac.createGain(); master.gain.value = 0.45;
      muffle = ac.createBiquadFilter(); muffle.type = 'lowpass'; muffle.frequency.value = 22000; muffle.Q.value = 0.4;
      duck = ac.createGain(); duck.gain.value = 1;
      const comp = ac.createDynamicsCompressor();
      master.connect(muffle); muffle.connect(duck); duck.connect(comp); comp.connect(ac.destination);
      ringBus = ac.createGain(); ringBus.gain.value = 1; ringBus.connect(ac.destination);
      noise = ac.createBuffer(1, ac.sampleRate * 2, ac.sampleRate);
      const d = noise.getChannelData(0);
      for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
      // build the voice variants in the background, one at a time, so a first scream never stutters
      const queue = ['scream', 'groan', 'scream', 'groan', 'scream', 'scream', 'groan', 'scream', 'groan', 'scream'];
      const next = () => { const k = queue.shift(); if (!k) return; if (VOICES[k].length < 6) VOICES[k].push(renderVoice(k)); setTimeout(next, 60); };
      setTimeout(next, 500);
    } catch (e) { ac = null; }
  }

  // filtered noise burst. o: { type, q, sweep (end freq), delay, attack }
  function burst(dur, freq, vol, o = {}) {
    if (!ac || vol < 0.005) return;
    const t = ac.currentTime + (o.delay || 0);
    const src = ac.createBufferSource(); src.buffer = noise;
    const f = ac.createBiquadFilter();
    f.type = o.type || 'lowpass'; f.Q.value = o.q || 0.7;
    f.frequency.setValueAtTime(freq, t);
    if (o.sweep) f.frequency.exponentialRampToValueAtTime(o.sweep, t + dur);
    const g = ac.createGain();
    if (o.attack) { g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + o.attack); }
    else g.gain.setValueAtTime(vol, t);
    g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
    src.connect(f); f.connect(g); g.connect(master);
    src.start(t, Math.random() * 1.5); src.stop(t + dur + 0.02);
  }

  function tone(freq, dur, vol, type = 'sine', to = null, delay = 0) {
    if (!ac || vol < 0.005) return;
    const t = ac.currentTime + delay;
    const o = ac.createOscillator(); o.type = type;
    o.frequency.setValueAtTime(freq, t);
    if (to) o.frequency.exponentialRampToValueAtTime(to, t + dur);
    const g = ac.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(vol, t + 0.005);
    g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
    o.connect(g); g.connect(master); o.start(t); o.stop(t + dur + 0.02);
  }

  // ---------------------------------------------------------------- guns
  // crack: supersonic snap, body: the blast, thump: low punch, tail: the report rolling away
  // [freq, duration, volume, (sweep)]
  const GUN = {
    m4a1:    { crack: [6200, 0.03, 0.5],  body: [2500, 0.13, 0.6, 520],  thump: [140, 0.11, 0.42], tail: [950, 0.5, 0.17] },
    ak47:    { crack: [4300, 0.035, 0.45], body: [1500, 0.2, 0.72, 280], thump: [92, 0.17, 0.62],  tail: [650, 0.75, 0.23] },
    mp5:     { crack: [4800, 0.02, 0.22], body: [1900, 0.08, 0.42, 650], thump: [175, 0.07, 0.28], tail: [1300, 0.22, 0.07] },
    spas12:  { crack: [2600, 0.045, 0.5], body: [1100, 0.36, 0.95, 140], thump: [66, 0.32, 0.85],  tail: [450, 1.1, 0.3], after: 'pump' },
    barrett: { crack: [5200, 0.05, 0.75], body: [900, 0.55, 1.0, 110],   thump: [46, 0.55, 0.95],  tail: [380, 1.9, 0.36], echo: 0.38, after: 'bolt' },
    m249:    { crack: [5600, 0.03, 0.45], body: [2100, 0.15, 0.64, 420], thump: [118, 0.13, 0.52], tail: [800, 0.55, 0.19] },
    m1911:   { crack: [3300, 0.025, 0.38], body: [1400, 0.13, 0.56, 380], thump: [105, 0.12, 0.52], tail: [850, 0.35, 0.1] },
    glock18: { crack: [6800, 0.018, 0.33], body: [2900, 0.065, 0.4, 950], thump: [185, 0.06, 0.24], tail: [1500, 0.18, 0.06] },
    deagle:  { crack: [4600, 0.04, 0.62], body: [1250, 0.3, 0.88, 190], thump: [72, 0.27, 0.78],  tail: [580, 0.95, 0.26], echo: 0.22 },
    magnum:  { crack: [5600, 0.04, 0.6],  body: [1800, 0.26, 0.82, 300], thump: [84, 0.22, 0.66],  tail: [820, 0.85, 0.22], ring: 2300 },
  };
  // reload character per gun: which sequence it uses and a pitch multiplier
  const RELOAD = {
    m4a1: ['rifle', 1], ak47: ['rifle', 0.78], mp5: ['rifle', 1.18], m249: ['lmg', 0.85], spas12: ['shotgun', 0.95], barrett: ['bolt', 0.7],
    m1911: ['pistol', 1], glock18: ['pistol', 1.22], deagle: ['pistol', 0.82], magnum: ['revolver', 1],
  };

  // mechanical noises used to build reloads
  function mech(kind, t, vol, p = 1) {
    switch (kind) {
      case 'click': burst(0.025, 3600 * p, 0.5 * vol, { type: 'bandpass', q: 4, delay: t }); tone(2100 * p, 0.02, 0.12 * vol, 'square', null, t); break;
      case 'clack': burst(0.05, 1700 * p, 0.6 * vol, { type: 'bandpass', q: 2, delay: t }); tone(620 * p, 0.05, 0.22 * vol, 'triangle', 300 * p, t); break;
      case 'slide': burst(0.13, 2600 * p, 0.35 * vol, { type: 'bandpass', q: 3, sweep: 900 * p, delay: t }); break;
      case 'magout': burst(0.09, 2200 * p, 0.35 * vol, { type: 'bandpass', q: 2.5, sweep: 1200 * p, delay: t }); mech('click', t + 0.02, vol * 0.7, p); break;
      case 'magin': mech('slide', t, vol * 0.6, p); mech('clack', t + 0.1, vol, p); break;
      case 'rack': mech('slide', t, vol, p); mech('clack', t + 0.14, vol * 1.1, p); break;
      case 'snap': burst(0.04, 2400 * p, 0.7 * vol, { type: 'bandpass', q: 2.5, delay: t }); tone(900 * p, 0.04, 0.2 * vol, 'square', 500 * p, t); break;
      case 'shell': tone(1900 * p, 0.04, 0.14 * vol, 'triangle', 1300 * p, t); burst(0.035, 3800 * p, 0.35 * vol, { type: 'bandpass', q: 3, delay: t + 0.01 }); break;
      case 'jingle': for (let k = 0; k < 6; k++) tone(3200 + Math.random() * 2400, 0.06, 0.05 * vol, 'sine', null, t + k * 0.035 + Math.random() * 0.02); break;
      case 'cover': burst(0.08, 900 * p, 0.7 * vol, { type: 'bandpass', q: 1.5, delay: t }); tone(220 * p, 0.08, 0.3 * vol, 'triangle', 140 * p, t); break;
      case 'belt': for (let k = 0; k < 8; k++) burst(0.02, 3000 + Math.random() * 1500, 0.18 * vol, { type: 'bandpass', q: 4, delay: t + k * 0.03 }); break;
      case 'pump': burst(0.1, 1500 * p, 0.5 * vol, { type: 'bandpass', q: 2, sweep: 700 * p, delay: t }); mech('clack', t + 0.12, vol * 1.2, p * 0.9); break;
      case 'bolt': mech('clack', t, vol * 0.8, p * 0.8); mech('slide', t + 0.08, vol * 0.8, p * 0.8); mech('slide', t + 0.25, vol * 0.8, p * 0.9); mech('clack', t + 0.38, vol, p * 0.8); break;
    }
  }

  function shot(id, vol = 1) {
    const s = GUN[id] || GUN.m4a1;
    burst(s.crack[1], s.crack[0], s.crack[2] * vol, { type: 'highpass' });
    burst(s.body[1], s.body[0], s.body[2] * vol, { sweep: s.body[3] });
    tone(s.thump[0], s.thump[1], s.thump[2] * vol, 'sine', s.thump[0] * 0.35);
    burst(s.tail[1], s.tail[0], s.tail[2] * vol, { sweep: s.tail[0] * 0.4, delay: 0.02 });
    if (s.echo) burst(s.tail[1] * 0.8, s.tail[0] * 0.8, s.tail[2] * 0.45 * vol, { sweep: s.tail[0] * 0.3, delay: s.echo });
    if (s.ring) tone(s.ring, 0.3, 0.035 * vol, 'sine', null, 0.01);
    if (s.after) mech(s.after, s.after === 'pump' ? 0.38 : 0.55, 0.6 * vol, s.after === 'pump' ? 0.95 : 0.7);
  }

  // A full reload sequence spread over the reload time d (seconds).
  function reload(id, d, vol = 1) {
    const [kind, p] = RELOAD[id] || RELOAD.m4a1;
    switch (kind) {
      case 'rifle': mech('magout', d * 0.12, vol, p); mech('magin', d * 0.55, vol, p); mech('rack', d * 0.8, vol, p); break;
      case 'lmg': mech('cover', d * 0.08, vol, p); mech('belt', d * 0.3, vol, p); mech('magout', d * 0.42, vol, p); mech('belt', d * 0.55, vol, p); mech('cover', d * 0.72, vol * 1.2, p); mech('rack', d * 0.86, vol, p); break;
      case 'shotgun': for (let k = 0; k < 5; k++) mech('shell', d * (0.08 + k * 0.15), vol, p); mech('pump', d * 0.86, vol, p); break;
      case 'bolt': mech('bolt', d * 0.06, vol, p); mech('magout', d * 0.32, vol, p); mech('magin', d * 0.6, vol, p); mech('bolt', d * 0.8, vol, p); break;
      case 'pistol': mech('magout', d * 0.12, vol, p); mech('magin', d * 0.5, vol, p); mech('snap', d * 0.82, vol, p); break;
      case 'revolver': mech('click', d * 0.06, vol, 0.8); mech('jingle', d * 0.2, vol, p); for (let k = 0; k < 3; k++) mech('shell', d * (0.42 + k * 0.1), vol, p); mech('snap', d * 0.86, vol, 0.7); break;
    }
  }

  // ---------------------------------------------------------------- footsteps
  function step(surface, vol = 1) {
    const p = 0.9 + Math.random() * 0.2;
    switch (surface) {
      case 'wood': burst(0.06, 850 * p, 0.55 * vol, { type: 'bandpass', q: 1.6 }); tone(150 * p, 0.09, 0.3 * vol, 'triangle', 105); break;
      case 'grass': burst(0.1, 1300 * p, 0.33 * vol, { type: 'bandpass', q: 0.8, attack: 0.015 }); burst(0.07, 3600, 0.1 * vol, { type: 'highpass', delay: 0.02 }); break;
      case 'sand': burst(0.12, 2700 * p, 0.3 * vol, { type: 'bandpass', q: 1, attack: 0.02 }); burst(0.08, 5200, 0.12 * vol, { type: 'highpass', delay: 0.035 }); break;
      case 'snow': burst(0.07, 1100 * p, 0.42 * vol, { type: 'bandpass', q: 2.2 }); burst(0.09, 2100 * p, 0.32 * vol, { type: 'bandpass', q: 3, delay: 0.05 }); break;
      case 'carpet': burst(0.06, 480 * p, 0.3 * vol, { attack: 0.01 }); break;
      default: burst(0.035, 2300 * p, 0.45 * vol, { type: 'bandpass', q: 1.2 }); tone(115 * p, 0.05, 0.22 * vol, 'sine', 70); break; // hard: concrete, asphalt, tile
    }
  }

  // ---------------------------------------------------------------- voices
  // ---- human voice synthesis (screams and groans)
  // Voices are rendered sample by sample into buffers, the way speech synthesizers do it:
  //  * a glottal pulse train (Rosenberg pulse shape), with every vocal cord cycle slightly
  //    different in length (jitter) and loudness (shimmer), alternate cycles weaker (the rough,
  //    torn quality of a strained voice), and a natural wandering pitch contour with a crack;
  //  * a cascade of five resonators (the throat and mouth) for the vowel, gliding as the
  //    mouth opens and closes;
  //  * soft clipping for strain.
  // Several variants of each are made once and picked at random, so no two cries match.
  let ringId = 0;
  const VOICES = { scream: [], groan: [] };

  function gauss() { return (Math.random() + Math.random() + Math.random() - 1.5) * 1.15; }

  function renderVoice(kind) {
    const sr = ac.sampleRate;
    const scream = kind === 'scream';
    const dur = scream ? 0.85 + Math.random() * 0.6 : 1.1 + Math.random() * 0.6;
    const gasp = 0;
    const n = Math.floor((dur + gasp + 0.1) * sr);
    const out = new Float32Array(n);
    // pitch contour (Hz) over the cry
    const f0Base = scream ? 300 + Math.random() * 90 : 115 + Math.random() * 30;
    const peak = scream ? 1.25 + Math.random() * 0.2 : 1.1;
    const crackAt = scream ? 0.45 + Math.random() * 0.3 : -1;   // moment the voice cracks
    const wander = [0, 1, 2].map(() => [0.6 + Math.random() * 2.2, Math.random() * 6.3, 0.02 + Math.random() * 0.03]);
    const vibF = 4.5 + Math.random() * 2.5;
    const f0At = u => { // u: 0..1 through the cry
      let k;
      if (u < 0.12) k = 0.62 + (peak - 0.62) * Math.sin((u / 0.12) * Math.PI / 2);   // tears out
      else if (u < 0.75) k = peak - (u - 0.12) * 0.25;                                  // holds, straining
      else k = (peak - 0.16) - (u - 0.75) / 0.25 * (scream ? 0.45 : 0.25);              // breaks down
      if (crackAt > 0 && u > crackAt && u < crackAt + 0.06) k *= 1.35;                   // crack
      let w = 0; for (const [f, ph, a] of wander) w += Math.sin(u * dur * f * 6.283 + ph) * a;
      return f0Base * k * (1 + w + Math.sin(u * dur * vibF * 6.283) * 0.018);
    };
    // vowel formants [start, middle, end] Hz and bandwidths: scream "AAH" opening to "AEH" then
    // closing toward "UH"; groan "UHH"/"OH"
    const mouth = 0.92 + Math.random() * 0.16;
    const F = scream
      ? [[700, 880, 620, 110], [1180, 1420, 1080, 130], [2500, 2650, 2400, 190], [3450, 3550, 3350, 260], [4300, 4400, 4200, 320]]
      : [[480, 560, 430, 90], [900, 980, 820, 110], [2400, 2450, 2350, 170], [3300, 3350, 3250, 250], [4200, 4200, 4200, 300]];
    const state = F.map(() => [0, 0]);
    // glottal source
    let phase = 0, period = 1 / f0At(0), amp = 1, cyc = 0, glPrev = 0;
    const oq = scream ? 0.5 : 0.62;            // open quotient: tighter = brighter, harsher
    const rough = scream ? 0.18 + Math.random() * 0.22 : 0.12;
    const voiced = Math.floor(dur * sr);
    for (let i = 0; i < voiced; i++) {
      const u = i / voiced;
      phase += 1 / (period * sr);
      if (phase >= 1) { // a new vocal cord cycle: new jitter, shimmer and roughness
        phase -= 1; cyc++;
        period = (1 / f0At(u)) * (1 + gauss() * (scream ? 0.025 : 0.018));
        amp = (1 + gauss() * (scream ? 0.12 : 0.08)) * (cyc % 2 ? 1 - rough : 1);
      }
      // Rosenberg glottal flow pulse, then its derivative (what the mouth actually radiates)
      let gl;
      const tp = oq * 0.62;
      if (phase < tp) gl = 0.5 * (1 - Math.cos(Math.PI * phase / tp));
      else if (phase < oq) gl = Math.cos((Math.PI / 2) * (phase - tp) / (oq - tp));
      else gl = 0;
      gl *= amp;
      let x = (gl - glPrev) * 18; glPrev = gl;
      // loudness envelope: hard attack, a dip and second push, release
      let env;
      if (u < 0.04) env = u / 0.04;
      else if (u < 0.6) env = 1 - (u - 0.04) * 0.35;
      else if (u < 0.7) env = 0.8 + (u - 0.6) * 1.5;
      else env = 0.95 * Math.pow(1 - (u - 0.7) / 0.3, 1.6);
      x *= env;
      // throat and mouth: cascade of resonators, formants gliding over the cry
      for (let k = 0; k < F.length; k++) {
        const [a, b, c, bw] = F[k];
        const f = (u < 0.3 ? a + (b - a) * (u / 0.3) : b + (c - b) * ((u - 0.3) / 0.7)) * mouth;
        const C = -Math.exp(-2 * Math.PI * bw / sr), B = 2 * Math.exp(-Math.PI * bw / sr) * Math.cos(2 * Math.PI * f / sr), A = 1 - B - C;
        const y = A * x + B * state[k][0] + C * state[k][1];
        state[k][1] = state[k][0]; state[k][0] = y; x = y;
      }
      out[i] = x;
    }
    // strain: soft clip, then normalise
    let peakV = 0;
    for (let i = 0; i < n; i++) peakV = Math.max(peakV, Math.abs(out[i]));
    const drive = scream ? 2.2 : 1.4;
    let peak2 = 0;
    for (let i = 0; i < n; i++) { out[i] = Math.tanh((out[i] / (peakV || 1)) * drive); peak2 = Math.max(peak2, Math.abs(out[i])); }
    const buf = ac.createBuffer(1, n, sr);
    const ch = buf.getChannelData(0);
    for (let i = 0; i < n; i++) ch[i] = out[i] / (peak2 || 1) * 0.9;
    return buf;
  }

  function playVoice(kind, vol, pitch) {
    if (!ac || vol < 0.01) return;
    const list = VOICES[kind];
    if (!list.length) list.push(renderVoice(kind)); // normally pre-built in the background
    const src = ac.createBufferSource();
    src.buffer = list[Math.floor(Math.random() * list.length)];
    src.playbackRate.value = Math.min(1.15, Math.max(0.85, pitch)) * (0.96 + Math.random() * 0.08);
    const g = ac.createGain(); g.gain.value = (kind === 'scream' ? 0.14 : 0.22) * vol;
    src.connect(g); g.connect(master); src.start();
  }

  // A man screaming in pain.
  function scream(vol = 1, pitch = 1) { playVoice('scream', vol, pitch); }
  function renderVoiceForTest(kind) { return renderVoice(kind); }

  // Ears ringing (death, nearby grenades): a loud high tone on its own bus while every other
  // sound goes muffled, quieter and far away. Holds for 'hold' seconds, then fades over 'fade'.
  function ring(hold = 5, fade = 2, vol = 0.12, attack = 0.03) {
    if (!ac) return;
    const t = ac.currentTime;
    for (const o of ringing) { try { o.stop(t + 0.05); } catch (e) { /* already stopped */ } }
    ringing = [];
    for (const [p, v, back] of [[muffle.frequency, 320, 22000], [duck.gain, 0.28, 1]]) {
      p.cancelScheduledValues(t); p.setValueAtTime(p.value, t);
      p.exponentialRampToValueAtTime(v, t + 0.06);
      p.setValueAtTime(v, t + hold);
      p.exponentialRampToValueAtTime(back, t + hold + fade);
    }
    for (const [f, a] of [[3520, 1], [3534, 0.55], [7040, 0.1]]) { // two close tones beat against each other
      const o = ac.createOscillator(); o.type = 'sine'; o.frequency.value = f * (0.98 + Math.random() * 0.04);
      const g = ac.createGain();
      g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol * a, t + attack);
      g.gain.setValueAtTime(vol * a, t + hold); g.gain.exponentialRampToValueAtTime(0.0001, t + hold + fade);
      o.connect(g); g.connect(ringBus); o.start(t); o.stop(t + hold + fade + 0.05);
      ringing.push(o);
    }
    // backstop: once the ring is over, make sure the mix is fully restored even if the
    // scheduled automation was delayed (unless a newer ring has started since)
    const id = ++ringId;
    setTimeout(() => {
      if (id !== ringId || !ac) return;
      const now = ac.currentTime;
      for (const [prm, back] of [[muffle.frequency, 22000], [duck.gain, 1]]) { prm.cancelScheduledValues(now); prm.setTargetAtTime(back, now, 0.08); }
    }, (hold + fade) * 1000 + 150);
  }
  // for tests: how muffled the mix is right now (Hz of the lowpass)
  function muffleHz() { return muffle ? muffle.frequency.value : 22000; }
  function audioTime() { return ac ? ac.currentTime : 0; }
  // Low moaning groan of a wounded soldier (same voice model, low and closed).
  function groan(vol = 1, pitch = 1) { playVoice('groan', vol, pitch); }

  // ---------------------------------------------------------------- medical
  function useMed(id) {
    if (id === 'bandage') {
      // tear the wrapper, rip the bandage strip, then wrap it round (cloth rustles)
      burst(0.18, 1800, 0.35, { type: 'bandpass', q: 3, sweep: 4200 });
      burst(0.12, 2600, 0.3, { type: 'bandpass', q: 4, sweep: 5200, delay: 0.22 });
      for (let k = 0; k < 4; k++) burst(0.16, 1500 + k * 200, 0.12, { type: 'bandpass', q: 1.2, attack: 0.04, delay: 0.45 + k * 0.17 });
    } else {
      // medkit: zipper, then the same dressing sounds
      for (let k = 0; k < 16; k++) burst(0.015, 4200, 0.18, { type: 'bandpass', q: 5, delay: k * 0.025 });
      burst(0.2, 1800, 0.3, { type: 'bandpass', q: 3, sweep: 4200, delay: 0.55 });
      for (let k = 0; k < 6; k++) burst(0.18, 1400 + k * 150, 0.12, { type: 'bandpass', q: 1.2, attack: 0.04, delay: 0.85 + k * 0.25 });
    }
  }

  return {
    init, shot, reload, step, scream, groan, useMed, ring, muffleHz, audioTime, renderVoiceForTest,
    explosion(vol = 1) { burst(1.3, 900, vol, { sweep: 50 }); tone(70, 0.9, 0.6 * vol, 'sine', 25); },
    flashbang(vol = 1) { burst(0.5, 3500, 0.8 * vol, { type: 'highpass' }); },
    hit() { tone(1900, 0.05, 0.16, 'square'); },
    kill() { tone(1400, 0.08, 0.18, 'square'); tone(900, 0.12, 0.18, 'square', null, 0.05); },
    hurt() { burst(0.15, 600, 0.4); tone(160, 0.15, 0.22, 'sawtooth', 80); },
    armourHit() { burst(0.05, 3800, 0.3, { type: 'bandpass', q: 2 }); tone(700, 0.06, 0.1, 'square', 400); },
    click() { tone(1200, 0.03, 0.12, 'square'); },
    pickup() { tone(660, 0.06, 0.13, 'triangle'); tone(990, 0.08, 0.13, 'triangle', null, 0.05); },
    deny() { tone(180, 0.12, 0.18, 'square'); },
    throwIt() { burst(0.15, 1200, 0.25, { type: 'bandpass' }); },
    bounce() { tone(900, 0.04, 0.08, 'triangle', 500); },
    heal() { tone(520, 0.12, 0.12, 'sine', 780); tone(780, 0.15, 0.1, 'sine', 1040, 0.1); },
    beep() { tone(880, 0.07, 0.1, 'sine'); },
    extract() { tone(440, 0.2, 0.18, 'triangle'); tone(660, 0.2, 0.18, 'triangle', null, 0.15); tone(880, 0.45, 0.18, 'triangle', null, 0.3); },
    ignite(vol = 1) { burst(0.12, 2500, 0.5 * vol, { type: 'highpass' }); burst(1.2, 700, 0.6 * vol, { sweep: 200, delay: 0.05 }); },
    smoke(vol = 1) { burst(1.8, 5000, 0.35 * vol, { type: 'highpass', sweep: 1500 }); },
    armourBreak() { burst(0.25, 3000, 0.45, { type: 'bandpass', q: 1.5 }); tone(300, 0.25, 0.15, 'square', 120); },
    rotor(vol = 1) { burst(0.09, 180, 0.5 * vol, { sweep: 60 }); },
    radio() { tone(1200, 0.05, 0.08, 'square'); tone(900, 0.05, 0.08, 'square', null, 0.07); burst(0.25, 2500, 0.08, { type: 'bandpass', q: 1, delay: 0.14 }); },
    death() { ring(6, 3, 0.08, 1); }, // dying: quieter ringing ears with a gradual onset
    knifeThrow() { burst(0.16, 2600, 0.3, { type: 'bandpass', q: 1.5, sweep: 900 }); },
    slash() { burst(0.14, 3200, 0.35, { type: 'bandpass', q: 1.2, sweep: 1200 }); burst(0.12, 700, 0.45, { delay: 0.08 }); tone(140, 0.12, 0.2, 'sine', 80, 0.08); },
    knifeClink() { tone(2600, 0.08, 0.1, 'triangle', 1800); burst(0.04, 4200, 0.2, { type: 'bandpass', q: 3 }); },
  };
})();