Back to Pursuit PIT
SOURCE / PINNED RELEASE

Made of little things.

Pursuit PIT

Release
565f4981bdc3…
Author-recorded commit
197afdbed44b…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7

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

src/game/audio.ts
// All sound is synthesized with Web Audio (napplets have no ambient network access,
// so there are no sample files). Sounds from outside the car go through a "cabin" low-pass
// so they sound muffled from the driver's seat.
import { forwardSpeed, slipSpeed, type Car, type Sim, type SimEvent } from './sim.js';
import type { Gearbox } from './engine.js';

const CYLINDERS = 8;

function smooth(param: AudioParam, value: number, ctx: AudioContext, time = 0.05): void {
  param.setTargetAtTime(value, ctx.currentTime, time);
}

/** Where the sound is heard from: the player's car, or the replay camera. */
interface Listener {
  x: number;
  s: number;
  yaw: number;
}

/** Position of a world point relative to the listener: lateral (right +) and distance. */
function relative(player: Listener, x: number, s: number): { lat: number; dist: number } {
  const dx = x - player.x, ds = s - player.s;
  return { lat: dx * Math.cos(player.yaw) - ds * Math.sin(player.yaw), dist: Math.hypot(dx, ds) };
}

export class Sound {
  volume = 0.7;
  muted = false;
  siren = true;
  /**
   * Getaway: distance to the nearest police car, whose siren is heard from outside (louder as
   * it closes, yelping when it's right on you). Null when no one is chasing you.
   */
  sirenDistance: number | null = null;
  private stereoOut: GainNode | null = null;
  private ctx?: AudioContext;
  private master!: GainNode;
  private cabin!: BiquadFilterNode;
  private noise!: AudioBuffer;
  private engine!: { osc: OscillatorNode[]; filter: BiquadFilterNode; gain: GainNode; burble: OscillatorNode; intake: BiquadFilterNode; intakeGain: GainNode };
  private road!: { filter: BiquadFilterNode; gain: GainNode };
  private wind!: { filter: BiquadFilterNode; gain: GainNode };
  private screech!: { tone: OscillatorNode; gain: GainNode };
  private scrape!: { gain: GainNode; grind: OscillatorNode };
  private rumble!: { osc: OscillatorNode; gain: GainNode };
  private traffic!: { filter: BiquadFilterNode; pan: StereoPannerNode; gain: GainNode };
  private sirenNodes!: { osc: OscillatorNode; gain: GainNode };
  private sirenPhase = 0;
  private hornCooldown = new Map<number, number>();
  private radioBus!: GainNode;

  /** Must be called from a user gesture (browsers block audio until then). */
  start(): void {
    if (this.ctx) {
      void this.ctx.resume();
      return;
    }
    const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
    if (!Ctx) return;
    const ctx = new Ctx();
    this.ctx = ctx;

    const comp = ctx.createDynamicsCompressor();
    comp.threshold.value = -14;
    comp.ratio.value = 4;
    this.master = ctx.createGain();
    this.master.gain.value = this.muted ? 0 : this.volume;
    this.master.connect(comp).connect(ctx.destination);
    this.radioBus = ctx.createGain();
    this.radioBus.gain.value = this.muted ? 0 : Math.min(1.6, this.volume * 1.8);
    this.radioBus.connect(ctx.destination);
    this.cabin = ctx.createBiquadFilter();
    this.cabin.type = 'lowpass';
    this.cabin.frequency.value = 2600;
    this.cabin.connect(this.master);

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

    this.buildEngine(ctx);
    this.buildLoops(ctx);
  }

  suspend(): void {
    void this.ctx?.suspend();
  }

  setVolume(volume: number, muted: boolean): void {
    this.volume = volume;
    this.muted = muted;
    this.applyLevels();
  }

