Back to City Breaker
SOURCE / PINNED RELEASE

Made of little things.

City Breaker

Release
94d2b8664b10…
Author-recorded commit
df23103cbcc1…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-cf8b858653e

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

src/game/audio.ts
// Synthesized, positional audio. Every sound is generated from period numbers:
// NK-12 contra-props (8 blades a side, ~735 rpm, supersonic tips), turbojet
// compressor whine, Bofors at ~2 rounds/s, Vulcan at ~100 rounds/s, a Fan Song
// tracking beam heard as a 16 Hz rattle that turns into a steady tone when the
// missile is guided, flak fragments "like hail on a tin roof". World sounds are
// panned by bearing, muffled and delayed by distance, and sent to an echo.
import { CONFIG } from './config';
import { prefs } from './prefs';

type EngineModel = 'jet' | 'turboprop' | 'fighter';

interface EngineBank {
  model: EngineModel;
  out: GainNode;
  lp: BiquadFilterNode;
  roarGain: GainNode;
  whine: OscillatorNode[];
  whineGain: GainNode;
  props: OscillatorNode[];
  propGain: GainNode;
  burnerGain: GainNode;
  sources: AudioScheduledSourceNode[];
}

interface Beds {
  src: AudioScheduledSourceNode[];
  wind: GainNode;
  windLp: BiquadFilterNode;
  hiss: GainNode;
  rattle: GainNode;
  siren: GainNode;
  growl: GainNode;
  fan: GainNode;
  guide: GainNode;
  guideOsc: OscillatorNode;
  bell: GainNode;
  crackle: GainNode;
  alt: GainNode;
}

export interface AudioFrame {
  engine: number;
  speed: number;
  lowAlt: boolean;
  searchCount: number;
  locked: boolean;
  launchClose: number;
  aaaGrowl: number;
  siren: number;
  critical: number;
  engineFire: boolean;
  intensity?: number;
  damage?: number;
  doors?: number;
  turn?: number;
  throttle?: number;
  flamedOut?: boolean;
  pullUp?: boolean;
}

export class Audio {
  ctx: AudioContext | null = null;
  private master!: GainNode;
  private radioIn!: GainNode;
  private sfx!: GainNode;
  private cockpit!: GainNode;
  private rwrBus!: GainNode;
  private verbSend!: GainNode;
  private noise!: AudioBuffer;
  private engine: EngineBank | null = null;
  private beds: Beds | null = null;
  private timers = { search: 0, launch: 0, heart: 0, breath: 0, beat: 0, bar: 0, creak: 0 };
  private listener = { x: 0, y: 0, hdg: 0 };
  muted = false;

  /** Must be called from a user gesture. */
  start(): void {
    if (this.ctx) {
      void this.ctx.resume();
      return;
    }
    const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
    if (!AC) return;
    const ctx = new AC();
    this.ctx = ctx;
    const comp = ctx.createDynamicsCompressor();
    comp.threshold.value = -14;
    comp.ratio.value = 5;
    comp.attack.value = 0.003;
    comp.release.value = 0.25;
    comp.connect(ctx.destination);
    this.master = ctx.createGain();
    this.master.gain.value = CONFIG.audio.master * prefs.volume;
    this.master.connect(comp);
    this.sfx = ctx.createGain();
    this.sfx.connect(this.master);
    // Cockpit sounds are heard through a helmet: slightly muffled.
    this.cockpit = ctx.createGain();
    const cockLp = ctx.createBiquadFilter();
    cockLp.type = 'lowpass';
    cockLp.frequency.value = 5200;
    this.cockpit.connect(cockLp).connect(this.master);
    this.rwrBus = ctx.createGain();
    this.rwrBus.gain.value = CONFIG.audio.rwr;
    this.rwrBus.connect(this.master);
    // Outdoor echo: a decaying noise impulse (ground reflections, rolling thunder).
    const verb = ctx.createConvolver();
    const len = Math.floor(ctx.sampleRate * 2.6);
    const ir = ctx.createBuffer(2, len, ctx.sampleRate);
    for (let ch = 0; ch < 2; ch++) {
      const d = ir.getChannelData(ch);
      for (let i = 0; i < len; i++) {
        const t = i / ctx.sampleRate;
        const early = t > 0.05 && t < 0.09 ? 0.6 : 0;
        d[i] = (Math.random() * 2 - 1) * (Math.exp(-t * 2.2) * 0.7 + early);
      }
    }
    verb.buffer = ir;
    this.verbSend = ctx.createGain();
    this.verbSend.gain.value = 0.55;
    const verbLp = ctx.createBiquadFilter();
    verbLp.type = 'lowpass';
    verbLp.frequency.value = 1800;
    this.verbSend.connect(verb).connect(verbLp).connect(this.master);
    // Radio: band-limited, overdriven, hissy.
    this.radioIn = ctx.createGain();
    const hp = ctx.createBiquadFilter();
    hp.type = 'highpass';
    hp.frequency.value = 450;
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 2600;
    const shaper = ctx.createWaveShaper();
    shaper.curve = this.curve(3.5);
    const rg = ctx.createGain();
    rg.gain.value = CONFIG.audio.radio;
    this.radioIn.connect(hp).connect(shaper).connect(lp).connect(rg).connect(this.master);
    const nlen = ctx.sampleRate * 2;
    this.noise = ctx.createBuffer(1, nlen, ctx.sampleRate);
    const nd = this.noise.getChannelData(0);
    for (let i = 0; i < nlen; i++) nd[i] = Math.random() * 2 - 1;
  }

