Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

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

src/game/audio.ts
// Procedural sound effects built with Web Audio. There are no audio files, so the
// napplet stays a single file and never needs network access. Every call does
// nothing until unlock() runs from a user gesture, as browser autoplay rules require.
//
// The crashes are meant to be loud and dramatic. crash-synth.ts renders each one
// sample by sample, with more chaos the faster the hit, and this file sends it
// through controlled saturation and a dark reverb tail.

import { renderCrash, type ImpactKind } from './crash-synth.js';

type Wave = OscillatorType;

const ENGINE_LEVEL = 0.11;
const MIN_CRASH_GAP = 0.07;
const MIN_BUMP_GAP = 0.16;
const MIN_PILEUP_GAP = 0.12;
/** Crashes render at a lower rate to stay cheap; glass tops out near 14 kHz. */
const CRASH_RATE = 32000;

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

function rand(lo: number, hi: number): number {
  return lo + Math.random() * (hi - lo);
}

/** Some browsers expose cancellation but not the newer hold operation. */
function holdGain(param: AudioParam, time: number): void {
  if (typeof param.cancelAndHoldAtTime === 'function') {
    param.cancelAndHoldAtTime(time);
  } else {
    const current = param.value;
    param.cancelScheduledValues(time);
    param.setValueAtTime(current, time);
  }
}

/** Fixed channel layouts avoid filter reconfiguration as voices connect/end. */
function createFilter(ctx: AudioContext, channels = 1): BiquadFilterNode {
  const filter = ctx.createBiquadFilter();
  filter.channelCount = channels;
  filter.channelCountMode = 'explicit';
  return filter;
}

interface Engine {
  osc: OscillatorNode;
  sub: OscillatorNode;
  lfo: OscillatorNode;
  lfoDepth: GainNode;
  filter: BiquadFilterNode;
  out: GainNode;
  turbo: OscillatorNode;
  turboGain: GainNode;
  squeal: OscillatorNode;
  squealGain: GainNode;
  hissGain: GainNode;
  hissFilter: BiquadFilterNode;
}

/** The A-10's twin turbofans: rumble, roar, the fan's hollow whine and the rush of air. */
interface Jet {
  out: GainNode;
  rumble: GainNode;
  roar: GainNode;
  roarTone: BiquadFilterNode;
  whineA: OscillatorNode;
  whineB: OscillatorNode;
  whine: GainNode;
  air: GainNode;
  airTone: BiquadFilterNode;
}

/** The GAU-8's continuous voice: a buzz and chopped roar at the firing rate, and the shells' crackle. */
interface Gun {
  buzz: OscillatorNode;
  chop: OscillatorNode;
  roar: GainNode;
  crackle: GainNode;
  out: GainNode;
}

/** What blew up: explosions by size, and buildings coming down. */
export type BlastKind = 'small' | 'medium' | 'huge' | 'collapse-wood' | 'collapse-stone';

export class Sfx {
  private ctx: AudioContext | null = null;
  private master!: GainNode;
  private mix!: GainNode;
  private ambient!: GainNode;
  private slots = new Map<string, number[]>();
  private duckUntil = 0;
  private transient = new WeakMap<AudioNode, { users: number; nodes: AudioNode[] }>();
  /** Engine and other continuous sounds. Ducked while a crash plays. */
  private drive!: GainNode;
  /** One-shot effects, dry. */
  private fx!: GainNode;
  /** Crash bus: distortion before the compressor. */
  private crunch!: GainNode;
  private verb!: GainNode;
  private noise!: AudioBuffer;
  private engine: Engine | null = null;
  private gunVoice: Gun | null = null;
  private fireVoice: GainNode | null = null;
  private jetVoice: Jet | null = null;
  private lastThrottle = 0;
  /** The A-10's bank (-1..1) and airspeed (0..1), for the rush of air. */
  private flightBank = 0;
  private flightSpeed = 0;
  private gunOn = false;
  private volume = 0.8;
  private muted = false;
  private lastCrash = -1;
  private lastBump = -1;
  private lastPileup = -1;
  private crashBusyUntil = 0;
  private wasBraking = false;
  private lastSpeed = 0;

  get ready(): boolean {
    return this.ctx !== null;
  }