  private applyLevels(): void {
    if (!this.ctx) return;
    smooth(this.master.gain, this.muted ? 0 : this.volume, this.ctx, 0.12);
    // The radio's squelch, static and tones sit louder than the game, on their own bus.
    smooth(this.radioBus.gain, this.muted ? 0 : Math.min(1.6, this.volume * 1.8), this.ctx, 0.05);
  }

  private noiseSource(ctx: AudioContext): AudioBufferSourceNode {
    const src = ctx.createBufferSource();
    src.buffer = this.noise;
    src.loop = true;
    src.loopStart = Math.random();
    src.start(0, Math.random() * 1.5);
    return src;
  }

  private buildEngine(ctx: AudioContext): void {
    // A V8 fires 4 times per crank revolution. Harmonics through soft clipping and a
    // load-dependent low-pass give the growl; a slow beat between banks gives the burble.
    const mix = ctx.createGain();
    mix.gain.value = 0.5;
    const osc = ([['sawtooth', 1, 0.6], ['square', 0.5, 0.35], ['sawtooth', 2, 0.12], ['triangle', 0.25, 0.5]] as const).map(([type, , level]) => {
      const o = ctx.createOscillator();
      o.type = type;
      const g = ctx.createGain();
      g.gain.value = level;
      o.connect(g).connect(mix);
      o.start();
      return o;
    });
    osc[2].detune.value = 9;
    const shaper = ctx.createWaveShaper();
    const curve = new Float32Array(1024);
    for (let i = 0; i < curve.length; i++) {
      const x = (i / (curve.length - 1)) * 2 - 1;
      curve[i] = Math.tanh(x * 2.6);
    }
    shaper.curve = curve;
    const filter = ctx.createBiquadFilter();
    filter.type = 'lowpass';
    filter.Q.value = 2.5;
    const am = ctx.createGain();
    am.gain.value = 0.75;
    const burble = ctx.createOscillator();
    burble.type = 'sine';
    const burbleDepth = ctx.createGain();
    burbleDepth.gain.value = 0.25;
    burble.connect(burbleDepth).connect(am.gain);
    burble.start();
    const gain = ctx.createGain();
    gain.gain.value = 0;
    mix.connect(shaper).connect(filter).connect(am).connect(gain).connect(this.master);

    const intake = ctx.createBiquadFilter();
    intake.type = 'bandpass';
    intake.Q.value = 1.2;
    const intakeGain = ctx.createGain();
    intakeGain.gain.value = 0;
    this.noiseSource(ctx).connect(intake).connect(intakeGain).connect(this.master);
    this.engine = { osc, filter, gain, burble, intake, intakeGain };
  }