  setMuted(m: boolean): void {
    this.muted = m;
    this.applyVolume();
  }

  applyVolume(): void {
    if (this.ctx) this.master.gain.setTargetAtTime(this.muted ? 0 : CONFIG.audio.master * prefs.volume, this.ctx.currentTime, 0.05);
  }

  private curve(k: number): Float32Array<ArrayBuffer> {
    const c = new Float32Array(new ArrayBuffer(1024 * 4));
    for (let i = 0; i < c.length; i++) c[i] = Math.tanh(((i / c.length) * 2 - 1) * k);
    return c;
  }

  private noiseSrc(): AudioBufferSourceNode {
    const s = this.ctx!.createBufferSource();
    s.buffer = this.noise;
    s.loop = true;
    s.loopStart = Math.random();
    return s;
  }

  private env(g: GainNode, t: number, peak: number, attack: number, decay: number): void {
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);
    g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
  }

  // ── Space ──
  setListener(x: number, y: number, hdg: number): void {
    this.listener = { x, y, hdg };
  }

  /** A chain placed in the world: distance muffling, bearing pan, speed-of-sound delay, echo send. */
  private place(x: number, y: number, vol: number, verbAmt = 0.35): { input: AudioNode; t: number } | null {
    if (!this.ctx) return null;
    const ctx = this.ctx;
    const L = this.listener;
    const d = Math.hypot(x - L.x, y - L.y);
    const rel = Math.atan2(y - L.y, x - L.x) - L.hdg;
    const pan = Math.max(-1, Math.min(1, Math.sin(rel) * Math.min(1, d / 0.6)));
    const g = vol / (1 + d * d * 0.06);
    if (g < 0.004) return null;
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 600 + 9000 / (1 + d * 0.9);
    const panner = ctx.createStereoPanner();
    panner.pan.value = pan;
    const out = ctx.createGain();
    out.gain.value = g;
    lp.connect(panner).connect(out).connect(this.sfx);
    const send = ctx.createGain();
    send.gain.value = verbAmt * Math.min(1, 0.3 + d * 0.15) * g;
    panner.connect(send).connect(this.verbSend);
    // Sound travels: compressed so a distant burst still arrives within a second.
    return { input: lp, t: ctx.currentTime + Math.min(1.1, d * 0.07) };
  }

  // ── Mission beds ──
  startMission(kind = 'Tu-16'): void {
    if (!this.ctx) return;
    this.stopMission();
    const ctx = this.ctx;
    const model: EngineModel = kind === 'Tu-95' ? 'turboprop' : ['MiG-21', 'Su-7', 'MiG-25R'].includes(kind) ? 'fighter' : 'jet';
    const out = ctx.createGain();
    out.gain.value = 0;
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 700;
    out.connect(this.cockpit);
    lp.connect(out);
    const sources: AudioScheduledSourceNode[] = [];
    // Roar: broadband jet / propwash noise.
    const roar = this.noiseSrc();
    const roarLp = ctx.createBiquadFilter();
    roarLp.type = 'lowpass';
    roarLp.frequency.value = model === 'turboprop' ? 500 : 900;
    const roarGain = ctx.createGain();
    roarGain.gain.value = 0.5;
    roar.connect(roarLp).connect(roarGain).connect(lp);
    roar.start();
    sources.push(roar);
    // Structure rumble.
    const rumble = ctx.createOscillator();
    rumble.frequency.value = model === 'turboprop' ? 24.5 : 38;
    const rgn = ctx.createGain();
    rgn.gain.value = 0.25;
    rumble.connect(rgn).connect(out);
    rumble.start();
    sources.push(rumble);
    // Compressor whine (two spools).
    const whineGain = ctx.createGain();
    whineGain.gain.value = 0.02;
    const whineBp = ctx.createBiquadFilter();
    whineBp.type = 'bandpass';
    whineBp.frequency.value = 2400;
    whineBp.Q.value = 3;
    const whine: OscillatorNode[] = [];
    for (const f of model === 'turboprop' ? [2600] : [2150, 3210]) {
      const o = ctx.createOscillator();
      o.type = 'triangle';
      o.frequency.value = f;
      const vib = ctx.createOscillator();
      vib.frequency.value = 5 + Math.random() * 2;
      const vg = ctx.createGain();
      vg.gain.value = f * 0.004;
      vib.connect(vg).connect(o.frequency);
      o.connect(whineBp);
      o.start();
      vib.start();
      whine.push(o);
      sources.push(o, vib);
    }
    whineBp.connect(whineGain).connect(out);
    // NK-12 contra-rotating props: two rows at slightly different blade-pass
    // rates beat against each other → the Bear's throbbing howl, driven hard.
    const propGain = ctx.createGain();
    propGain.gain.value = 0;
    const props: OscillatorNode[] = [];
    if (model === 'turboprop') {
      const shaper = ctx.createWaveShaper();
      shaper.curve = this.curve(5);
      const plp = ctx.createBiquadFilter();
      plp.type = 'lowpass';
      plp.frequency.value = 1600;
      for (const f of [49, 51.5, 98.5]) {
        const o = ctx.createOscillator();
        o.type = 'sawtooth';
        o.frequency.value = f;
        o.connect(shaper);
        o.start();
        props.push(o);
        sources.push(o);
      }
      shaper.connect(plp).connect(propGain).connect(out);
    }
    // Afterburner crackle for fighters.
    const burnerGain = ctx.createGain();
    burnerGain.gain.value = 0;
    if (model === 'fighter') {
      const b = this.noiseSrc();
      const bp = ctx.createBiquadFilter();
      bp.type = 'bandpass';
      bp.frequency.value = 300;
      const am = ctx.createOscillator();
      am.type = 'square';
      am.frequency.value = 23;
      const amg = ctx.createGain();
      amg.gain.value = 0.5;
      am.connect(amg).connect(burnerGain.gain);
      b.connect(bp).connect(burnerGain).connect(out);
      b.start();
      am.start();
      sources.push(b, am);
    }
    this.engine = { model, out, lp, roarGain, whine, whineGain, props, propGain, burnerGain, sources };

    // Environment beds.
    const bedSrc: AudioScheduledSourceNode[] = [];
    const noiseBed = (type: BiquadFilterType, f: number, q: number, dest: AudioNode) => {
      const s = this.noiseSrc();
      const flt = ctx.createBiquadFilter();
      flt.type = type;
      flt.frequency.value = f;
      flt.Q.value = q;
      const g = ctx.createGain();
      g.gain.value = 0;
      s.connect(flt).connect(g).connect(dest);
      s.start();
      bedSrc.push(s);
      return { g, flt };
    };
    const wind = noiseBed('bandpass', 700, 0.5, this.cockpit);
    const hiss = noiseBed('bandpass', 1700, 2.5, this.cockpit); // air shrieking through holes
    const rattle = noiseBed('bandpass', 180, 1.2, this.cockpit);
    const crackle = noiseBed('highpass', 2500, 0.7, this.cockpit); // engine fire
    const tone = (type: OscillatorType, f: number) => {
      const o = ctx.createOscillator();
      o.type = type;
      o.frequency.value = f;
      o.start();
      bedSrc.push(o);
      return o;
    };
    const gateBy = (src: AudioNode, rate: number, dest: AudioNode) => {
      const gate = ctx.createGain();
      gate.gain.value = 0;
      const l = tone('square', rate);
      const lg = ctx.createGain();
      lg.gain.value = 0.5;
      l.connect(lg).connect(gate.gain);
      src.connect(gate).connect(dest);
    };
    // Mechanical air-raid siren: a rising and falling wail with a hard harmonic.
    const siren = ctx.createGain();
    siren.gain.value = 0;
    const sirenLp = ctx.createBiquadFilter();
    sirenLp.type = 'lowpass';
    sirenLp.frequency.value = 1400;
    siren.connect(sirenLp).connect(this.sfx);
    const sendS = ctx.createGain();
    sendS.gain.value = 0.6;
    sirenLp.connect(sendS).connect(this.verbSend);
    for (const k of [1, 2.01]) {
      const o = tone('sawtooth', 330 * k);
      const lfo = tone('triangle', 0.09);
      const lg = ctx.createGain();
      lg.gain.value = 150 * k;
      lfo.connect(lg).connect(o.frequency);
      const og = ctx.createGain();
      og.gain.value = k === 1 ? 1 : 0.35;
      o.connect(og).connect(siren);
    }
    // Flak fire-control radar growl.
    const growl = ctx.createGain();
    growl.gain.value = 0;
    growl.connect(this.rwrBus);
    gateBy(tone('sawtooth', 95), 11, growl);
    // Fan Song tracking beam: a 16 Hz rattle while a SAM radar holds you…
    const fan = ctx.createGain();
    fan.gain.value = 0;
    fan.connect(this.rwrBus);
    gateBy(tone('square', 1100), 16, fan);
    // …and a steady, rising tone once a missile is guided at you.
    const guide = ctx.createGain();
    guide.gain.value = 0;
    const guideOsc = tone('square', 1600);
    const glp = ctx.createBiquadFilter();
    glp.type = 'lowpass';
    glp.frequency.value = 3500;
    guideOsc.connect(glp).connect(guide).connect(this.rwrBus);
    // Fire bell: two clangorous partials hammered at 18 Hz.
    const bell = ctx.createGain();
    bell.gain.value = 0;
    bell.connect(this.cockpit);
    const bellMix = ctx.createGain();
    tone('sine', 2210).connect(bellMix);
    tone('sine', 3170).connect(bellMix);
    gateBy(bellMix, 18, bell);
    // Radar altimeter "dangerous altitude" tone.
    const alt = ctx.createGain();
    alt.gain.value = 0;
    alt.connect(this.cockpit);
    gateBy(tone('square', 820), 5, alt);
    this.beds = { src: bedSrc, wind: wind.g, windLp: wind.flt, hiss: hiss.g, rattle: rattle.g, siren, growl, fan, guide, guideOsc, bell, crackle: crackle.g, alt };
  }

  stopMission(): void {
    const stop = (n: AudioScheduledSourceNode) => {
      try {
        n.stop();
      } catch {
        /* already stopped */
      }
    };
    this.engine?.sources.forEach(stop);
    this.beds?.src.forEach(stop);
    this.engine = null;
    this.beds = null;
  }

  /** Per-frame state of the cockpit. */
  update(dt: number, s: AudioFrame): void {
    if (!this.ctx || !this.engine || !this.beds) return;
    const t = this.ctx.currentTime;
    const e = this.engine;
    const B = this.beds;
    const run = s.flamedOut ? 0 : s.engine;
    const thr = Math.max(0, s.throttle ?? 0);
    e.out.gain.setTargetAtTime(0.12 + run * 0.16, t, 0.3);
    e.lp.frequency.setTargetAtTime(500 + s.speed * 500 + thr * 400, t, 0.3);
    e.roarGain.gain.setTargetAtTime(run * (e.model === 'turboprop' ? 0.35 : 0.55) + 0.05, t, 0.3);
    e.whineGain.gain.setTargetAtTime(run * (e.model === 'turboprop' ? 0.012 : 0.022) * (1 + thr * 0.6), t, 0.4);
    e.whine.forEach((o, i) => o.frequency.setTargetAtTime((i ? 3210 : 2150) * (0.82 + run * 0.18 + thr * 0.06) * (e.model === 'turboprop' ? 1.2 : 1), t, s.flamedOut ? 2.5 : 0.6));
    e.props.forEach((o, i) => o.frequency.setTargetAtTime([49, 51.5, 98.5][i] * (0.85 + run * 0.15), t, 0.5));
    e.propGain.gain.setTargetAtTime(e.model === 'turboprop' ? 0.22 * run : 0, t, 0.3);
    e.burnerGain.gain.setTargetAtTime(e.model === 'fighter' && thr > 0.3 ? 0.25 : 0, t, 0.15);
    const doors = s.doors ?? 0;
    B.wind.gain.setTargetAtTime(0.025 + s.speed * 0.035 + (s.lowAlt ? 0.03 : 0) + doors * 0.07, t, 0.3);
    B.windLp.frequency.setTargetAtTime(500 + s.speed * 400 + doors * 600, t, 0.3);
    const dmg = s.damage ?? 0;
    B.hiss.gain.setTargetAtTime(dmg > 0.15 ? dmg * 0.09 * s.speed : 0, t, 0.4);
    B.rattle.gain.setTargetAtTime(0.012 + dmg * 0.05 + (s.lowAlt ? 0.02 : 0), t, 0.3);
    B.crackle.gain.setTargetAtTime(s.engineFire ? 0.05 + Math.random() * 0.06 : 0, t, 0.05);
    B.bell.gain.setTargetAtTime(s.engineFire ? 0.05 : 0, t, 0.05);
    B.alt.gain.setTargetAtTime(s.pullUp ? 0.12 : 0, t, 0.05);
    B.siren.gain.setTargetAtTime(s.siren * 0.05, t, 0.8);
    B.growl.gain.setTargetAtTime(s.aaaGrowl * 0.16, t, 0.1);
    B.fan.gain.setTargetAtTime(s.locked && s.launchClose <= 0 ? 0.22 : 0, t, 0.03);
    B.guide.gain.setTargetAtTime(s.launchClose > 0 ? 0.12 + s.launchClose * 0.25 : 0, t, 0.03);
    B.guideOsc.frequency.setTargetAtTime(1300 + s.launchClose * 1700, t, 0.05);
    const T = this.timers;
    T.search -= dt;
    if (s.searchCount > 0 && T.search <= 0) {
      // A search radar's beam sweeps past: one soft ping per antenna rotation.
      T.search = Math.max(0.4, 2.6 / s.searchCount);
      this.beep(1250, 0.04, 0.2, 'sine');
    }
    T.launch -= dt;
    if (s.launchClose > 0 && T.launch <= 0) {
      T.launch = 0.18 - s.launchClose * 0.12;
      this.beep(2900 + s.launchClose * 900, 0.03, 0.12 + s.launchClose * 0.3, 'square');
    }
    T.creak -= dt;
    if (Math.abs(s.turn ?? 0) > 0.8 && s.speed > 0.7 && T.creak <= 0) {
      T.creak = 1.2 + Math.random();
      this.creak(0.12 + dmg * 0.15);
    }
    const I = s.intensity ?? 0;
    T.beat -= dt;
    if (I > 0.08 && T.beat <= 0) {
      const bpm = 84 + I * 84;
      T.beat = 60 / bpm;
      T.bar = (T.bar + 1) % 4;
      this.drum(T.bar === 0 ? 0.3 : 0.18, I);
      if (I > 0.45 && T.bar % 2 === 1) setTimeout(() => this.snare(0.09 * I), (T.beat * 500) | 0);
    }
    // Oxygen-mask breathing follows the stress; a heartbeat when dying.
    T.breath -= dt;
    if (T.breath <= 0) {
      T.breath = 3.2 - I * 1.6 - s.critical;
      this.breath(0.025 + I * 0.04 + s.critical * 0.06);
    }
    T.heart -= dt;
    if (s.critical > 0 && T.heart <= 0) {
      T.heart = 0.9 - s.critical * 0.45;
      this.thud(55, 0.35 * s.critical, 0.1);
      setTimeout(() => this.thud(48, 0.25 * s.critical, 0.08), 160);
    }
  }

  // ── One-shots: cockpit ──
  beep(freq: number, dur: number, vol: number, type: OscillatorType): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const o = ctx.createOscillator();
    o.type = type;
    o.frequency.value = freq;
    const g = ctx.createGain();
    this.env(g, ctx.currentTime, vol, 0.004, dur);
    o.connect(g).connect(this.rwrBus);
    o.start();
    o.stop(ctx.currentTime + dur + 0.05);
  }

  thud(freq: number, vol: number, dur: number): void {
    if (!this.ctx) return;
    this.thudAt(this.ctx.currentTime, freq, vol, dur);
  }

  private thudAt(t: number, freq: number, vol: number, dur = 0.06): void {
    const ctx = this.ctx!;
    const o = ctx.createOscillator();
    o.frequency.setValueAtTime(freq * 1.6, t);
    o.frequency.exponentialRampToValueAtTime(freq, t + dur);
    const g = ctx.createGain();
    this.env(g, t, vol, 0.004, dur * 2);
    o.connect(g).connect(this.cockpit);
    o.start(t);
    o.stop(t + dur * 3 + 0.05);
  }

  private breath(vol: number): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const t = ctx.currentTime;
    this.click(t, 0.04);
    const src = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = 1100;
    f.Q.value = 1.6;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.linearRampToValueAtTime(vol, t + 0.45);
    g.gain.linearRampToValueAtTime(0.0001, t + 1.1);
    src.connect(f).connect(g).connect(this.cockpit);
    src.start(t);
    src.stop(t + 1.2);
  }

  private click(t: number, vol: number, dest?: AudioNode): void {
    const ctx = this.ctx!;
    const s = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = 2500;
    const g = ctx.createGain();
    this.env(g, t, vol, 0.001, 0.02);
    s.connect(f).connect(g).connect(dest ?? this.cockpit);
    s.start(t);
    s.stop(t + 0.05);
  }

  private creak(vol: number): void {
    const ctx = this.ctx!;
    const t = ctx.currentTime;
    const o = ctx.createOscillator();
    o.type = 'sawtooth';
    o.frequency.setValueAtTime(70, t);
    o.frequency.linearRampToValueAtTime(52 + Math.random() * 20, t + 0.7);
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = 240;
    f.Q.value = 6;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.linearRampToValueAtTime(vol, t + 0.25);
    g.gain.linearRampToValueAtTime(0.0001, t + 0.8);
    o.connect(f).connect(g).connect(this.cockpit);
    o.start(t);
    o.stop(t + 0.85);
  }

  private drum(vol: number, intensity: number): void {
    const ctx = this.ctx!;
    const t = ctx.currentTime;
    const o = ctx.createOscillator();
    o.frequency.setValueAtTime(110 + intensity * 30, t);
    o.frequency.exponentialRampToValueAtTime(38, t + 0.22);
    const g = ctx.createGain();
    this.env(g, t, vol, 0.004, 0.32);
    o.connect(g).connect(this.sfx);
    o.start(t);
    o.stop(t + 0.45);
  }

  private snare(vol: number): void {
    const ctx = this.ctx!;
    const src = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = 1800;
    const g = ctx.createGain();
    this.env(g, ctx.currentTime, vol, 0.002, 0.12);
    src.connect(f).connect(g).connect(this.sfx);
    src.start();
    src.stop(ctx.currentTime + 0.2);
  }

  /** Flak fragments rattling through the skin: "like hail on a tin roof". */
  shrapnel(amount: number): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const n = Math.round(6 + amount * 34);
    const t0 = ctx.currentTime;
    for (let i = 0; i < n; i++) {
      const t = t0 + Math.random() * (0.25 + amount * 0.4);
      const s = this.noiseSrc();
      const f = ctx.createBiquadFilter();
      f.type = 'bandpass';
      f.frequency.value = 2500 + Math.random() * 4500;
      f.Q.value = 9;
      const p = ctx.createStereoPanner();
      p.pan.value = Math.random() * 2 - 1;
      const g = ctx.createGain();
      this.env(g, t, 0.25 + Math.random() * 0.35, 0.001, 0.025 + Math.random() * 0.03);
      s.connect(f).connect(g).connect(p).connect(this.cockpit);
      s.start(t);
      s.stop(t + 0.08);
    }
  }

  /** Metal struck: inharmonic partials ringing down, plus fragments. */
  clang(vol: number): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const t = ctx.currentTime;
    const base = 170 + Math.random() * 120;
    for (const [r, a] of [[1, 1], [2.76, 0.6], [5.4, 0.35], [8.9, 0.2]] as [number, number][]) {
      const o = ctx.createOscillator();
      o.frequency.value = base * r;
      const g = ctx.createGain();
      this.env(g, t, vol * a, 0.002, 0.35 / Math.sqrt(r));
      o.connect(g).connect(this.cockpit);
      o.start(t);
      o.stop(t + 0.6);
    }
    this.shrapnel(vol * 0.6);
  }

  /** Bomb shackle releasing: a mechanical thunk and a click. */
  clunk(): void {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    this.thudAt(t, 85, 0.3, 0.05);
    this.click(t + 0.01, 0.2);
  }

  /** Hydraulic actuators driving the bomb doors. */
  doors(open: boolean): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const t = ctx.currentTime;
    const o = ctx.createOscillator();
    o.type = 'sawtooth';
    o.frequency.setValueAtTime(open ? 330 : 620, t);
    o.frequency.linearRampToValueAtTime(open ? 640 : 340, t + 1.1);
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = 900;
    f.Q.value = 2;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.linearRampToValueAtTime(0.06, t + 0.15);
    g.gain.setValueAtTime(0.06, t + 1.0);
    g.gain.linearRampToValueAtTime(0.0001, t + 1.25);
    o.connect(f).connect(g).connect(this.cockpit);
    o.start(t);
    o.stop(t + 1.3);
    this.thudAt(t, 60, 0.25);
    this.thudAt(t + 1.15, 70, 0.3);
  }

  extinguisher(): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const s = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = 'highpass';
    f.frequency.value = 1800;
    const g = ctx.createGain();
    this.env(g, ctx.currentTime, 0.25, 0.01, 1.6);
    s.connect(f).connect(g).connect(this.cockpit);
    s.start();
    s.stop(ctx.currentTime + 1.8);
    this.thud(70, 0.3, 0.05);
  }

  /** Engine flame-out: the spool winding down. */
  spoolDown(): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const t = ctx.currentTime;
    const o = ctx.createOscillator();
    o.type = 'triangle';
    o.frequency.setValueAtTime(2200, t);
    o.frequency.exponentialRampToValueAtTime(140, t + 4);
    const g = ctx.createGain();
    this.env(g, t, 0.08, 0.05, 4);
    o.connect(g).connect(this.cockpit);
    o.start(t);
    o.stop(t + 4.2);
  }

  // ── One-shots: world ──
  /** An explosion at a world point: crack, body, rolling tail and echo. */
  boomAt(x: number, y: number, size: number): void {
    const P = this.place(x, y, Math.min(1.2, 0.3 + size), 0.6);
    if (!P) return;
    const ctx = this.ctx!;
    const t = P.t;
    const crack = this.noiseSrc();
    const cf = ctx.createBiquadFilter();
    cf.type = 'highpass';
    cf.frequency.value = 1500;
    const cg = ctx.createGain();
    this.env(cg, t, 0.5, 0.001, 0.05);
    crack.connect(cf).connect(cg).connect(P.input);
    crack.start(t);
    crack.stop(t + 0.1);
    const body = this.noiseSrc();
    const bf = ctx.createBiquadFilter();
    bf.type = 'lowpass';
    bf.frequency.value = 260 + size * 500;
    const bg = ctx.createGain();
    this.env(bg, t, 1, 0.006, 0.4 + size * 1.4);
    body.connect(bf).connect(bg).connect(P.input);
    body.start(t);
    body.stop(t + 2.5 + size);
    const o = ctx.createOscillator();
    o.frequency.setValueAtTime(75, t);
    o.frequency.exponentialRampToValueAtTime(26, t + 0.9 + size * 0.6);
    const og = ctx.createGain();
    this.env(og, t, 0.9, 0.01, 0.9 + size * 0.6);
    o.connect(og).connect(P.input);
    o.start(t);
    o.stop(t + 2 + size);
  }

  /** Explosion at a distance without a bearing (cockpit-relative). */
  boom(distKm: number, size: number): void {
    const L = this.listener;
    this.boomAt(L.x + Math.cos(L.hdg) * distKm, L.y + Math.sin(L.hdg) * distKm, size);
  }

  /** Heavy flak: a sharp crack close by, a dull crump far away. */
  flak(x: number, y: number, near: number, heavy: boolean): void {
    const P = this.place(x, y, heavy ? 0.7 : 0.35, 0.5);
    if (!P) return;
    const ctx = this.ctx!;
    const t = P.t;
    const s = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = near < 1 ? 'bandpass' : 'lowpass';
    f.frequency.value = near < 1 ? 900 : 300;
    const g = ctx.createGain();
    this.env(g, t, 1, 0.002, heavy ? 0.45 : 0.18);
    s.connect(f).connect(g).connect(P.input);
    s.start(t);
    s.stop(t + 0.8);
    if (near < 0.9) {
      // The "baseball bat" slam on the airframe, then fragments.
      this.thud(55, 0.5 * (1 - near), 0.08);
      if (near < 0.55) this.shrapnel(1 - near);
    }
  }

  /** Bofors: the double "pom-pom" at ~2 rounds a second. */
  bofors(x: number, y: number): void {
    for (let i = 0; i < 2; i++) {
      const P = this.place(x, y, 0.35, 0.4);
      if (!P) return;
      const ctx = this.ctx!;
      const t = P.t + i * 0.5;
      const o = ctx.createOscillator();
      o.frequency.setValueAtTime(140, t);
      o.frequency.exponentialRampToValueAtTime(55, t + 0.12);
      const g = ctx.createGain();
      this.env(g, t, 0.9, 0.002, 0.2);
      o.connect(g).connect(P.input);
      o.start(t);
      o.stop(t + 0.3);
      const s = this.noiseSrc();
      const f = ctx.createBiquadFilter();
      f.type = 'lowpass';
      f.frequency.value = 700;
      const sg = ctx.createGain();
      this.env(sg, t, 0.6, 0.001, 0.12);
      s.connect(f).connect(sg).connect(P.input);
      s.start(t);
      s.stop(t + 0.2);
    }
  }

  /** Vulcan: ~100 rounds a second blur into one tearing buzz. */
  vulcan(x: number, y: number): void {
    const P = this.place(x, y, 0.4, 0.3);
    if (!P) return;
    const ctx = this.ctx!;
    const t = P.t;
    const dur = 0.5 + Math.random() * 0.5;
    const o = ctx.createOscillator();
    o.type = 'sawtooth';
    o.frequency.value = 100;
    const s = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = 600;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.linearRampToValueAtTime(0.6, t + 0.03);
    g.gain.setValueAtTime(0.6, t + dur);
    g.gain.linearRampToValueAtTime(0.0001, t + dur + 0.08);
    o.connect(f);
    s.connect(f);
    f.connect(g).connect(P.input);
    o.start(t);
    s.start(t);
    o.stop(t + dur + 0.1);
    s.stop(t + dur + 0.1);
  }

  /** Solid-fuel booster: a crackling roar, then (Nike) the booster separation pop. */
  samLaunch(x: number, y: number, nike: boolean): void {
    const P = this.place(x, y, 0.9, 0.7);
    if (!P) return;
    const ctx = this.ctx!;
    const t = P.t;
    const burn = nike ? 3.4 : 2.6;
    const s = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = 'lowpass';
    f.frequency.setValueAtTime(180, t);
    f.frequency.linearRampToValueAtTime(1100, t + 0.6);
    f.frequency.linearRampToValueAtTime(500, t + burn);
    const crackle = ctx.createGain();
    const am = this.noiseSrc();
    const amf = ctx.createBiquadFilter();
    amf.type = 'lowpass';
    amf.frequency.value = 40;
    const amg = ctx.createGain();
    amg.gain.value = 2.5;
    am.connect(amf).connect(amg).connect(crackle.gain);
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.linearRampToValueAtTime(1, t + 0.15);
    g.gain.setValueAtTime(0.9, t + burn - 0.3);
    g.gain.exponentialRampToValueAtTime(0.0001, t + burn + 0.5);
    s.connect(f).connect(crackle).connect(g).connect(P.input);
    s.start(t);
    am.start(t);
    s.stop(t + burn + 0.6);
    am.stop(t + burn + 0.6);
    if (nike) setTimeout(() => this.boomAt(x, y, 0.05), burn * 1000);
  }

  /** Something fast going past: falling pitch and a pan sweep (Doppler). */
  flyby(x: number, y: number, vol: number, missile: boolean): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const L = this.listener;
    const side = Math.max(-1, Math.min(1, Math.sin(Math.atan2(y - L.y, x - L.x) - L.hdg)));
    const t = ctx.currentTime;
    const s = this.noiseSrc();
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.Q.value = missile ? 4 : 1.5;
    f.frequency.setValueAtTime(missile ? 3200 : 1400, t);
    f.frequency.exponentialRampToValueAtTime(missile ? 600 : 300, t + 1.1);
    const p = ctx.createStereoPanner();
    p.pan.setValueAtTime(side, t);
    p.pan.linearRampToValueAtTime(-side, t + 1.1);
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(Math.max(0.0002, vol), t + 0.35);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 1.3);
    s.connect(f).connect(g).connect(p).connect(this.sfx);
    s.start(t);
    s.stop(t + 1.4);
  }

  /** Aircraft guns: NR-30 thumps (fighters) or the AM-23 tail turret's stutter. */
  gun(vol: number, tail = false): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const t0 = ctx.currentTime;
    const n = tail ? 4 : 1;
    for (let i = 0; i < n; i++) {
      const t = t0 + i * 0.045;
      const s = this.noiseSrc();
      const f = ctx.createBiquadFilter();
      f.type = 'bandpass';
      f.frequency.value = tail ? 1100 : 700;
      const g = ctx.createGain();
      this.env(g, t, vol, 0.002, 0.05);
      s.connect(f).connect(g).connect(this.cockpit);
      s.start(t);
      s.stop(t + 0.08);
      this.thudAt(t, tail ? 90 : 70, vol * 0.8, 0.04);
    }
  }

  whoosh(vol: number): void {
    const L = this.listener;
    this.flyby(L.x + 1, L.y, vol, true);
  }

  /** A voice line through the radio: squelch, garbled formant buzz, squelch. */
  radio(text: string, urgent: boolean): void {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const t0 = ctx.currentTime;
    const dur = Math.min(3.2, 0.5 + text.length * 0.035);
    this.click(t0, 0.35, this.radioIn);
    const hiss = this.noiseSrc();
    const hg = ctx.createGain();
    hg.gain.setValueAtTime(0.06, t0);
    hg.gain.setValueAtTime(0.0001, t0 + dur + 0.1);
    hiss.connect(hg).connect(this.radioIn);
    hiss.start(t0);
    hiss.stop(t0 + dur + 0.15);
    const o = ctx.createOscillator();
    o.type = 'sawtooth';
    const base = urgent ? 150 : 115;
    o.frequency.setValueAtTime(base, t0);
    const f1 = ctx.createBiquadFilter();
    f1.type = 'bandpass';
    f1.Q.value = 5;
    const f2 = ctx.createBiquadFilter();
    f2.type = 'bandpass';
    f2.Q.value = 6;
    const vg = ctx.createGain();
    vg.gain.setValueAtTime(0.0001, t0);
    let t = t0 + 0.08;
    const syll = Math.max(2, Math.round(dur / 0.12));
    for (let i = 0; i < syll; i++) {
      const on = Math.random() > 0.18;
      vg.gain.setTargetAtTime(on ? 0.5 : 0.02, t, 0.015);
      f1.frequency.setTargetAtTime(300 + Math.random() * 600, t, 0.03);
      f2.frequency.setTargetAtTime(1000 + Math.random() * 1400, t, 0.03);
      o.frequency.setTargetAtTime(base * (0.9 + Math.random() * (urgent ? 0.5 : 0.25)), t, 0.04);
      t += 0.07 + Math.random() * 0.09;
    }
    vg.gain.setTargetAtTime(0.0001, t0 + dur, 0.02);
    o.connect(f1);
    o.connect(f2);
    f1.connect(vg);
    f2.connect(vg);
    vg.connect(this.radioIn);
    o.start(t0);
    o.stop(t0 + dur + 0.1);
    this.click(t0 + dur + 0.05, 0.35, this.radioIn);
  }

  uiClick(): void {
    this.beep(880, 0.03, 0.12, 'triangle');
  }

  teletype(): void {
    if (!this.ctx) return;
    for (let i = 0; i < 5; i++) setTimeout(() => this.beep(300 + Math.random() * 120, 0.012, 0.08, 'square'), i * 45);
  }
}

export const audio = new Audio();