  /** Call from a user gesture (key press, tap, button click). Safe to call often. */
  unlock(): void {
    if (!this.ctx) {
      const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
      if (!Ctor) return;
      try {
        this.ctx = new Ctor();
      } catch {
        return;
      }
      this.build(this.ctx);
    }
    if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {});
  }

  /** Pause all audio, e.g. when the frame is hidden. */
  suspend(): void {
    if (this.ctx?.state === 'running') void this.ctx.suspend().catch(() => {});
  }

  /** Set the volume from 0 to 1. */
  setVolume(value: number): void {
    this.volume = clamp(value, 0, 1);
    this.applyVolume();
  }

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

  toggleMute(): boolean {
    this.setMuted(!this.muted);
    return this.muted;
  }

  get isMuted(): boolean {
    return this.muted;
  }

  private applyVolume(): void {
    if (!this.ctx) return;
    const target = this.muted ? 0 : this.volume * this.volume;
    this.master.gain.setTargetAtTime(target, this.ctx.currentTime, 0.02);
  }

  private build(ctx: AudioContext): void {
    // Mix dynamics run before user volume: turning down must not change the mix.
    this.mix = ctx.createGain();
    this.mix.gain.value = 0.65;
    const lowCut = createFilter(ctx, 2);
    lowCut.type = 'highpass';
    lowCut.frequency.value = 25;
    lowCut.Q.value = 0.707;
    const comp = ctx.createDynamicsCompressor();
    comp.threshold.value = -12;
    comp.knee.value = 12;
    comp.ratio.value = 4;
    comp.attack.value = 0.003;
    comp.release.value = 0.18;
    // Compressor attack can let transients through. A mostly linear safety curve
    // catches only peaks, with a ceiling below full scale and no makeup gain.
    const safety = ctx.createWaveShaper();
    const curve = new Float32Array(4097);
    for (let i = 0; i < curve.length; i++) {
      const x = i / (curve.length - 1) * 2 - 1;
      const a = Math.abs(x);
      curve[i] = Math.sign(x) * (a <= 0.75 ? a : 0.75 + 0.15 * Math.tanh((a - 0.75) / 0.15));
    }
    safety.curve = curve;
    this.master = ctx.createGain();
    this.master.gain.value = 0;
    this.mix.connect(lowCut).connect(comp).connect(safety).connect(this.master).connect(ctx.destination);

    const len = ctx.sampleRate * 2;
    this.noise = ctx.createBuffer(1, len, ctx.sampleRate);
    const data = this.noise.getChannelData(0);
    for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;

    // Long, dark reverb: the crash rings out across the city.
    const convolver = ctx.createConvolver();
    convolver.buffer = this.impulse(ctx, 1.8, 2.8);
    const verbTone = createFilter(ctx, 2);
    verbTone.type = 'lowpass';
    verbTone.frequency.value = 5200;
    this.verb = ctx.createGain();
    this.verb.gain.value = 0.28;
    this.verb.connect(convolver).connect(verbTone).connect(this.mix);

    this.drive = ctx.createGain();
    this.drive.connect(this.mix);

    this.fx = ctx.createGain();
    this.fx.gain.value = 0.7;
    this.fx.connect(this.mix);
    this.ambient = ctx.createGain();
    this.ambient.gain.value = 0.55;
    this.ambient.connect(this.mix);
    const fxSend = ctx.createGain();
    fxSend.gain.value = 0.12;
    this.fx.connect(fxSend).connect(this.verb);

    const shaper = ctx.createWaveShaper();
    shaper.curve = this.driveCurve(3.2);
    shaper.oversample = '2x';
    this.crunch = ctx.createGain();
    // Control the summed impact energy before saturation so overlapping booms
    // do not pin the distortion at full scale for seconds.
    const impactComp = ctx.createDynamicsCompressor();
    impactComp.threshold.value = -10;
    impactComp.knee.value = 8;
    impactComp.ratio.value = 8;
    impactComp.attack.value = 0.002;
    impactComp.release.value = 0.12;
    this.crunch.gain.value = 0.55;
    this.crunch.connect(impactComp).connect(shaper);
    const impactLevel = ctx.createGain();
    impactLevel.gain.value = 0.5;
    shaper.connect(impactLevel).connect(this.mix);
    const crunchSend = ctx.createGain();
    crunchSend.gain.value = 0.22;
    shaper.connect(crunchSend).connect(this.verb);

    this.engine = this.buildEngine(ctx);
    this.applyVolume();
    // Warm up the crash renderer so the first real crash doesn't stall a frame.
    window.setTimeout(() => renderCrash(CRASH_RATE, 1.2, 'car'), 0);

  }

  private impulse(ctx: AudioContext, seconds: number, decay: number): AudioBuffer {
    const len = Math.floor(ctx.sampleRate * seconds);
    const buf = ctx.createBuffer(2, len, ctx.sampleRate);
    for (let ch = 0; ch < 2; ch++) {
      const d = buf.getChannelData(ch);
      for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
    }
    return buf;
  }

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

  // --- Diesel engine, brake squeal and air hiss: continuous voices.

  private buildEngine(ctx: AudioContext): Engine {
    const out = ctx.createGain();
    out.gain.value = 0;
    out.connect(this.drive);

    const filter = createFilter(ctx);
    filter.type = 'lowpass';
    filter.frequency.value = 320;
    filter.Q.value = 4;

    // The chug: the engine's level pulses at the firing rate.
    const body = ctx.createGain();
    body.gain.value = 0.6;
    const lfo = ctx.createOscillator();
    lfo.type = 'square';
    lfo.frequency.value = 12;
    const lfoDepth = ctx.createGain();
    lfoDepth.gain.value = 0.35;
    lfo.connect(lfoDepth).connect(body.gain);

    const osc = ctx.createOscillator();
    osc.type = 'sawtooth';
    osc.frequency.value = 42;
    const sub = ctx.createOscillator();
    sub.type = 'square';
    sub.frequency.value = 21;
    const subGain = ctx.createGain();
    subGain.gain.value = 0.5;
    osc.connect(filter);
    sub.connect(subGain).connect(filter);
    filter.connect(body).connect(out);

    const turbo = ctx.createOscillator();
    turbo.type = 'sine';
    turbo.frequency.value = 1400;
    const turboGain = ctx.createGain();
    turboGain.gain.value = 0;
    turbo.connect(turboGain).connect(out);

    // Brake squeal goes to the drive bus directly so it still sounds when the engine is quiet.
    const squeal = ctx.createOscillator();
    squeal.type = 'triangle';
    squeal.frequency.value = 2300;
    const vibrato = ctx.createOscillator();
    vibrato.frequency.value = 9;
    const vibratoDepth = ctx.createGain();
    vibratoDepth.gain.value = 40;
    vibrato.connect(vibratoDepth).connect(squeal.frequency);
    const squealGain = ctx.createGain();
    squealGain.gain.value = 0;
    squeal.connect(squealGain).connect(this.drive);

    const hiss = ctx.createBufferSource();
    hiss.buffer = this.noise;
    hiss.loop = true;
    const hissFilter = createFilter(ctx);
    hissFilter.type = 'bandpass';
    hissFilter.frequency.value = 3600;
    hissFilter.Q.value = 0.8;
    const hissGain = ctx.createGain();
    hissGain.gain.value = 0;
    hiss.connect(hissFilter).connect(hissGain).connect(this.drive);

    for (const node of [osc, sub, lfo, turbo, squeal, vibrato, hiss]) node.start();
    return { osc, sub, lfo, lfoDepth, filter, out, turbo, turboGain, squeal, squealGain, hissGain, hissFilter };
  }

  /**
   * Update the truck's continuous sounds once per frame.
   * speed: 0 to 1 (negative when reversing), active: false silences the engine.
   * engineRpm (optional, 0 to 1 of redline) comes from the gearbox; without it the
   * pitch is estimated from speed.
   */
  /** Diesel rig or a high-revving sports car. */
  private voice: 'diesel' | 'sports' | 'jet' = 'diesel';

  setFlight(bank: number, speed: number): void {
    this.flightBank = bank;
    this.flightSpeed = speed;
  }

  setVoice(voice: 'diesel' | 'sports' | 'jet'): void {
    this.voice = voice;
  }

  driveUpdate(speed: number, throttle: boolean, braking: boolean, active: boolean, engineRpm?: number): void {
    const ctx = this.ctx;
    const e = this.engine;
    if (!ctx || !e) return;
    const t = ctx.currentTime;
    const s = clamp(Math.abs(speed), 0, 1);
    const load = throttle ? 1 : 0;
    const rpm = engineRpm === undefined ? 0.15 + s * 0.7 + load * 0.15 : 0.1 + clamp(engineRpm, 0, 1.05) * 0.9;

    const sports = this.voice === 'sports';
    if (this.voice === 'jet') {
      e.out.gain.setTargetAtTime(0, t, 0.1);
      e.turboGain.gain.setTargetAtTime(0, t, 0.1);
      e.squealGain.gain.setTargetAtTime(0, t, 0.05);
      e.hissGain.gain.setTargetAtTime(0, t, 0.1);
      this.jetUpdate(clamp(engineRpm ?? 0.6, 0, 1), active);
      this.wasBraking = false;
      return;
    }
    if (this.jetVoice) this.jetVoice.out.gain.setTargetAtTime(0, t, 0.2);
    if (e.hissFilter.frequency.value !== 3600) {
      e.hissFilter.frequency.setTargetAtTime(3600, t, 0.1);
      e.hissFilter.Q.setTargetAtTime(0.8, t, 0.1);
      e.lfoDepth.gain.setTargetAtTime(0.35, t, 0.1);
      e.hissGain.gain.setTargetAtTime(0, t, 0.1);
      e.squeal.frequency.setTargetAtTime(2300, t, 0.1);
    }
    // A sports engine revs far higher and snarls; the diesel thrums low.
    e.osc.frequency.setTargetAtTime(sports ? 60 + rpm * 300 : 32 + rpm * 70, t, sports ? 0.05 : 0.12);
    e.sub.frequency.setTargetAtTime(sports ? 30 + rpm * 150 : 16 + rpm * 35, t, sports ? 0.05 : 0.12);
    e.lfo.frequency.setTargetAtTime(sports ? 25 + rpm * 110 : 9 + rpm * 26, t, sports ? 0.05 : 0.12);
    e.filter.frequency.setTargetAtTime(sports ? 500 + rpm * 2200 + load * 700 : 220 + rpm * 700 + load * 400, t, 0.1);
    e.out.gain.setTargetAtTime(active ? ENGINE_LEVEL * (0.55 + load * 0.3 + s * 0.3) : 0, t, active ? 0.08 : 0.25);
    e.turbo.frequency.setTargetAtTime(900 + rpm * 2200, t, 0.3);
    e.turboGain.gain.setTargetAtTime(active && throttle && !sports ? 0.012 * s : 0, t, 0.25);

    const squealing = active && braking && s > 0.25;
    e.squealGain.gain.setTargetAtTime(squealing ? 0.035 * s : 0, t, squealing ? 0.06 : 0.04);

    // Air brakes let go with a "pssht" after a hard stop.
    if (active && !sports && this.wasBraking && this.lastSpeed > 0.05 && s <= 0.03) this.airRelease();
    this.wasBraking = braking && active;
    this.lastSpeed = s;
  }

  private airRelease(): void {
    const ctx = this.ctx!;
    const t = ctx.currentTime;
    const e = this.engine!;
    e.hissGain.gain.cancelScheduledValues(t);
    e.hissGain.gain.setValueAtTime(0, t);
    e.hissGain.gain.linearRampToValueAtTime(0.18, t + 0.03);
    e.hissGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.7);
  }

  /** Briefly pull engine and background activity down so a crash owns the mix. */
  private duck(seconds: number): void {
    const t = this.ctx!.currentTime;
    this.duckUntil = Math.max(this.duckUntil, t + seconds);
    for (const [g, floor, resting] of [[this.drive.gain, 0.35, 1], [this.ambient.gain, 0.16, 0.55]] as const) {
      holdGain(g, t);
      g.linearRampToValueAtTime(floor, t + 0.02);
      g.setTargetAtTime(resting, this.duckUntil, 0.25);
    }
  }

  /** Bound whole events, including their scheduled tails, before allocating nodes.
   * Separate pools preserve player feedback when background destruction is busy.
   */
  private admit(group: string, length: number, max: number): boolean {
    const t = this.ctx!.currentTime;
    const live = (this.slots.get(group) ?? []).filter(end => end > t);
    if (live.length >= max) return false;
    live.push(t + length);
    this.slots.set(group, live);
    return true;
  }

  /** Release private nodes when the last scheduled voice using them finishes. */
  private release(source: AudioScheduledSourceNode, dest: AudioNode, ...nodes: AudioNode[]): void {
    const shared = this.transient.get(dest);
    if (shared) shared.users++;
    source.onended = () => {
      source.disconnect();
      for (const node of nodes) node.disconnect();
      if (shared && --shared.users === 0) {
        for (const node of shared.nodes) node.disconnect();
        this.transient.delete(dest);
      }
    };
  }

  // --- Building blocks.

  private out(pan: number, dest: AudioNode): AudioNode {
    if (!pan || !this.ctx!.createStereoPanner) return dest;
    const p = this.ctx!.createStereoPanner();
    p.pan.value = clamp(pan, -1, 1);
    p.connect(dest);
    this.transient.set(p, { users: 0, nodes: [p] });
    return p;
  }

  private tone(
    dest: AudioNode, wave: Wave, f0: number, f1: number, t: number, dur: number,
    peak: number, attack = 0.005,
  ): OscillatorNode {
    const ctx = this.ctx!;
    const osc = ctx.createOscillator();
    osc.type = wave;
    osc.frequency.setValueAtTime(f0, t);
    if (f1 !== f0) osc.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t + dur);
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    osc.connect(g).connect(dest);
    this.release(osc, dest, g);
    osc.start(t);
    osc.stop(t + dur + 0.05);
    return osc;
  }

  private burst(
    dest: AudioNode, type: BiquadFilterType, f0: number, f1: number, q: number,
    t: number, dur: number, peak: number, attack = 0.003,
  ): void {
    const ctx = this.ctx!;
    const src = ctx.createBufferSource();
    src.buffer = this.noise;
    src.playbackRate.value = rand(0.9, 1.1);
    const filter = createFilter(ctx);
    filter.type = type;
    filter.Q.value = q;
    filter.frequency.setValueAtTime(f0, t);
    if (f1 !== f0) filter.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur);
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    src.connect(filter).connect(g).connect(dest);
    this.release(src, dest, filter, g);
    src.start(t, rand(0, 1.5));
    src.stop(t + dur + 0.05);
  }

  // --- Crashes: rendered sample by sample in crash-synth.ts, then given room
  // acoustics here (saturation, stereo position, reverb).

  /** Play one rendered crash. Returns its severity (0 to ~1.25). */
  private playCrash(intensity: number, kind: ImpactKind, pan: number, delay = 0, finale = false): number {
    const ctx = this.ctx!;
    const t = ctx.currentTime + delay;
    const group = finale ? 'finale' : kind === 'pileup' ? 'pileup' : 'impact';
    if (!this.admit(group, 5 + delay, finale ? 2 : kind === 'pileup' ? 3 : 5)) return 0;
    const render = renderCrash(CRASH_RATE, intensity, kind);
    const sev = render.severity;
    // Small hits free their slot sooner; hold it through delayed playback too.
    const ends = this.slots.get(group)!;
    ends[ends.length - 1] = t + render.left.length / CRASH_RATE;
    const buf = ctx.createBuffer(2, render.left.length, CRASH_RATE);
    buf.copyToChannel(render.left, 0);
    buf.copyToChannel(render.right, 1);
    const src = ctx.createBufferSource();
    src.buffer = buf;

    // Harder crashes drive the saturation harder and fill more of the reverb.
    const level = ctx.createGain();
    level.gain.value = (kind === 'pileup' ? 0.55 : 1) * (0.7 + 1.1 * Math.min(1, sev));
    const send = ctx.createGain();
    send.gain.value = 0.15 + 0.45 * Math.min(1, sev) + (kind === 'pileup' ? 0.35 : 0);
    const out = this.out(pan, level);
    src.connect(out);
    level.connect(this.crunch);
    level.connect(send).connect(this.verb);
    this.release(src, out, level, send);
    src.start(t);
    return sev;
  }

  /**
   * A crash at the game's raw intensity (closing speed / 100): about 0.1 for a
   * bump, 0.5 to 1 for a solid hit, 1.1 to 1.7 at highway speed. Higher speed means more chaos: more crumpling,
   * glass and debris, longer scrapes, and cars that roll with repeated impacts.
   * kind: 'car' (truck hits a car), 'wall' (truck hits a building) or 'pileup'
   * (two AI cars, a little further away). pan: -1 (left) to 1 (right).
   */
  impact(intensity: number, kind: ImpactKind = 'car', pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    // Each kind has its own limit, so a burst of AI wrecks never swallows the player's hit.
    if (kind === 'wall') {
      if (t - this.lastBump < MIN_BUMP_GAP) return;
      this.lastBump = t;
    } else if (kind === 'pileup') {
      if (t - this.lastPileup < MIN_PILEUP_GAP) return;
      this.lastPileup = t;
    } else {
      if (t - this.lastCrash < MIN_CRASH_GAP) return;
      this.lastCrash = t;
    }
    // A crash on top of a fresh one is part of the same pile-up: keep it smaller.
    const busy = t < this.crashBusyUntil;
    const sev = this.playCrash(busy ? intensity * 0.6 : intensity, kind, pan);
    if (kind === 'pileup') return;
    this.crashBusyUntil = Math.max(this.crashBusyUntil, t + 0.25);
    if (sev > 0.25) this.duck(0.3 + sev);
  }

  /** Truck hits a car. intensity 0 to 1. Prefer impact() with the game's raw value. */
  crash(intensity = 0.6, pan = 0): void {
    this.impact(intensity * 1.4, 'car', pan);
  }

  /** Truck hits a wall or building. intensity 0 to 1. Prefer impact(). */
  bump(intensity = 0.5, pan = 0): void {
    this.impact(intensity * 1.2, 'wall', pan);
  }

  /**
   * The truck is totaled. The worst crash in the game, the trailer folding in a
   * moment later, then the fuel tank goes up in a fireball, flames crackle and
   * the horn sticks on as it dies.
   */
  totaled(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.playCrash(1.8, 'wall', 0, 0, true);
    // Render the trailer's crash on a later frame so the two renders don't stack into one stall.
    window.setTimeout(() => {
      if (this.ctx) this.playCrash(1.7, 'car', rand(-0.4, 0.4), Math.max(0, 0.5 - (this.ctx.currentTime - t)), true);
    }, 120);
    this.duck(4);

    // Fireball.
    const fire = t + 0.22;
    this.tone(this.crunch, 'sine', 90, 18, fire, 2.6, 1.0, 0.02);
    this.tone(this.crunch, 'sine', 55, 16, fire, 3.0, 0.8, 0.05);
    this.burst(this.crunch, 'lowpass', 2400, 90, 0.9, fire, 3.2, 1.0, 0.03);
    this.burst(this.crunch, 'bandpass', 600, 120, 1.2, fire + 0.05, 1.6, 0.7, 0.02);

    // Metal groaning under heat.
    this.tone(this.fx, 'sawtooth', 140, 70, t + 0.9, 2.2, 0.05, 0.5);

    // Flames crackling, fading out.
    for (let i = 0; i < 46; i++) {
      const at = fire + 0.3 + Math.pow(Math.random(), 1.4) * 3.6;
      const fade = 1 - (at - fire) / 4.2;
      this.burst(this.fx, 'bandpass', rand(900, 4000), rand(600, 2000), rand(2, 8), at, rand(0.01, 0.04), rand(0.12, 0.35) * fade);
    }

    // The horn jams on and sags as the battery dies.
    const horn = t + 0.4;
    for (const f of [185, 233, 277]) {
      const osc = this.tone(this.fx, 'sawtooth', f, f * 0.8, horn, 3.2, 0.06, 0.04);
      osc.detune.setValueAtTime(0, horn + 1.8);
      osc.detune.linearRampToValueAtTime(-260, horn + 3.2);
    }
  }

  /** Big rig air horn: the player's horn and the start of a shift. */
  airHorn(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const bp = createFilter(ctx);
    bp.type = 'bandpass';
    bp.frequency.value = 700;
    bp.Q.value = 0.7;
    bp.connect(this.fx);
    this.transient.set(bp, { users: 0, nodes: [bp] });
    for (const f of [220, 277, 330]) {
      const osc = ctx.createOscillator();
      osc.type = 'sawtooth';
      osc.frequency.value = f;
      const g = ctx.createGain();
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(0.16, t + 0.03);
      g.gain.setValueAtTime(0.16, t + 0.55);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 0.8);
      osc.connect(g).connect(bp);
      this.release(osc, bp, g);
      osc.start(t);
      osc.stop(t + 0.85);
    }
  }

  /** An AI car honks. Rude drivers lean on it twice; polite ones give a short beep. */
  honk(rude: boolean, pan = 0, distance = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('horns', 0.7, 4)) return;
    const t = ctx.currentTime;
    const level = clamp(1 - distance, 0.25, 1);
    const dest = this.out(pan, this.ambient);
    const pitch = rand(0.92, 1.08);
    const pulses = rude ? [[0, 0.16], [0.22, 0.42]] : [[0, 0.12]];
    const freqs = rude ? [392, 494] : [523, 659];
    for (const [at, dur] of pulses) {
      for (const f of freqs) {
        const osc = ctx.createOscillator();
        osc.type = 'square';
        osc.frequency.value = f * pitch;
        const g = ctx.createGain();
        g.gain.setValueAtTime(0.0001, t + at);
        g.gain.exponentialRampToValueAtTime(0.045 * level, t + at + 0.01);
        g.gain.setValueAtTime(0.045 * level, t + at + dur - 0.02);
        g.gain.exponentialRampToValueAtTime(0.0001, t + at + dur);
        osc.connect(g).connect(dest);
        this.release(osc, dest, g);
        osc.start(t + at);
        osc.stop(t + at + dur + 0.02);
      }
    }
  }

  /** Cargo loaded: air hiss, a heavy clunk and a two-tone chime. */
  loaded(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.burst(this.fx, 'bandpass', 3500, 2500, 0.8, t, 0.35, 0.12);
    this.tone(this.fx, 'sine', 120, 50, t + 0.05, 0.25, 0.4);
    this.burst(this.fx, 'bandpass', 500, 300, 3, t + 0.05, 0.1, 0.3);
    this.tone(this.fx, 'square', 660, 660, t + 0.12, 0.14, 0.08);
    this.tone(this.fx, 'square', 990, 990, t + 0.24, 0.24, 0.08);
  }

  /** Delivery complete: a cash-register ding and a rising arpeggio. */
  delivered(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    [523, 659, 784, 1047].forEach((f, i) => this.tone(this.fx, 'square', f, f, t + i * 0.07, 0.18, 0.08));
    this.tone(this.fx, 'sine', 2093, 2093, t + 0.3, 0.6, 0.12, 0.002);
    this.tone(this.fx, 'sine', 3136, 3136, t + 0.3, 0.45, 0.06, 0.002);
  }

  /** Menu button press. */
  click(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    this.tone(this.fx, 'square', 880, 440, ctx.currentTime, 0.06, 0.05, 0.002);
  }

  /** Gear change: a clutch thunk and a short air puff. */
  shift(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.tone(this.fx, 'sine', 140, 60, t, 0.12, 0.16, 0.003);
    this.burst(this.fx, 'bandpass', 900, 600, 4, t, 0.05, 0.12);
    this.burst(this.fx, 'bandpass', 3200, 2400, 1, t + 0.04, 0.12, 0.04);
  }

  /** Missed shift: gear teeth grinding. */
  grind(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.tone(this.fx, 'sawtooth', 210, 160, t, 0.35, 0.05, 0.01);
    for (let i = 0; i < 9; i++) this.burst(this.fx, 'bandpass', rand(1500, 3200), rand(900, 1500), 6, t + i * 0.035, 0.03, 0.09);
  }

  /** A pedestrian gets bowled over: a soft thud and a yelp. */
  pedHit(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    this.tone(dest, 'sine', 120, 50, t, 0.18, 0.35, 0.003);
    this.burst(dest, 'lowpass', 900, 200, 0.7, t, 0.12, 0.3);
    const yelp = rand(520, 760);
    this.tone(dest, 'triangle', yelp, yelp * 1.5, t + 0.05, 0.12, 0.06, 0.01);
    this.tone(dest, 'triangle', yelp * 1.5, yelp * 0.8, t + 0.17, 0.2, 0.05, 0.01);
  }

  /** One cycle of a wrecked car's alarm: a two-tone warble. Call about twice a second. */
  alarmBeep(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.ambient);
    this.tone(dest, 'square', 1250, 1250, t, 0.16, 0.022, 0.004);
    this.tone(dest, 'square', 950, 950, t + 0.22, 0.16, 0.022, 0.004);
  }

  /**
   * Extra weight on top of impact() for crashes above highway speed: a deeper,
   * longer boom, a tearing metal shriek and a rain of parts. intensity is the
   * game's raw value (1.1 to about 2.5), so the fastest crashes get the most.
   */
  extremeHit(intensity: number, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('extreme', 3.5, 2)) return;
    const t = ctx.currentTime;
    const k = clamp((intensity - 1) / 1.3, 0, 1);
    if (k <= 0) return;
    const dest = this.out(pan, this.fx);
    this.tone(this.crunch, 'sine', 70, 20, t, 1 + k * 1.4, 0.5 + 0.5 * k, 0.005);
    this.tone(this.crunch, 'sine', 42, 16, t + 0.02, 1.4 + k * 1.6, 0.4 + 0.5 * k, 0.01);
    // Metal tearing: a falling, distorted shriek.
    this.tone(dest, 'sawtooth', rand(900, 1300), rand(180, 260), t + 0.03, 0.5 + k * 0.6, 0.05 + 0.07 * k, 0.01);
    this.burst(dest, 'bandpass', 2600, 700, 3, t + 0.02, 0.6 + k * 0.5, 0.18 + 0.2 * k, 0.005);
    // Parts raining down over the next seconds.
    const pieces = Math.round(6 + k * 22);
    for (let i = 0; i < pieces; i++) {
      const at = t + 0.3 + Math.pow(Math.random(), 1.3) * (1 + k * 1.6);
      const f = rand(300, 2400);
      this.burst(dest, 'bandpass', f, f * 0.7, rand(3, 8), at, rand(0.02, 0.07), rand(0.08, 0.3) * (0.5 + k));
    }
    this.duck(0.8 + k);
  }

  /** A fuel tank explodes: a concussive blast, a roaring fireball and crackling flames. */
  explosion(size = 1, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('explosions', 5.5, 4)) return;
    const t = ctx.currentTime;
    const s = clamp(size, 0.5, 1.6);
    const dest = this.out(pan, this.fx);
    this.burst(this.crunch, 'lowpass', 6000, 120, 0.8, t, 0.25, 1.0 * s, 0.001);
    this.tone(this.crunch, 'sine', 110, 18, t, 2.2 * s, 1.0, 0.003);
    this.tone(this.crunch, 'sine', 60, 15, t + 0.03, 2.6 * s, 0.85, 0.01);
    this.burst(this.crunch, 'lowpass', 1800, 80, 0.9, t + 0.05, 2.4 * s, 0.8 * s, 0.03);
    for (let i = 0; i < 30 * s; i++) {
      const at = t + 0.2 + Math.pow(Math.random(), 1.5) * 2.6 * s;
      const fade = 1 - (at - t) / (3 * s);
      this.burst(dest, 'bandpass', rand(800, 4200), rand(500, 1800), rand(2, 7), at, rand(0.01, 0.04), rand(0.1, 0.3) * fade);
    }
    this.duck(1.5 * s);
  }

  /** A tumbling car's roof or side slamming into the road. */
  rollThud(energy: number, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('rolls', 0.6, 4)) return;
    const t = ctx.currentTime;
    const k = clamp(energy / 90, 0.15, 1);
    const dest = this.out(pan, this.fx);
    this.tone(dest, 'sine', 95, 35, t, 0.3, 0.45 * k, 0.003);
    this.burst(dest, 'lowpass', 3000, 300, 0.8, t, 0.18 + 0.2 * k, 0.45 * k, 0.002);
    for (let i = 0; i < 4 + k * 8; i++) this.burst(dest, 'bandpass', rand(500, 2500), rand(300, 1200), rand(3, 7), t + rand(0, 0.15), rand(0.02, 0.06), rand(0.15, 0.35) * k);
    if (k > 0.4) for (let i = 0; i < 10 * k; i++) this.tone(dest, 'sine', rand(3000, 9000), rand(2800, 8000), t + rand(0.01, 0.3), rand(0.01, 0.05), rand(0.02, 0.08), 0.001);
  }

  /** Wheels lifting in a corner: tyre scrub and the trailer creaking. */
  tipping(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.burst(this.fx, 'bandpass', 1800, 1300, 2, t, 0.6, 0.15, 0.05);
    this.tone(this.fx, 'sawtooth', 70, 55, t, 0.7, 0.04, 0.2);
  }

  // Type-specific crash layers. All are one-shots with fixed lengths, and at most
  // MAX_FLAVOR may sound at once so a pile of police cars can't stack up sirens.
  private flavorUntil: number[] = [];

  private flavorSlot(t: number, length: number): boolean {
    this.flavorUntil = this.flavorUntil.filter((u) => u > t);
    if (this.flavorUntil.length >= 3) return false;
    this.flavorUntil.push(t + length);
    return true;
  }

  /** The signature sound of the kind of vehicle that got hit, louder with speed. */
  carFlavor(model: string, intensity: number, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('flavors', 2.5, 4)) return;
    const t = ctx.currentTime;
    const k = clamp(intensity / 1.4, 0.2, 1.3);
    const dest = this.out(pan, this.fx);
    switch (model) {
      case 'police': {
        if (!this.flavorSlot(t, 1.6)) return;
        // Siren whoop that dies as the lightbar shorts out, then radio static.
        const osc = this.tone(dest, 'sawtooth', 650, 300, t + 0.05, 1.3, 0.06 * k, 0.02);
        osc.frequency.setValueAtTime(650, t + 0.05);
        osc.frequency.linearRampToValueAtTime(1300, t + 0.45);
        osc.frequency.linearRampToValueAtTime(700, t + 0.85);
        osc.frequency.exponentialRampToValueAtTime(140, t + 1.3);
        this.burst(dest, 'bandpass', 2200, 1800, 2, t + 0.9, 0.5, 0.08 * k);
        break;
      }
      case 'icecream': {
        if (!this.flavorSlot(t, 2)) return;
        // The jingle, warped and slowing down like a dying music box.
        const notes = [784, 659, 784, 880, 784, 659, 587, 523];
        notes.forEach((f, i) => {
          const at = t + 0.1 + i * (0.14 + i * 0.02);
          const sag = 1 - i * 0.04;
          this.tone(dest, 'triangle', f * sag, f * sag * 0.97, at, 0.16, 0.06 * k, 0.005);
        });
        break;
      }
      case 'bus':
        this.tone(dest, 'sine', 55, 25, t, 1.2, 0.5 * k, 0.005);
        for (let i = 0; i < 40 * k; i++) this.tone(dest, 'sine', rand(3000, 9000), rand(2800, 8500), t + rand(0.02, 0.9), rand(0.01, 0.05), rand(0.02, 0.07), 0.001);
        this.burst(dest, 'bandpass', 3800, 2600, 0.8, t + 0.3, 0.8, 0.12 * k, 0.02);
        break;
      case 'motorcycle':
        this.tone(dest, 'sawtooth', 300, 900, t, 0.25, 0.06 * k, 0.01);
        this.tone(dest, 'sawtooth', 900, 120, t + 0.25, 0.7, 0.05 * k, 0.01);
        this.burst(dest, 'highpass', 4000, 3000, 0.7, t + 0.2, 1, 0.12 * k, 0.05);
        break;
      case 'limo':
        // Champagne: a cork pop and a crash of bottles.
        this.tone(dest, 'sine', 900, 200, t + 0.05, 0.08, 0.2 * k, 0.001);
        for (let i = 0; i < 16; i++) this.tone(dest, 'triangle', rand(1800, 4200), rand(1600, 3800), t + rand(0.05, 0.6), rand(0.04, 0.12), rand(0.04, 0.1) * k, 0.001);
        break;
      case 'pickup':
      case 'van':
        // Cargo tumbling out of the back.
        for (let i = 0; i < 6 + 6 * k; i++) {
          const at = t + 0.15 + i * rand(0.06, 0.14);
          this.tone(dest, 'sine', rand(90, 160), rand(40, 70), at, 0.12, rand(0.15, 0.3) * k, 0.002);
          this.burst(dest, 'lowpass', 1200, 300, 1, at, 0.05, 0.12 * k);
        }
        break;
      case 'beater':
        // Rattling junk: a cascade of tinny clanks and a hubcap.
        for (let i = 0; i < 10; i++) this.tone(dest, 'square', rand(400, 1400), rand(300, 900), t + rand(0.05, 0.9), 0.04, 0.04 * k, 0.001);
        break;
      case 'sports':
        // The turbo spooling down with a whistle.
        this.tone(dest, 'sine', 2600, 600, t + 0.05, 1.1, 0.05 * k, 0.02);
        break;
      case 'taxi':
        this.tone(dest, 'sine', 1568, 1568, t + 0.2, 0.25, 0.06 * k, 0.003);
        this.tone(dest, 'sine', 2093, 2093, t + 0.32, 0.3, 0.05 * k, 0.003);
        break;
      case 'compact':
        // Cheap plastic cracking.
        for (let i = 0; i < 12; i++) this.burst(dest, 'bandpass', rand(2000, 5000), rand(1500, 3000), 6, t + rand(0, 0.2), 0.02, 0.15 * k);
        break;
    }
  }

  /** An animal is struck: a heavy thud scaled by its size, and a cry. */
  animalHit(mass: number, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    const big = clamp(mass / 4.5, 0.05, 1);
    this.tone(dest, 'sine', 110 - big * 50, 30, t, 0.25 + big * 0.4, 0.3 + big * 0.5, 0.003);
    this.burst(dest, 'lowpass', 1200, 200, 0.7, t, 0.15 + big * 0.2, 0.3 + big * 0.4);
    const cry = 1200 - big * 900;
    this.tone(dest, 'sawtooth', cry, cry * 0.6, t + 0.04, 0.25 + big * 0.3, 0.04, 0.01);
  }

  /** A market stall or party gear is flattened: splintering wood and splatting fruit. */
  stallSmash(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('stalls', 0.8, 3)) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    for (let i = 0; i < 10; i++) this.burst(dest, 'bandpass', rand(600, 2400), rand(400, 1200), 4, t + rand(0, 0.25), rand(0.02, 0.06), 0.3);
    for (let i = 0; i < 8; i++) this.burst(dest, 'lowpass', 900, 200, 1, t + rand(0.1, 0.6), 0.05, 0.2);
  }

  /** One beat of party music (kick, hat and a bass note). Call about every half second. */
  partyBeat(pan = 0, volume = 1): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const v = clamp(volume, 0, 1) * 0.35;
    if (v < 0.02 || !this.admit('music', 0.46, 2)) return;
    const dest = this.out(pan, this.ambient);
    this.tone(dest, 'sine', 120, 40, t, 0.18, v, 0.002);
    this.burst(dest, 'highpass', 8000, 8000, 0.5, t + 0.24, 0.04, v * 0.3);
    this.tone(dest, 'square', [55, 55, 65, 49][Math.floor(t * 2) % 4], 55, t, 0.3, v * 0.15, 0.01);
  }

  /** A stinger for each crash tier: from a bump to the apocalypse. */
  dramaTier(tier: number, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx || tier < 2) return;
    if (!this.admit('drama', 4, 2)) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    const k = (tier - 1) / 3;
    // A rising whoosh into the impact and a long sub drop.
    this.burst(dest, 'bandpass', 600, 3000, 1.2, t, 0.35, 0.15 + 0.2 * k, 0.2);
    this.tone(this.crunch, 'sine', 50, 20, t + 0.05, 1 + k * 2.5, 0.4 + 0.5 * k, 0.01);
    if (tier >= 4) {
      for (const [f, at] of [
        [220, 0],
        [207, 0.4],
        [196, 0.8],
      ]) this.tone(dest, 'sawtooth', f, f * 0.98, t + 0.3 + at, 0.6, 0.06, 0.05);
    }
  }

  /** One beat of rave music: four-on-the-floor kick, open hat and an acid stab. Call every ~0.47s. */
  raveBeat(pan = 0, volume = 1): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const v = clamp(volume, 0, 1) * 0.4;
    if (v < 0.02 || !this.admit('music', 0.46, 2)) return;
    const dest = this.out(pan, this.ambient);
    this.tone(dest, 'sine', 140, 38, t, 0.22, v, 0.002);
    this.burst(dest, 'highpass', 9000, 9000, 0.5, t + 0.235, 0.08, v * 0.35);
    const step = Math.floor(t * 2.13) % 8;
    const note = [110, 110, 165, 110, 131, 110, 196, 147][step];
    this.tone(dest, 'sawtooth', note, note, t, 0.2, v * 0.18, 0.005);
  }

  /**
   * About a second of siren from an emergency vehicle. The game calls this for at
   * most two vehicles at a time, with volume falling off with distance.
   */
  siren(kind: string, pan = 0, volume = 1): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const v = clamp(volume, 0, 1) * 0.05;
    if (v < 0.004) return;
    const dest = this.out(pan, this.ambient);
    const osc = ctx.createOscillator();
    osc.type = kind === 'fire' ? 'sawtooth' : 'square';
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(v, t + 0.05);
    g.gain.setValueAtTime(v, t + 0.8);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
    if (kind === 'police') {
      // Fast yelp.
      for (let i = 0; i < 4; i++) {
        osc.frequency.setValueAtTime(700, t + i * 0.22);
        osc.frequency.linearRampToValueAtTime(1500, t + i * 0.22 + 0.2);
      }
    } else if (kind === 'ambulance') {
      // Hi-lo.
      osc.frequency.setValueAtTime(960, t);
      osc.frequency.setValueAtTime(770, t + 0.45);
    } else {
      // Slow wail.
      osc.frequency.setValueAtTime(500, t);
      osc.frequency.linearRampToValueAtTime(900, t + 0.45);
      osc.frequency.linearRampToValueAtTime(500, t + 0.9);
    }
    const lp = createFilter(ctx);
    lp.type = 'lowpass';
    lp.frequency.value = 2400;
    osc.connect(lp).connect(g).connect(dest);
    this.release(osc, dest, lp, g);
    osc.start(t);
    osc.stop(t + 0.95);
  }

  /** The tow crew at work: winch whine and a wrench clank. */
  towWinch(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('crews', 0.7, 3)) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.ambient);
    this.tone(dest, 'sawtooth', 180, 320, t, 0.6, 0.03, 0.05);
    this.tone(dest, 'square', rand(1400, 2200), rand(1000, 1400), t + 0.3, 0.05, 0.05, 0.001);
  }

  /** A vehicle smashing into a building: masonry cracking and crumbling. */
  structureHit(impact: number, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('structures', 2, 4)) return;
    const t = ctx.currentTime;
    const k = clamp(impact / 120, 0.2, 1.3);
    const dest = this.out(pan, this.fx);
    this.burst(dest, 'lowpass', 1500, 120, 0.8, t + 0.02, 0.4 + k * 0.6, 0.4 * k, 0.005);
    for (let i = 0; i < 6 + k * 18; i++) {
      const at = t + 0.05 + Math.pow(Math.random(), 1.4) * (0.4 + k * 0.9);
      this.burst(dest, 'bandpass', rand(250, 1400), rand(150, 700), rand(2, 5), at, rand(0.02, 0.07), rand(0.1, 0.3) * k);
    }
  }

  /** A fire hose blast. */
  hoseSpray(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('crews', 1, 3)) return;
    const t = ctx.currentTime;
    this.burst(this.out(pan, this.ambient), 'highpass', 2500, 3500, 0.6, t, 0.9, 0.08, 0.05);
  }

  /**
   * A missile leaving the rail: the ignition crack, then the rocket motor's roar and
   * hiss dropping in pitch and fading as it streaks away (a Doppler fall).
   */
  missile(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    this.burst(dest, 'highpass', 3000, 1500, 0.8, t, 0.05, 0.4, 0.001);
    this.burst(dest, 'lowpass', 700, 150, 0.8, t, 0.15, 0.4, 0.002);
    this.burst(dest, 'bandpass', 1400, 380, 1.4, t + 0.02, 1.4, 0.3, 0.03);
    this.burst(dest, 'bandpass', 5200, 1800, 2, t + 0.03, 1.2, 0.12, 0.03);
    this.tone(dest, 'sawtooth', 220, 70, t + 0.02, 1.2, 0.04, 0.05);
  }

  /** A missile's warhead: a sharp crack, a punchy boom and a scatter of debris. */
  missileHit(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    this.burst(this.crunch, 'highpass', 2200, 900, 0.7, t, 0.06, 0.8, 0.001);
    this.tone(this.crunch, 'sine', 95, 22, t, 1.4, 0.9, 0.003);
    this.burst(this.crunch, 'lowpass', 1400, 90, 0.8, t + 0.02, 1.6, 0.6, 0.02);
    for (let i = 0; i < 16; i++) {
      const at = t + 0.15 + Math.pow(Math.random(), 1.4) * 1.6;
      this.burst(dest, 'bandpass', rand(900, 4200), rand(500, 1600), rand(2, 6), at, rand(0.01, 0.04), rand(0.06, 0.2) * (1 - (at - t) / 2));
    }
    this.duck(0.9);
  }

  private sirenUntil = 0;

  /** The air-raid sirens: a slow rising and falling wail across the city. */
  airRaid(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    if (t < this.sirenUntil) return;
    this.sirenUntil = t + 12;
    for (const [detune, gain] of [
      [0, 0.05],
      [7, 0.035],
    ]) {
      const osc = ctx.createOscillator();
      osc.type = 'sawtooth';
      const g = ctx.createGain();
      const f = createFilter(ctx);
      f.type = 'lowpass';
      f.frequency.value = 1400;
      osc.frequency.setValueAtTime(180 + detune, t);
      for (let k = 0; k < 3; k++) {
        osc.frequency.linearRampToValueAtTime(620 + detune, t + k * 4 + 2);
        osc.frequency.linearRampToValueAtTime(220 + detune, t + k * 4 + 4);
      }
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(gain, t + 1);
      g.gain.setValueAtTime(gain, t + 10.5);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 12);
      osc.connect(f).connect(g).connect(this.verb);
      this.release(osc, this.verb, f, g);
      osc.start(t);
      osc.stop(t + 12.1);
    }
  }

  /** A surface-to-air missile leaving its rail: a crack and a rushing roar. */
  samLaunch(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    this.burst(dest, 'highpass', 2200, 700, 0.6, t, 0.12, 0.3, 0.001);
    this.burst(dest, 'bandpass', 900, 300, 0.7, t + 0.05, 1.6, 0.22, 0.05);
  }

  private lockNext = 0;

  /** The missile-warning tone while something's tracking the jet (call every frame). */
  missileLock(on: boolean): void {
    const ctx = this.ctx;
    if (!ctx || !on) return;
    const t = ctx.currentTime;
    if (t < this.lockNext) return;
    this.lockNext = t + 0.22;
    this.tone(this.fx, 'square', 1500, 1500, t, 0.1, 0.05, 0.002);
  }

  private gunsNext = 0;

  /** Anti-aircraft guns hammering: a quick string of heavy thuds, fainter far off. */
  aaGuns(pan = 0, distance = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    if (t < this.gunsNext) return;
    this.gunsNext = t + 0.5;
    const dest = this.out(pan, this.ambient);
    const level = Math.max(0.05, 1 - distance / 1700);
    for (let k = 0; k < 5; k++) {
      this.tone(dest, 'sine', 120, 50, t + k * 0.09, 0.12, 0.2 * level, 0.002);
      this.burst(dest, 'lowpass', 1600, 300, 0.7, t + k * 0.09, 0.08, 0.15 * level, 0.001);
    }
  }

  /** Shells and fragments hitting the airframe. */
  jetHit(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.burst(this.crunch, 'bandpass', rand(1500, 4000), rand(700, 1500), 3, t, 0.06, 0.35, 0.001);
    this.tone(this.crunch, 'square', 300, 120, t, 0.06, 0.08, 0.001);
  }

  /** Flares popping off the jet. */
  flares(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    for (let k = 0; k < 4; k++) this.burst(this.fx, 'highpass', 3000, 1200, 0.8, t + k * 0.07, 0.08, 0.18, 0.001);
  }

  /** A jet roaring past overhead: a rising then falling rush and whine. */
  flyby(pan = 0, volume = 1): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.ambient);
    this.burst(dest, 'bandpass', 500, 1400, 0.5, t, 1.4, 0.35 * volume, 0.5);
    this.burst(dest, 'lowpass', 400, 120, 0.7, t + 0.6, 1.8, 0.3 * volume, 0.2);
    this.tone(dest, 'sawtooth', 2400, 1700, t + 0.2, 1.6, 0.03 * volume, 0.4);
  }

  /** The GAU-8 heard from the ground: a tearing BRRRT, late and dulled with distance. */
  brrt(pan = 0, distance = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime + distance / 1040;
    const level = Math.max(0.08, 1 - distance / 1900);
    const dest = this.out(pan, this.ambient);
    const osc = ctx.createOscillator();
    osc.type = 'square';
    osc.frequency.value = 65;
    const f = createFilter(ctx);
    f.type = 'lowpass';
    f.frequency.value = 900 - distance * 0.3;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(0.25 * level, t + 0.05);
    g.gain.setValueAtTime(0.25 * level, t + 0.7);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
    osc.connect(f).connect(g).connect(dest);
    this.release(osc, dest, f, g);
    osc.start(t);
    osc.stop(t + 0.95);
    this.burst(dest, 'bandpass', 1800, 900, 1, t, 0.85, 0.12 * level, 0.03);
  }

  /** The ballute popping out of its can and slamming full of air. */
  ballute(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.burst(this.fx, 'lowpass', 900, 200, 0.7, t, 0.25, 0.22, 0.005);
    this.tone(this.fx, 'sine', 120, 60, t + 0.02, 0.18, 0.12, 0.004);
  }

  /** Ordnance clunking onto the racks. */
  rearmed(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    for (let i = 0; i < 4; i++) {
      this.tone(this.fx, 'square', 140, 80, t + i * 0.14, 0.08, 0.12, 0.002);
      this.burst(this.fx, 'bandpass', 2600, 1800, 2, t + i * 0.14, 0.05, 0.08, 0.001);
    }
  }

  /** The gun overheats: the barrels whir down and the breech hisses. */
  gunOverheat(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    this.tone(this.fx, 'sawtooth', 140, 18, t, 1.3, 0.08, 0.01);
    this.burst(this.fx, 'highpass', 4500, 3000, 0.6, t + 0.1, 1.6, 0.12, 0.05);
    this.tone(this.fx, 'square', 2400, 2100, t + 0.05, 0.04, 0.05, 0.001);
  }

  private buildJet(ctx: AudioContext): Jet {
    const out = ctx.createGain();
    out.gain.value = 0;
    out.connect(this.drive);
    const noiseSrc = (rate: number) => {
      const src = ctx.createBufferSource();
      src.buffer = this.noise;
      src.loop = true;
      src.playbackRate.value = rate;
      src.start(0, Math.random());
      return src;
    };
    // Deep rumble.
    const rumbleTone = createFilter(ctx);
    rumbleTone.type = 'lowpass';
    rumbleTone.frequency.value = 140;
    const rumble = ctx.createGain();
    noiseSrc(0.5).connect(rumbleTone).connect(rumble).connect(out);
    // The broad roar of the exhaust.
    const roarTone = createFilter(ctx);
    roarTone.type = 'bandpass';
    roarTone.frequency.value = 700;
    roarTone.Q.value = 0.5;
    const roar = ctx.createGain();
    noiseSrc(1).connect(roarTone).connect(roar).connect(out);
    // The fan's whine: two tones a few hertz apart beat into a hollow, wavering whistle.
    const whine = ctx.createGain();
    whine.gain.value = 0;
    const whineA = ctx.createOscillator();
    whineA.type = 'sine';
    const whineB = ctx.createOscillator();
    whineB.type = 'triangle';
    const bTone = ctx.createGain();
    bTone.gain.value = 0.5;
    whineA.connect(whine);
    whineB.connect(bTone).connect(whine);
    whine.connect(out);
    whineA.start();
    whineB.start();
    // Air rushing over the airframe.
    const airTone = createFilter(ctx);
    airTone.type = 'highpass';
    airTone.frequency.value = 3000;
    const air = ctx.createGain();
    noiseSrc(1.3).connect(airTone).connect(air).connect(out);
    return { out, rumble, roar, roarTone, whineA, whineB, whine, air, airTone };
  }

  /** The jet, once per frame: throttle 0..1. The turbines lag the throttle as they spool. */
  private jetUpdate(throttle: number, active: boolean): void {
    const ctx = this.ctx!;
    if (!active && !this.jetVoice) return;
    this.jetVoice ??= this.buildJet(ctx);
    const j = this.jetVoice;
    const t = ctx.currentTime;
    const spool = 0.8;
    const r = throttle;
    const rush = this.flightSpeed * 0.5 + Math.abs(this.flightBank) * this.flightSpeed * 0.9;
    j.out.gain.setTargetAtTime(active ? 1 : 0, t, active ? 0.3 : 0.2);
    j.rumble.gain.setTargetAtTime(0.35 + r * 0.25, t, spool);
    j.roar.gain.setTargetAtTime(0.1 + r * 0.16, t, spool);
    j.roarTone.frequency.setTargetAtTime(500 + r * 700, t, spool);
    j.whineA.frequency.setTargetAtTime(2600 + r * 2200, t, spool);
    j.whineB.frequency.setTargetAtTime(2600 + r * 2200 + 6 + r * 5, t, spool);
    j.whine.gain.setTargetAtTime(0.012 + r * 0.028, t, spool);
    j.air.gain.setTargetAtTime(0.02 + rush * 0.09, t, 0.25);
    j.airTone.frequency.setTargetAtTime(2400 + rush * 2400, t, 0.25);
    // Slamming the throttle open: a rising whoosh as the engines spool up.
    if (active && r >= 0.75 && this.lastThrottle < 0.75) {
      const src = ctx.createBufferSource();
      src.buffer = this.noise;
      const bp = createFilter(ctx);
      bp.type = 'bandpass';
      bp.Q.value = 1.2;
      bp.frequency.setValueAtTime(500, t);
      bp.frequency.exponentialRampToValueAtTime(3500, t + 1.4);
      const g = ctx.createGain();
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(0.12, t + 0.8);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6);
      src.connect(bp).connect(g).connect(this.drive);
      this.release(src, this.drive, bp, g);
      src.start(t, Math.random());
      src.stop(t + 1.7);
    }
    this.lastThrottle = r;
  }

  private buildGun(ctx: AudioContext): Gun {
    const out = ctx.createGain();
    out.gain.value = 0;
    const shaper = ctx.createWaveShaper();
    shaper.curve = this.driveCurve(2.5);
    shaper.oversample = '2x';
    const gunLevel = ctx.createGain();
    gunLevel.gain.value = 0.5;
    out.connect(shaper).connect(gunLevel).connect(this.fx);
    const send = ctx.createGain();
    send.gain.value = 0.12;
    shaper.connect(send).connect(this.verb);
    // The buzz: one pulse per round, a sawtooth at the firing rate, through a low-pass.
    const buzz = ctx.createOscillator();
    buzz.type = 'sawtooth';
    buzz.frequency.value = 40;
    const buzzTone = createFilter(ctx);
    buzzTone.type = 'lowpass';
    buzzTone.frequency.value = 1400;
    const buzzGain = ctx.createGain();
    buzzGain.gain.value = 0.55;
    buzz.connect(buzzTone).connect(buzzGain).connect(out);
    // The roar: broadband noise chopped at the same rate (each round's report).
    const noise = ctx.createBufferSource();
    noise.buffer = this.noise;
    noise.loop = true;
    const roarTone = createFilter(ctx);
    roarTone.type = 'bandpass';
    roarTone.frequency.value = 650;
    roarTone.Q.value = 0.6;
    const roar = ctx.createGain();
    roar.gain.value = 0.5;
    const chop = ctx.createOscillator();
    chop.type = 'square';
    chop.frequency.value = 40;
    const chopDepth = ctx.createGain();
    chopDepth.gain.value = 0.45;
    chop.connect(chopDepth).connect(roar.gain);
    noise.connect(roarTone).connect(roar).connect(out);
    // The shells landing: a sharper crackle that starts and stops a beat after the gun.
    const crackleNoise = ctx.createBufferSource();
    crackleNoise.buffer = this.noise;
    crackleNoise.loop = true;
    crackleNoise.playbackRate.value = 0.7;
    const crackleTone = createFilter(ctx);
    crackleTone.type = 'bandpass';
    crackleTone.frequency.value = 2200;
    crackleTone.Q.value = 1.2;
    const crackle = ctx.createGain();
    crackle.gain.value = 0;
    crackleNoise.connect(crackleTone).connect(crackle).connect(this.fx);
    for (const node of [buzz, noise, chop, crackleNoise]) node.start();
    return { buzz, chop, roar, crackle, out };
  }

  /**
   * The A-10's GAU-8 Avenger, once per frame. It fires 65 rounds a second once the
   * barrels are spun up (fewer as they spin up), so it isn't a rattle but one tearing
   * BRRRRT: the pitch of the buzz is the firing rate itself.
   */
  gunUpdate(firing: boolean, rate: number): void {
    const ctx = this.ctx;
    if (!ctx || (!firing && !this.gunVoice)) return;
    this.gunVoice ??= this.buildGun(ctx);
    const g = this.gunVoice;
    const t = ctx.currentTime;
    if (firing) {
      g.buzz.frequency.setTargetAtTime(rate, t, 0.08);
      g.chop.frequency.setTargetAtTime(rate, t, 0.08);
      g.out.gain.setTargetAtTime(0.42, t, this.gunOn ? 0.05 : 0.02);
      if (!this.gunOn) {
        holdGain(g.crackle.gain, t);
        g.crackle.gain.setTargetAtTime(0.09, t + 0.25, 0.08);
      }
    } else if (this.gunOn) {
      g.out.gain.setTargetAtTime(0, t, 0.06);
      g.buzz.frequency.setTargetAtTime(25, t, 0.2);
      holdGain(g.crackle.gain, t);
      g.crackle.gain.setTargetAtTime(0, t + 0.3, 0.1);
    }
    this.gunOn = firing;
  }

  /**
   * A bomb going off: the crack of the blast wave, a deep boom you feel, a long rolling
   * rumble, then debris raining down and secondary pops.
   */
  bigBlast(pan = 0, size = 1): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('bombs', 12, 2)) return;
    const t = ctx.currentTime;
    const s = clamp(size, 0.6, 2.5);
    const dest = this.out(pan, this.fx);
    this.burst(this.crunch, 'highpass', 2500, 800, 0.7, t, 0.09, 1.1, 0.001);
    this.tone(this.crunch, 'sine', 70, 14, t, 3.2 * s, 1.2, 0.004);
    this.tone(this.crunch, 'sine', 42, 10, t + 0.04, 4 * s, 1, 0.02);
    this.burst(this.crunch, 'lowpass', 900, 60, 0.9, t + 0.05, 4.5 * s, 0.9, 0.05);
    this.burst(dest, 'lowpass', 300, 80, 0.7, t + 0.6, 3.5 * s, 0.35, 0.4);
    // The boom rolling away across the city.
    this.burst(this.verb, 'lowpass', 500, 70, 0.7, t + 0.1, 3 * s, 0.6, 0.05);
    this.tone(this.crunch, 'sine', 30, 9, t, 1.2, 0.8, 0.002);
    for (let i = 0; i < 45 * s; i++) {
      const at = t + 0.4 + Math.pow(Math.random(), 1.3) * 3.5 * s;
      const fade = 1 - (at - t) / (4.5 * s);
      this.burst(dest, 'bandpass', rand(600, 4000), rand(300, 1500), rand(2, 6), at, rand(0.01, 0.05), rand(0.08, 0.28) * fade);
    }
    for (let i = 0; i < 3; i++) {
      const at = t + rand(0.5, 2.5);
      this.burst(this.crunch, 'lowpass', 1600, 100, 0.8, at, 0.5, 0.35, 0.005);
    }
    this.duck(2.2);
  }

  /**
   * An explosion or a collapse, heard from a distance (map units). Kept simple and
   * punchy: a noise burst whose filter sweeps down from bright to dark, and a sub-bass
   * thump that falls in pitch (plus a crack when it's close). Sound takes its time to
   * arrive (about a second per 1,000 units), and distance dulls it and adds echo.
   */
  blast(kind: BlastKind, pan: number, distance: number): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('world-blasts', 4.5, 8)) return;
    const t = ctx.currentTime + distance / 1040;
    // Everything goes through one distance stage: muffle, level, position, echo.
    const muffle = createFilter(ctx);
    muffle.type = 'lowpass';
    muffle.frequency.value = clamp(16000 * Math.exp(-distance / 380), 400, 16000);
    const level = ctx.createGain();
    level.gain.value = 1 / (1 + distance / 320);
    const send = ctx.createGain();
    send.gain.value = 0.2 + Math.min(0.6, distance / 700);
    const position = this.out(pan, level);
    muffle.connect(position);
    this.transient.set(muffle, { users: 0, nodes: [muffle, position, level, send] });
    level.connect(distance < 250 ? this.crunch : this.fx);
    level.connect(send).connect(this.verb);

    if (kind === 'collapse-wood' || kind === 'collapse-stone') {
      // A long dark rumble with a few heavy thuds as it comes down.
      const stone = kind === 'collapse-stone';
      this.noiseSweep(muffle, t, stone ? 500 : 800, 120, 0.15, 2.6, 0.7);
      for (const [at, f] of [[0, stone ? 48 : 62], [0.35, stone ? 40 : 55], [0.8, stone ? 34 : 48]] as [number, number][]) {
        this.tone(muffle, 'sine', f, f * 0.6, t + at, 0.35, 0.55, 0.004);
      }
      for (let i = 0; i < 8; i++) this.burst(muffle, 'bandpass', stone ? rand(500, 1500) : rand(700, 2200), 400, 3, t + rand(0.05, 1.6), 0.03, 0.12);
      if (distance < 400) this.duck(0.8);
      return;
    }
    const size = kind === 'huge' ? 2.2 : kind === 'medium' ? 1.3 : 0.8;
    const dur = 0.7 + size * 0.9;
    // The burst: bright to dark.
    this.noiseSweep(muffle, t, 3200, 110, 0.005, dur, 0.9 * Math.min(1.4, size));
    // The thump.
    this.tone(muffle, 'sine', 50 + 30 / size, 22 + 8 / size, t, 0.35 + size * 0.35, 1.1 * Math.min(1.3, size), 0.003);
    // The crack, only when it's close enough to hear it.
    if (distance < 300) this.burst(muffle, 'highpass', 2500, 1200, 0.7, t, 0.04, 0.7, 0.001);
    if (distance < 400) this.duck(0.5 + size * 0.5);
  }

  /** Noise through a low-pass that sweeps from f0 down to f1, with a quick rise and a long fade. */
  private noiseSweep(dest: AudioNode, t: number, f0: number, f1: number, attack: number, dur: number, peak: number): void {
    const ctx = this.ctx!;
    const src = ctx.createBufferSource();
    src.buffer = this.noise;
    const lp = createFilter(ctx);
    lp.type = 'lowpass';
    lp.frequency.setValueAtTime(f0, t);
    lp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.7);
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(peak, t + attack);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    src.connect(lp).connect(g).connect(dest);
    this.release(src, dest, lp, g);
    src.start(t, rand(0, 1));
    src.stop(t + dur + 0.05);
  }

  /** The steady roar of the fires around the listener, 0 (none) to 1 (an inferno). */
  fireLevel(level: number): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.fireVoice && level <= 0) return;
    if (!this.fireVoice) {
      const src = ctx.createBufferSource();
      src.buffer = this.noise;
      src.loop = true;
      src.playbackRate.value = 0.5;
      const lp = createFilter(ctx);
      lp.type = 'lowpass';
      lp.frequency.value = 520;
      const g = ctx.createGain();
      g.gain.value = 0;
      src.connect(lp).connect(g).connect(this.ambient);
      src.start();
      this.fireVoice = g;
    }
    this.fireVoice.gain.setTargetAtTime(clamp(level, 0, 1) * 0.22, ctx.currentTime, 0.5);
  }

  /** A big fire nearby: a low roar and the crack and pop of burning timber. */
  fireCrackle(volume: number): void {
    const ctx = this.ctx;
    if (!ctx || volume < 0.05) return;
    const t = ctx.currentTime;
    this.burst(this.ambient, 'lowpass', 380, 200, 0.6, t, 0.3, 0.12 * volume, 0.08);
    for (let i = 0; i < 3 + Math.random() * 5; i++) {
      this.burst(this.ambient, 'bandpass', rand(1200, 4500), rand(800, 2000), rand(3, 9), t + Math.random() * 0.25, rand(0.005, 0.02), rand(0.05, 0.2) * volume);
    }
  }

  /** A bomb coming off the rack (a heavy clunk) and whistling down, louder as it falls. */
  bombDrop(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    this.burst(dest, 'lowpass', 500, 180, 0.8, t, 0.08, 0.35, 0.002);
    this.tone(dest, 'square', 95, 60, t, 0.07, 0.12, 0.002);
    const osc = ctx.createOscillator();
    osc.type = 'sine';
    osc.frequency.setValueAtTime(1700, t + 0.05);
    osc.frequency.exponentialRampToValueAtTime(420, t + 1);
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t + 0.05);
    g.gain.exponentialRampToValueAtTime(0.06, t + 0.95);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 1.02);
    osc.connect(g).connect(dest);
    this.release(osc, dest, g);
    osc.start(t + 0.05);
    osc.stop(t + 1.05);
  }

  /** One thump of a helicopter rotor. */
  rotor(pan = 0, volume = 1): void {
    const ctx = this.ctx;
    if (!ctx || volume < 0.03) return;
    if (!this.admit('rotors', 0.12, 3)) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.ambient);
    this.burst(dest, 'lowpass', 260, 90, 1.2, t, 0.09, 0.22 * volume, 0.004);
    this.tone(dest, 'sine', 70, 48, t, 0.08, 0.08 * volume, 0.004);
  }

  /** A crew at work: wrench clanks or a jackhammer rattle. */
  tools(pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.admit('crews', 0.6, 3)) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.ambient);
    if (Math.random() < 0.5) {
      for (let i = 0; i < 2; i++) this.tone(dest, 'square', rand(1800, 2600), rand(1200, 1600), t + i * 0.14, 0.05, 0.04, 0.001);
    } else {
      for (let i = 0; i < 10; i++) this.burst(dest, 'bandpass', rand(500, 900), rand(300, 600), 3, t + i * 0.05, 0.035, 0.09, 0.001);
    }
  }

  /** A lamp post, hydrant or trash can knocked flying. */
  knock(kind: string, pan = 0): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const dest = this.out(pan, this.fx);
    if (kind === 'lamp') {
      this.tone(dest, 'triangle', 420, 380, t, 0.9, 0.1, 0.002);
      this.tone(dest, 'triangle', 1130, 1080, t, 0.6, 0.05, 0.002);
      this.burst(dest, 'highpass', 4000, 6000, 0.7, t + 0.35, 0.25, 0.12);
    } else if (kind === 'hydrant') {
      this.tone(dest, 'square', 220, 140, t, 0.12, 0.12, 0.002);
      this.burst(dest, 'highpass', 1800, 2600, 0.5, t + 0.05, 1.4, 0.1, 0.05);
    } else {
      this.tone(dest, 'triangle', 260, 170, t, 0.2, 0.14, 0.002);
      this.burst(dest, 'bandpass', 900, 500, 1.5, t, 0.25, 0.12);
    }
  }
}

/** Shared instance. Calls are no-ops until unlock() runs from a user gesture. */
export const sfx = new Sfx();