  private buildLoops(ctx: AudioContext): void {
    const chain = (type: BiquadFilterType, freq: number, q: number, dest: AudioNode) => {
      const filter = ctx.createBiquadFilter();
      filter.type = type;
      filter.frequency.value = freq;
      filter.Q.value = q;
      const gain = ctx.createGain();
      gain.gain.value = 0;
      this.noiseSource(ctx).connect(filter).connect(gain).connect(dest);
      return { filter, gain };
    };
    // Tyre roar on asphalt: low, broadband, rising with speed.
    this.road = chain('lowpass', 300, 0.7, this.master);
    // Wind rush around the A-pillars.
    this.wind = chain('bandpass', 900, 0.5, this.cabin);

    // Tyre squeal: narrow-band noise plus a wavering tone.
    const sq = chain('bandpass', 1500, 9, this.master);
    const tone = ctx.createOscillator();
    tone.type = 'triangle';
    tone.frequency.value = 1050;
    const vib = ctx.createOscillator();
    vib.frequency.value = 11;
    const vibDepth = ctx.createGain();
    vibDepth.gain.value = 45;
    vib.connect(vibDepth).connect(tone.frequency);
    const toneGain = ctx.createGain();
    toneGain.gain.value = 0.25;
    tone.connect(toneGain).connect(sq.gain);
    tone.start();
    vib.start();
    this.screech = { tone, gain: sq.gain };

    // Metal on guardrail: bright scrape noise plus a low grinding buzz.
    const sc = chain('bandpass', 2800, 2.5, this.master);
    const grind = ctx.createOscillator();
    grind.type = 'sawtooth';
    grind.frequency.value = 140;
    const grindGain = ctx.createGain();
    grindGain.gain.value = 0.3;
    grind.connect(grindGain).connect(sc.gain);
    grind.start();
    this.scrape = { gain: sc.gain, grind };

    // Rumble strip: tyres drumming over grooves every ~30 cm.
    const rOsc = ctx.createOscillator();
    rOsc.type = 'square';
    const rFilter = ctx.createBiquadFilter();
    rFilter.type = 'lowpass';
    rFilter.frequency.value = 380;
    const rGain = ctx.createGain();
    rGain.gain.value = 0;
    rOsc.connect(rFilter).connect(rGain).connect(this.master);
    rOsc.start();
    this.rumble = { osc: rOsc, gain: rGain };

    // Nearby traffic: tyre and wind noise from the cars around you, panned to their side.
    const tFilter = ctx.createBiquadFilter();
    tFilter.type = 'bandpass';
    tFilter.Q.value = 0.8;
    tFilter.frequency.value = 500;
    const tPan = ctx.createStereoPanner();
    const tGain = ctx.createGain();
    tGain.gain.value = 0;
    this.noiseSource(ctx).connect(tFilter).connect(tPan).connect(tGain).connect(this.cabin);
    this.traffic = { filter: tFilter, pan: tPan, gain: tGain };

    // Roof siren: a wail, switching to a fast yelp when the suspect is close.
    const sOsc = ctx.createOscillator();
    sOsc.type = 'sawtooth';
    const sFilter = ctx.createBiquadFilter();
    sFilter.type = 'lowpass';
    sFilter.frequency.value = 2400;
    const sGain = ctx.createGain();
    sGain.gain.value = 0;
    sOsc.connect(sFilter).connect(sGain).connect(this.cabin);
    sOsc.start();
    this.sirenNodes = { osc: sOsc, gain: sGain };
  }

  update(sim: Sim, gearbox: Gearbox, dt: number, active: boolean): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const p = sim.player;
    const speed = Math.abs(forwardSpeed(p));

    // Engine
    const fire = (gearbox.rpm / 60) * (CYLINDERS / 2);
    const mults = [1, 0.5, 2, 0.25];
    this.engine.osc.forEach((o, i) => smooth(o.frequency, fire * mults[i], ctx, 0.03));
    smooth(this.engine.burble.frequency, fire / 8, ctx, 0.05);
    smooth(this.engine.filter.frequency, 220 + gearbox.load * 1600 + gearbox.rpm * 0.25, ctx, 0.04);
    smooth(this.engine.gain.gain, 0.16 + gearbox.load * 0.22, ctx, 0.05);
    smooth(this.engine.intake.frequency, 300 + gearbox.rpm * 0.35, ctx, 0.05);
    smooth(this.engine.intakeGain.gain, gearbox.load * 0.05 * (gearbox.rpm / 6000), ctx, 0.05);

    // Road and wind
    smooth(this.road.filter.frequency, 140 + speed * 14, ctx);
    smooth(this.road.gain.gain, Math.min(0.5, speed / 55) * 0.55, ctx);
    smooth(this.wind.filter.frequency, 500 + speed * 22, ctx);
    smooth(this.wind.gain.gain, Math.min(1, (speed / 55) ** 2) * 0.35, ctx);

    // Squeal from the player's sideways slip, plus any car sliding nearby.
    let squeal = Math.min(1, Math.max(0, Math.abs(slipSpeed(p)) - 1.2) / 4) * Math.min(1, speed / 6);
    for (const car of sim.cars) {
      if (car === p || (car.slide <= 0 && !car.wrecked)) continue;
      const moving = Math.hypot(car.vx, car.vs);
      if (moving < 3) continue;
      const { dist } = relative(p, car.x, car.s);
      squeal = Math.max(squeal, Math.min(1, Math.abs(slipSpeed(car)) / 5) * (12 / (12 + dist)));
    }
    smooth(this.screech.gain.gain, squeal * 0.35, ctx, 0.04);
    smooth(this.screech.tone.frequency, 950 + squeal * 250, ctx, 0.1);

    smooth(this.scrape.gain.gain, Math.min(1, sim.scrape / 12) * 0.5, ctx, 0.03);
    smooth(this.scrape.grind.frequency, 90 + sim.scrape * 6, ctx, 0.05);
    smooth(this.rumble.gain.gain, sim.rumble && speed > 3 ? 0.22 : 0, ctx, 0.02);
    smooth(this.rumble.osc.frequency, Math.max(20, speed / 0.3), ctx, 0.03);

    // Traffic around the player
    let loud = 0, panSum = 0, closing = 0;
    for (const car of sim.cars) {
      if (car.kind === 'player') continue;
      const { lat, dist } = relative(p, car.x, car.s);
      if (dist > 40) continue;
      const own = Math.hypot(car.vx, car.vs);
      const rel = Math.hypot(car.vx - p.vx, car.vs - p.vs);
      const l = (own * 0.4 + rel) / (1 + (dist * dist) / 20);
      loud += l;
      panSum += Math.max(-1, Math.min(1, lat / 4)) * l;
      closing = Math.max(closing, rel);
    }
    smooth(this.traffic.gain.gain, Math.min(0.5, loud * 0.03), ctx, 0.08);
    smooth(this.traffic.pan.pan, loud > 0 ? panSum / loud : 0, ctx, 0.08);
    smooth(this.traffic.filter.frequency, 350 + closing * 25, ctx, 0.08);

    // Siren
    const chased = this.sirenDistance !== null;
    const d = this.sirenDistance ?? 0;
    const on = active && (this.siren || (chased && d < 450));
    const yelp = on && (chased ? d < 45 : sim.gap < 45 && sim.gap > -20);
    this.sirenPhase += dt / (yelp ? 0.32 : 4.6);
    const sweep = 0.5 - 0.5 * Math.cos(this.sirenPhase * Math.PI * 2);
    smooth(this.sirenNodes.osc.frequency, 650 + sweep * 850, ctx, 0.02);
    const level = chased ? 0.1 * Math.max(0, 1 - d / 450) ** 1.5 : 0.07;
    smooth(this.sirenNodes.gain.gain, on ? level : 0, ctx, 0.1);

    for (const [id, t] of this.hornCooldown) {
      if (t - dt <= 0) this.hornCooldown.delete(id);
      else this.hornCooldown.set(id, t - dt);
    }
  }

  /** The car stereo's output: through the cabin, like the rest of the inside sounds. */
  stereo(): { ctx: AudioContext; out: AudioNode } | null {
    const ctx = this.ctx;
    if (!ctx) return null;
    if (!this.stereoOut) {
      this.stereoOut = ctx.createGain();
      this.stereoOut.gain.value = 1.3; // turned up: it has to carry over the engine and wind
      const speakers = ctx.createBiquadFilter();
      speakers.type = 'lowpass';
      speakers.frequency.value = 7000;
      this.stereoOut.connect(speakers).connect(this.master);
    }
    return { ctx, out: this.stereoOut };
  }

  /** Mute the continuous sounds (engine, road, siren...) while a replay plays. */
  silence(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    for (const g of [this.engine.gain, this.engine.intakeGain, this.road.gain, this.wind.gain, this.screech.gain,
      this.scrape.gain, this.rumble.gain, this.traffic.gain, this.sirenNodes.gain]) smooth(g.gain, 0, ctx, 0.15);
  }

  /** One-shot sounds for collisions; traffic you hit sometimes leans on the horn. */
  event(sim: { player: Listener }, e: SimEvent): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (e.type === 'damage' || e.type === 'birdsWarn' || e.type === 'birds') return; // the collision itself already made the sound
    if (e.type === 'bird') {
      // A soft thump, loud only when it's your own car.
      const { lat, dist } = relative(sim.player, e.x, e.s);
      this.impact(2.5, Math.max(-1, Math.min(1, lat / 3)), 1 / (1 + dist / 6), false);
      return;
    }
    const { lat, dist } = relative(sim.player, e.x, e.s);
    const pan = Math.max(-1, Math.min(1, lat / 3));
    const near = 1 / (1 + dist / 15);
    if (e.type === 'explode') {
      this.explosion(pan, 1 / (1 + dist / 40));
      return;
    }
    if (e.type === 'wall') this.impact(e.strength * 0.7, pan, near, true);
    else if (e.type === 'crash') this.impact(Math.max(6, e.strength * 0.5), pan, near, false);
    else this.impact(e.strength, pan, near, false);
    const car = e.other;
    if (car && car.kind === 'traffic' && !car.wrecked && Math.random() < 0.75) {
      setTimeout(() => this.horn(sim, car), 250 + Math.random() * 400);
    }
  }

  /** Called when the player cuts close in front of traffic. */
  nearMiss(sim: Sim, car: Car): void {
    if (Math.random() < 0.6) this.horn(sim, car);
  }

  private horn(sim: { player: Listener }, car: Car): void {
    const ctx = this.ctx;
    if (!ctx || this.hornCooldown.has(car.id)) return;
    this.hornCooldown.set(car.id, 4);
    const { lat, dist } = relative(sim.player, car.x, car.s);
    const t = ctx.currentTime;
    const length = 0.25 + Math.random() * (Math.random() < 0.4 ? 1.2 : 0.3);
    const pitch = 0.85 + Math.random() * 0.35; // every car's horn is a bit different
    const out = ctx.createGain();
    out.gain.setValueAtTime(0, t);
    out.gain.linearRampToValueAtTime(0.16 / (1 + dist / 12), t + 0.02);
    out.gain.setValueAtTime(0.16 / (1 + dist / 12), t + length);
    out.gain.linearRampToValueAtTime(0, t + length + 0.05);
    const filter = ctx.createBiquadFilter();
    filter.type = 'lowpass';
    filter.frequency.value = 1800;
    const pan = ctx.createStereoPanner();
    pan.pan.value = Math.max(-1, Math.min(1, lat / 3));
    for (const f of [415, 520]) {
      const o = ctx.createOscillator();
      o.type = 'square';
      o.frequency.value = f * pitch;
      o.connect(filter);
      o.start(t);
      o.stop(t + length + 0.1);
    }
    filter.connect(out).connect(pan).connect(this.cabin);
  }

  /** A fuel-tank explosion: deep boom, a long rumbling tail, and crackling debris. */
  private explosion(pan: number, near: number): void {
    const ctx = this.ctx!;
    const t = ctx.currentTime;
    const panner = ctx.createStereoPanner();
    panner.pan.value = pan * 0.6;
    panner.connect(this.master);
    const boom = ctx.createOscillator();
    boom.frequency.setValueAtTime(70, t);
    boom.frequency.exponentialRampToValueAtTime(22, t + 1.2);
    const bg = ctx.createGain();
    bg.gain.setValueAtTime(near * 1.2, t);
    bg.gain.exponentialRampToValueAtTime(0.001, t + 1.4);
    boom.connect(bg).connect(panner);
    boom.start(t);
    boom.stop(t + 1.5);
    const src = ctx.createBufferSource();
    src.buffer = this.noise;
    src.loop = true;
    const low = ctx.createBiquadFilter();
    low.type = 'lowpass';
    low.frequency.setValueAtTime(1800, t);
    low.frequency.exponentialRampToValueAtTime(120, t + 2.5);
    const ng = ctx.createGain();
    ng.gain.setValueAtTime(near * 1.0, t);
    ng.gain.exponentialRampToValueAtTime(0.001, t + 3);
    src.connect(low).connect(ng).connect(panner);
    src.start(t, Math.random());
    src.stop(t + 3.1);
    for (let i = 0; i < 24; i++) {
      const at = t + 0.1 + Math.random() * 1.8;
      const c = ctx.createBufferSource();
      c.buffer = this.noise;
      const bp = ctx.createBiquadFilter();
      bp.type = 'bandpass';
      bp.frequency.value = 800 + Math.random() * 4000;
      bp.Q.value = 3;
      const cg = ctx.createGain();
      cg.gain.setValueAtTime(near * (0.1 + Math.random() * 0.2), at);
      cg.gain.exponentialRampToValueAtTime(0.001, at + 0.03 + Math.random() * 0.08);
      c.connect(bp).connect(cg).connect(panner);
      c.start(at, Math.random());
      c.stop(at + 0.15);
    }
  }

  // ---- police radio: the voice itself comes from speech synthesis (see radio.ts); these are
  // the sounds around it: squelch, carrier hiss, the end-of-transmission beep and alert tones.
  private radioHiss?: GainNode;

  /** Radio static under a transmission. */
  radioCarrier(on: boolean): void {
    const ctx = this.ctx;
    if (!ctx) return;
    if (!this.radioHiss) {
      const bp = ctx.createBiquadFilter();
      bp.type = 'bandpass';
      bp.frequency.value = 1900;
      bp.Q.value = 0.9;
      this.radioHiss = ctx.createGain();
      this.radioHiss.gain.value = 0;
      this.noiseSource(ctx).connect(bp).connect(this.radioHiss).connect(this.radioBus);
    }
    smooth(this.radioHiss.gain, on ? 0.06 : 0, ctx, 0.02);
  }

  /** Squelch opening (key up) or closing (the "kssht" tail plus a short beep). */
  radioSquelch(open: boolean): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    const src = ctx.createBufferSource();
    src.buffer = this.noise;
    const bp = ctx.createBiquadFilter();
    bp.type = 'bandpass';
    bp.frequency.value = 2200;
    bp.Q.value = 0.7;
    const g = ctx.createGain();
    const length = open ? 0.06 : 0.18;
    g.gain.setValueAtTime(0.22, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + length);
    src.connect(bp).connect(g).connect(this.radioBus);
    src.start(t, Math.random());
    src.stop(t + length + 0.02);
    if (!open) this.tone(1050, t, 0.05, 0.08);
  }

  /**
   * Play recorded voice clips back to back, `delay` seconds from now, the way a transmission
   * really sounds: the speaker's surroundings leak into their mic (`scene`: a pursuit car
   * with its siren, a car without, the helicopter, the dispatch room), a breath before they
   * start, the radio's automatic gain pumping it all up, then a narrow, slightly overdriven
   * band. Each transmission is a touch faster or slower and higher or lower, like a person.
   */
  radioVoice(clips: Float32Array[], rate: number, delay: number, scene: 'siren' | 'car' | 'heli' | 'room'): { duration: number; stop: () => void } | null {
    const ctx = this.ctx;
    if (!ctx || !clips.length) return null;
    const mic = ctx.createGain(); // everything the mic picks up
    const agc = ctx.createDynamicsCompressor();
    agc.threshold.value = -32;
    agc.knee.value = 6;
    agc.ratio.value = 8;
    agc.attack.value = 0.004;
    agc.release.value = 0.18;
    const hp = ctx.createBiquadFilter();
    hp.type = 'highpass';
    hp.frequency.value = 300;
    const presence = ctx.createBiquadFilter();
    presence.type = 'peaking';
    presence.frequency.value = 1700;
    presence.Q.value = 0.9;
    presence.gain.value = 4;
    const drive = ctx.createWaveShaper();
    const curve = new Float32Array(1024);
    for (let i = 0; i < curve.length; i++) curve[i] = Math.tanh(((i / 1023) * 2 - 1) * 1.5) / Math.tanh(1.5);
    drive.curve = curve;
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 3000;
    const out = ctx.createGain();
    out.gain.value = 1.1;
    mic.connect(agc).connect(hp).connect(presence).connect(drive).connect(lp).connect(out).connect(this.radioBus);

    const nodes: AudioScheduledSourceNode[] = [];
    const t0 = ctx.currentTime + delay;
    // A breath in before speaking, most of the time.
    let at = t0;
    if (Math.random() < 0.6) {
      const breath = ctx.createBufferSource();
      breath.buffer = this.noise;
      const bp = ctx.createBiquadFilter();
      bp.type = 'bandpass';
      bp.frequency.value = 1300;
      bp.Q.value = 0.6;
      const g = ctx.createGain();
      g.gain.setValueAtTime(0, at);
      g.gain.linearRampToValueAtTime(0.05, at + 0.15);
      g.gain.linearRampToValueAtTime(0, at + 0.32);
      breath.connect(bp).connect(g).connect(mic);
      breath.start(at, Math.random());
      breath.stop(at + 0.35);
      nodes.push(breath);
      at += 0.3;
    }
    // People never say two things at exactly the same pace and pitch.
    const pace = 0.97 + Math.random() * 0.06;
    for (const samples of clips) {
      const buffer = ctx.createBuffer(1, samples.length, rate);
      buffer.copyToChannel(samples as Float32Array<ArrayBuffer>, 0);
      const src = ctx.createBufferSource();
      src.buffer = buffer;
      src.playbackRate.value = pace;
      src.connect(mic);
      src.start(at);
      nodes.push(src);
      at += buffer.duration / pace + 0.05 + Math.random() * 0.08;
    }
    const end = at + 0.1;

    // What's around the speaker, low under the voice.
    // A sound in the speaker's surroundings: fades in and out with the transmission.
    const surround = (level: number) => {
      const g = ctx.createGain();
      g.gain.setValueAtTime(0, t0);
      g.gain.linearRampToValueAtTime(level, t0 + 0.05);
      g.gain.setValueAtTime(level, end - 0.05);
      g.gain.linearRampToValueAtTime(0, end);
      g.connect(mic);
      return g;
    };
    const run = (...sources: AudioScheduledSourceNode[]) => {
      for (const src of sources) {
        src.start(t0);
        src.stop(end + 0.05);
        nodes.push(src);
      }
    };
    const noise = (type: BiquadFilterType, freq: number, q: number) => {
      const src = ctx.createBufferSource();
      src.buffer = this.noise;
      src.loop = true;
      const f = ctx.createBiquadFilter();
      f.type = type;
      f.frequency.value = freq;
      f.Q.value = q;
      src.connect(f);
      run(src);
      return f;
    };
    if (scene === 'siren' || scene === 'car') {
      // Engine and road roar inside a patrol car at speed.
      noise('lowpass', 260, 0.7).connect(surround(0.22));
    }
    if (scene === 'siren') {
      // Their own siren wailing outside, muffled by the car body.
      const siren = ctx.createOscillator();
      siren.type = 'sawtooth';
      siren.frequency.value = 1080;
      const sweep = ctx.createOscillator();
      sweep.frequency.value = 1 / 4.6;
      const depth = ctx.createGain();
      depth.gain.value = 420;
      sweep.connect(depth).connect(siren.frequency);
      const muffle = ctx.createBiquadFilter();
      muffle.type = 'lowpass';
      muffle.frequency.value = 1600;
      siren.connect(muffle).connect(surround(0.035));
      run(siren, sweep);
    }
    if (scene === 'heli') {
      // Rotor blade slap (about 18 thumps a second) and turbine whine.
      const chop = ctx.createGain();
      chop.gain.value = 0.5;
      const lfo = ctx.createOscillator();
      lfo.type = 'square';
      lfo.frequency.value = 17.5;
      const lfoDepth = ctx.createGain();
      lfoDepth.gain.value = 0.45;
      lfo.connect(lfoDepth).connect(chop.gain);
      noise('lowpass', 420, 0.8).connect(chop).connect(surround(0.5));
      const whine = ctx.createOscillator();
      whine.frequency.value = 1850;
      whine.connect(surround(0.008));
      run(lfo, whine);
    }
    if (scene === 'room') {
      // The dispatch room: a faint hum of air handling.
      noise('bandpass', 500, 0.5).connect(surround(0.03));
    }

    return {
      duration: end - t0,
      stop: () => {
        for (const node of nodes) {
          try {
            node.stop();
          } catch {
            // Already finished.
          }
        }
        out.disconnect();
      },
    };
  }

  /** Dispatch's three-tone alert before priority traffic. */
  radioAlert(): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = ctx.currentTime;
    for (let i = 0; i < 3; i++) this.tone(1000, t + i * 0.28, 0.18, 0.09);
  }

  private tone(freq: number, at: number, length: number, level: number): void {
    const ctx = this.ctx!;
    const o = ctx.createOscillator();
    o.type = 'square';
    o.frequency.value = freq;
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = freq;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0, at);
    g.gain.linearRampToValueAtTime(level, at + 0.005);
    g.gain.setValueAtTime(level, at + length);
    g.gain.linearRampToValueAtTime(0, at + length + 0.01);
    o.connect(f).connect(g).connect(this.radioBus);
    o.start(at);
    o.stop(at + length + 0.02);
  }

  private impact(strength: number, pan: number, near: number, scrape: boolean): void {
    const ctx = this.ctx!;
    const t = ctx.currentTime;
    const level = Math.min(1, strength / 12) * near;
    if (level < 0.02) return;
    const panner = ctx.createStereoPanner();
    panner.pan.value = pan;
    panner.connect(this.master);

    // Body thump: a falling low sine.
    const thump = ctx.createOscillator();
    thump.frequency.setValueAtTime(110, t);
    thump.frequency.exponentialRampToValueAtTime(38, t + 0.25);
    const tg = ctx.createGain();
    tg.gain.setValueAtTime(level * 0.9, t);
    tg.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
    thump.connect(tg).connect(panner);
    thump.start(t);
    thump.stop(t + 0.4);

    // Crumpling sheet metal: noise through a few ringing resonances.
    const src = ctx.createBufferSource();
    src.buffer = this.noise;
    const decay = 0.25 + Math.min(0.7, strength * 0.05);
    const ng = ctx.createGain();
    ng.gain.setValueAtTime(level * 0.8, t);
    ng.gain.exponentialRampToValueAtTime(0.001, t + decay);
    for (let i = 0; i < 4; i++) {
      const bp = ctx.createBiquadFilter();
      bp.type = 'bandpass';
      bp.frequency.value = (scrape ? 1500 : 350) + Math.random() * 2600;
      bp.Q.value = 6 + Math.random() * 10;
      src.connect(bp).connect(ng);
    }
    const body = ctx.createBiquadFilter();
    body.type = 'lowpass';
    body.frequency.value = 900;
    src.connect(body).connect(ng);
    ng.connect(panner);
    src.start(t, Math.random());
    src.stop(t + decay + 0.05);

    // Hard hits break glass: a scatter of short, bright ticks.
    if (strength > 7 && !scrape) {
      for (let i = 0; i < 14; i++) {
        const at = t + 0.03 + Math.random() * 0.5;
        const g = ctx.createBufferSource();
        g.buffer = this.noise;
        const hp = ctx.createBiquadFilter();
        hp.type = 'highpass';
        hp.frequency.value = 4500 + Math.random() * 4000;
        const gg = ctx.createGain();
        gg.gain.setValueAtTime(level * (0.15 + Math.random() * 0.25), at);
        gg.gain.exponentialRampToValueAtTime(0.001, at + 0.02 + Math.random() * 0.05);
        g.connect(hp).connect(gg).connect(panner);
        g.start(at, Math.random());
        g.stop(at + 0.1);
      }
    }
  }
}