import { mulberry32 } from '../core/rng.js';

// audio/synth.js (Lane E) — THE PROCEDURAL VOICE BANK.
//
// Every sound in GUTS, synthesized. No files, no fetch, no decode. This module is the PRIMARY
// audio path (SOUND SPEC §1.6: "The synthesis path is primary; samples are layer overrides"),
// which is why `?localassets=0` is not a degraded mode — it is the mode this file was designed
// in. Every cue `names()` reports is audible with an empty manifest — none of them ever touch one.
//
// CONTRACT (fixed, shared with engine.js which is being written in parallel):
//   createSynth(ctx, destination) -> { play(name, opts) -> bool, has(name), names(), dispose() }
//   `destination` is the engine's sfx bus. We NEVER connect to ctx.destination — the engine
//   owns the master chain, the volume, and the mute law. Nothing in here reads flags or the bus.
//
// THE STRUCTURE IS THE DELIVERABLE. Read RECIPES (below) to retune the game; read the six
// generic builders to change how a sound is made. There are deliberately NOT twenty bespoke
// synth functions — a cue is DATA, and the only cues with hand-written code are the ones that
// own real state (a sustained lockout, a chase loop, a heart, a continuous scrape).
//
// FIVE DSP LAWS, obeyed everywhere below. Each one is an audible bug if broken:
//   1. No instantaneous gain on a live node. `gain.value = x` mid-flight is a step
//      discontinuity — a full-Nyquist click. Every gain goes through setValueAtTime +
//      a ramp. Minimum attack 2 ms, minimum release 6 ms.
//   2. exponentialRampToValueAtTime may not touch zero. Floor is 1e-4, then setValueAtTime(0)
//      to park. Ramping FROM an unscheduled zero is also illegal and silently no-ops in
//      Chrome — every ramp gets an explicit setValueAtTime anchor first.
//   3. Every source is stop()ed at a known time. An Oscillator or BufferSource that is never
//      stopped runs (and is retained) forever.
//   4. Noise buffers are built ONCE at construction and replayed via BufferSource. Allocating
//      a buffer per shot is the classic hot-path bug — at 8 cannon shots/second it is ~1 MB/s
//      of garbage.
//   5. The clock is ctx.currentTime. `now()` adds 5 ms of slack: scheduling in the past loses
//      the attack ramp, and a lost attack ramp is a click.
//
// DELIBERATE DEVIATION FROM SPEC §1.3-R3 (node pooling): the spec asks for a checkout pool of
// GainNode/BiquadFilterNode. This file is fire-and-forget instead. Reason: a recycled node that
// carries one un-cancelled automation event jumps mid-ramp in its next life, and that bug is
// silent, intermittent, and nearly untestable — whereas the cost it avoids is ~6 short-lived
// nodes per cue, which every current WebAudio implementation reclaims cheaply off the audio
// thread. The allocation the spec was really protecting against (rule 4) is the noise buffer,
// and that one IS hoisted. Flagged to the integrator rather than hidden.

// ─────────────────────────────────────────────────────────────────────────────────────────
// RECIPE SCHEMA — read this once and the whole table below is legible.
// ─────────────────────────────────────────────────────────────────────────────────────────
// A cue is { cap, retrig, wet, dry, layers: [...] }.
//   cap    max simultaneous voices OF THIS CUE. Over cap we steal the oldest voice of the SAME
//          name only — never across names, or a cannon burst eats your surge warning.
//   retrig minimum seconds between voices; a request inside the window is dropped.
//   wet    send into the cavity (lowpass -> convolver -> highpass). "Biological" cues are wet,
//          "instrument" cues are dry. That split IS the sound identity.
//   dry    direct-path multiplier, default 1. `dry: 0` = heard only through the cavity.
//
// A layer is one of five kinds. Shared fields:
//   at    offset in seconds from the cue's start time            (default 0)
//   peak  linear peak gain into the bus                          (required)
//   a d   attack / decay seconds                                 (required)
//   sus s r  sustain fraction / hold seconds / release seconds   (optional; sus>0 enables)
//   wet / dry   per-layer overrides of the cue values
//   pan   -1..1, or 'alt' to alternate sides per voice
//   am    { f, depth }  an LFO summed INTO the VCA's gain AudioParam. AudioParam-rate AM is
//         the correct WebAudio idiom — it costs 2 nodes and zero JS per frame. `depth` is a
//         FRACTION OF THIS LAYER'S PEAK, not an absolute gain: depth 0.3 = ±30 % of peak. It has
//         to be relative or retuning `peak` silently changes the modulation index — and a depth
//         that exceeds peak drives the gain param NEGATIVE, which is ring modulation with
//         polarity inversion, not tremolo. (That bug shipped in `dart` until it was caught.)
//   ctrl  depth of smoothed ~14 Hz control noise summed into the VCA gain. ABSOLUTE, unlike `am`
//         — it is used on hiss beds where 100 % flicker is the point, and control noise is
//         symmetric about 0 so it grazes silence rather than inverting through it.
//   aLin  documentary only — the attack is always linear (see adsr); kept where a recipe wants
//         to SAY "this attack is a linear swell, not a curve", e.g. surge_start's 1.2 s dread.
//   shape true = through the tanh waveshaper
//   lad   0 = exempt this layer from the semitone ladder (see §ladders)
//   rep every  repeat this layer `rep` times, `every` seconds apart
//
//   k:'tone'   wave ('sine'|'square'|'sawtooth'|'triangle'|'organ'), f, [f2, ft] sweep target
//              + sweep seconds, sweep:'exp'|'lin', filt, vib { f, cents, decay }
//   k:'noise'  n ('white'|'pink'|'brown'), filt, filt2 (a second filter in series),
//              split (stereo pair at ±split with the right channel delayed `splitDelay`)
//   k:'bank'   fs[] ds[] ps[]  — N fixed sines with per-partial decay and peak. Inharmonic
//              ratios + higher-partials-die-first is what reads as STRUCK STRUCTURE.
//   k:'seq'    wave, notes[], every | ats[], peak | peaks[], d | ds[]
//
//   filt = [type, freq, Q] or [type, freq, Q, freqTarget, sweepSeconds].
//   Q NEVER sweeps (high-Q biquad instability); it may STEP, which is inaudible on a filter.

// The comms panel holds a line open for exactly this long (comms.js `HOLD`). The channel-open
// bed below is timed to close WITH it — that mirror is the whole cue ("you hear it stop"), so it
// lives in one named constant instead of a hand-summed 2.52 buried in the table. We cannot import
// comms.js (audio never reaches into UI), so if that HOLD is retuned, retune this number.
const COMMS_HOLD = 3.0;

const T = {
  // ── the instrument family: dry, pitched, centred ────────────────────────────────────────
  cannon: {
    // SPEC §3.1 "this sound must be SMALL". 70 ms, dry, no sub, ducks nothing. A gorgeous
    // 400 ms wet cannon is right in isolation and catastrophic at 8 shots/second: the tail
    // masks the surge warning and the ear fatigues inside thirty seconds. Payoff lives on the
    // IMPACT (enemy_hit / enemy_die), never on the trigger.
    cap: 4, retrig: 0.045, wet: 0.05,
    layers: [
      { k: 'noise', n: 'white', filt: ['highpass', 2200, 0.5], peak: 0.30, a: 0.0008, d: 0.0022, pan: 'alt' },
      { k: 'tone', wave: 'square', f: 380, f2: 95, ft: 0.042, filt: ['lowpass', 3200, 0.9], peak: 0.32, a: 0.001, d: 0.062, pan: 'alt' },
      { k: 'noise', n: 'white', filt: ['bandpass', 1800, 1.2, 700, 0.050], peak: 0.13, a: 0.0005, d: 0.055, pan: 'alt' },
    ],
  },
  pickup: {
    // Rising fifth. LAW A: pitch rises = you gained. Plus the coin ladder (§ladders).
    cap: 4, retrig: 0.05, wet: 0,
    layers: [
      { k: 'seq', wave: 'sine', notes: [1046.5, 1567.98], every: 0.055, peak: 0.22, a: 0.003, d: 0.170 },
      { k: 'noise', n: 'white', filt: ['bandpass', 5200, 3], peak: 0.09, a: 0.001, d: 0.025 },
    ],
  },
  pickup_sample: {
    // THE collectible, 3 per level. Unmistakably longer and richer than `pickup` — if these two
    // are ever confused the collectible stops feeling like one.
    cap: 2, wet: 0,
    layers: [
      { k: 'seq', wave: 'sine', notes: [659.25, 987.77, 1318.5], every: 0.070, peak: 0.18, a: 0.004, ds: [0.30, 0.34, 0.60] },
      { k: 'noise', n: 'white', filt: ['bandpass', 3000, 14], peak: 0.10, a: 0.002, d: 0.040 },
      { k: 'tone', wave: 'sine', f: 130.81, peak: 0.16, a: 0.015, d: 0.600, wet: 0.5 },
      // "log entry": the instrument's own carrier gets a second partial for 200 ms, at -36 dB.
      // Barely a tone; present as a feeling that the scanner wrote something down. It was -60 dB,
      // which is not "subliminal" — it is silent, and a silent layer is two nodes of nothing.
      { k: 'tone', wave: 'sine', f: 3733.5, peak: 0.016, a: 0.010, d: 0.200 },
      { k: 'tone', wave: 'sine', f: 2093, at: 0.14, peak: 0.09, a: 0.050, d: 0.550, am: { f: 6, depth: 0.50 } },
    ],
  },
  checkpoint: {
    // A data handshake, not a fanfare. Root/fifth/octave with NO THIRD — a third would make it
    // a happy jingle, and this is a machine confirming it wrote a record.
    cap: 1, wet: 0.35,
    layers: [
      { k: 'seq', wave: 'triangle', notes: [587.33, 880.00, 1174.66], every: 0.09, peak: 0.24, a: 0.004, d: 0.260, wet: 0 },
      { k: 'noise', n: 'white', filt: ['bandpass', 2600, 8], peak: 0.08, a: 0.001, d: 0.012, rep: 4, every: 0.055 },
      { k: 'tone', wave: 'sine', f: 293.66, peak: 0.11, a: 0.030, d: 0.500 },
    ],
  },
  gate_pass: {
    // Deliberately ~5 dB quieter than gate_hit. Passing is the EXPECTED state; hitting is the
    // news. Rewarding the default loudly trains the player to stop listening to the channel.
    cap: 2, wet: 0.40,
    layers: [
      { k: 'seq', wave: 'triangle', notes: [587.33, 880], every: 0.075, peak: 0.16, a: 0.004, d: 0.140, wet: 0 },
      { k: 'noise', n: 'pink', filt: ['bandpass', 700, 1.6, 3200, 0.200], peak: 0.16, a: 0.015, d: 0.210 },
      { k: 'tone', wave: 'sine', f: 98, peak: 0.09, a: 0.020, d: 0.280 },
    ],
  },
  gate_hit: {
    // The only semitone in the game (587.33 vs 622.25). LAW A allows this one exception because
    // it is not "you lost" — it is the instrument saying NO.
    cap: 2, wet: 0.60,
    layers: [
      { k: 'noise', n: 'white', filt: ['highpass', 2000, 0.7], peak: 0.34, a: 0.001, d: 0.022 },
      { k: 'bank', fs: [320, 470, 705], ds: [0.70, 0.50, 0.34], ps: [0.30, 0.22, 0.14], a: 0.002 },
      { k: 'tone', wave: 'sine', f: 160, f2: 44, ft: 0.100, peak: 0.48, a: 0.004, d: 0.280 },
      { k: 'tone', wave: 'sine', f: 587.33, peak: 0.08, a: 0.008, d: 0.300, wet: 0 },
      { k: 'tone', wave: 'sine', f: 622.25, peak: 0.08, a: 0.008, d: 0.300, wet: 0 },
    ],
  },
  comms_open: {
    // A mic keying up, NOT a beep. Fourteen trigger keys × a 3 s hold means a whole level of
    // these; anything with a pitch in it is torture by minute four. The third layer is the
    // channel standing OPEN at ~-48 dB for 3.0 s — you never hear it, you hear it stop.
    cap: 1, retrig: 0.2, wet: 0,
    layers: [
      { k: 'noise', n: 'white', filt: ['highpass', 3000, 0.7], peak: 0.20, a: 0.0008, d: 0.006 },
      { k: 'noise', n: 'pink', filt: ['bandpass', 1900, 3], peak: 0.09, a: 0.004, d: 0.028 },
      // -36 dB, not -48: at -48 the bed is below audibility after the comms bus trim, and a bed
      // you cannot hear cannot be heard to STOP. It must be subliminally present to work.
      { k: 'noise', n: 'pink', filt: ['highpass', 700, 0.7], filt2: ['lowpass', 3400, 0.7], peak: 0.016, a: 0.080, d: 0.002, sus: 1.0, s: COMMS_HOLD - 0.080 - 0.002 - 0.40, r: 0.40 },
    ],
  },

  // ── the tissue family: wet, unpitched, formant-filtered ─────────────────────────────────
  dart: {
    // LAW B, and the sharpest call in the spec: ENEMY FIRE ASCENDS, player fire descends. Both
    // descending is the Star Fox laser, and if incoming and outgoing fire share a contour you
    // cannot tell them apart in a firefight without looking. No noise layer at all — the
    // ABSENCE of a transient is the message: something with muscle is pushing at you.
    cap: 3, retrig: 0.06, wet: 0.35,
    layers: [
      { k: 'tone', wave: 'sawtooth', f: 640, f2: 1180, ft: 0.090, filt: ['lowpass', 2600, 2], peak: 0.26, a: 0.002, d: 0.088, am: { f: 55, depth: 0.55 }, pan: 'alt', panAmt: 0.5 },
    ],
  },
  enemy_hit: {
    // Squelch sweeps UPWARD (Law A: you gained). Consecutive hits walk the whole cue up a
    // chromatic ladder — see §ladders. Cheapest feel win in the spec: one multiply turns
    // sustained fire on a tanky enemy from a flat rattle into a six-step build.
    cap: 4, retrig: 0.03, wet: 0.60,
    layers: [
      { k: 'noise', n: 'white', filt: ['highpass', 3400, 0.5], peak: 0.28, a: 0.0008, d: 0.002, lad: 0 },
      { k: 'noise', n: 'brown', filt: ['bandpass', 900, 2.5, 2600, 0.060], peak: 0.30, a: 0.004, d: 0.090 },
      { k: 'tone', wave: 'sine', f: 420, f2: 560, ft: 0.060, peak: 0.14, a: 0.003, d: 0.080 },
    ],
  },
  enemy_die: {
    // The whole design in miniature: a WET collapse with a DRY instrument ping on top. The body
    // fails; the scanner notes it. The shard is the only thing here that isn't meat.
    cap: 3, wet: 0.55,
    layers: [
      { k: 'noise', n: 'white', filt: ['highpass', 2600, 0.5], peak: 0.42, a: 0.001, d: 0.003, lad: 0 },
      { k: 'noise', n: 'brown', filt: ['bandpass', 1200, 10, 90, 0.340], filt2: ['bandpass', 1200, 10, 90, 0.340], qStep: [2, 0.120], peak: 0.50, a: 0.006, d: 0.340 },
      { k: 'tone', wave: 'sine', f: 160, f2: 46, ft: 0.300, peak: 0.34, a: 0.004, d: 0.300 },
      { k: 'tone', wave: 'sine', f: 784, peak: 0.10, a: 0.008, d: 0.180, wet: 0, lad: 0 },
    ],
  },
  coat_hit: {
    // Zero content above 1 kHz. Zero sub. ENTIRELY wet (dry: 0). The absence of dry signal and
    // the absence of low end are the whole message, and they are what makes coat-vs-hull
    // legible inside one frame of a firefight without a single extra dB.
    cap: 2, retrig: 0.09, wet: 0.75, dry: 0,
    layers: [
      { k: 'noise', n: 'pink', filt: ['bandpass', 700, 6, 380, 0.110], peak: 0.26, a: 0.005, d: 0.115 },
      { k: 'tone', wave: 'sine', f: 150, f2: 95, ft: 0.110, peak: 0.13, a: 0.005, d: 0.105 },
    ],
  },
  hull_hit: {
    // It reached the INSTRUMENT. Dry 6 ms dropout + an inharmonic struck ring (1 : 1.74 : 2.72,
    // higher partials dying first) + sub weight. The carrier stutter, the master duck and the
    // 47 Hz ring-mod aberration flash are the ENGINE's half of this cue — they act on buses
    // this module cannot see. See the report: engine.js owns them.
    cap: 1, retrig: 0.12, wet: 0,
    layers: [
      { k: 'noise', n: 'white', peak: 0.34, a: 0.0008, d: 0.005 },
      { k: 'bank', fs: [190, 331, 517], ds: [0.30, 0.22, 0.16], ps: [0.30, 0.20, 0.13], a: 0.002 },
      { k: 'tone', wave: 'sine', f: 120, f2: 38, ft: 0.130, peak: 0.40, a: 0.006, d: 0.330, wet: 0.6 },
    ],
  },
  boost: {
    // Rising (Law A). The room swell and the carrier glide are the engine's; here we build the
    // air, the lift and the settle. Note the bed is LIFTED under boost, not ducked — ducking
    // the world under a player-empowerment cue is emotionally backwards. That is engine-side.
    cap: 1, wet: 0.45,
    layers: [
      { k: 'noise', n: 'white', filt: ['lowpass', 300, 2.6, 4200, 0.260], filtThen: [1400, 0.440], peak: 0.40, a: 0.020, d: 0.010, sus: 0.9, s: 0.24, r: 0.42 },
      { k: 'tone', wave: 'sine', f: 90, f2: 200, ft: 0.300, peak: 0.18, a: 0.012, d: 0.400 },
      { k: 'noise', n: 'pink', at: 0.30, filt: ['bandpass', 1200, 1.0, 400, 0.380], peak: 0.10, a: 0.015, d: 0.380 },
    ],
  },
  torpedo: {
    cap: 2, wet: 0.50,
    layers: [
      { k: 'noise', n: 'brown', filt: ['lowpass', 1800, 1.4, 400, 0.260], peak: 0.30, a: 0.020, d: 0.300 },
      { k: 'tone', wave: 'triangle', f: 220, f2: 60, ft: 0.200, peak: 0.32, a: 0.006, d: 0.280 },
      { k: 'tone', wave: 'sine', f: 90, f2: 45, ft: 0.070, peak: 0.26, a: 0.006, d: 0.120 },
      // the fizz IS the chemistry — a 3 Hz tremolo on a 4.5 kHz highpass reads as reaction
      { k: 'noise', n: 'white', filt: ['highpass', 4500, 0.7], peak: 0.10, a: 0.008, d: 0.040, sus: 0.25, s: 0.20, r: 0.20, am: { f: 3, depth: 0.40 } },
    ],
  },
  torpedo_blast: {
    // The biggest routine transient in the game. This voice alone peaks near 1.0, which is why
    // the engine's limiter threshold is -8 and not -3. Two simultaneous blasts is a legitimate
    // game state; three is never worth the sub headroom, hence cap 2.
    cap: 2, wet: 0.55,
    layers: [
      { k: 'noise', n: 'white', filt: ['highpass', 2500, 0.7], peak: 0.30, a: 0.001, d: 0.040 },
      { k: 'noise', n: 'white', shape: true, shapeFade: [0.120, 0.080], filt: ['lowpass', 5200, 1.0, 130, 0.800], peak: 0.62, a: 0.004, d: 0.820 },
      { k: 'tone', wave: 'sine', f: 88, f2: 28, ft: 0.460, peak: 0.58, a: 0.006, d: 0.560 },
      // "identity": so the blast is not merely A NOISE. Every explosion in every game is a
      // noise burst; the pitched triangle under it is what makes this one antacid.
      { k: 'tone', wave: 'triangle', f: 320, f2: 70, ft: 0.250, peak: 0.16, a: 0.004, d: 0.300 },
      { k: 'noise', n: 'white', at: 0.05, filt: ['highpass', 3000, 0.7, 9000, 0.800], peak: 0.12, a: 0.020, d: 0.820, split: 0.7, splitDelay: 0.011 },
    ],
  },
  surge_stall: {
    // The antacid bites. The slow-linear-swell / fast-exponential-choke asymmetry IS what
    // "freeze" sounds like; a symmetric envelope here just sounds like a wet fart.
    cap: 1, wet: 0.30, surge: true,
    layers: [
      { k: 'noise', n: 'white', filt: ['bandpass', 1200, 8], choke: [0.22, 0.06], peak: 0.40, a: 0.22, d: 0.06 },
      { k: 'tone', wave: 'sine', at: 0.22, f: 180, f2: 130, ft: 0.260, vib: { f: 6, cents: 30, decay: 0.300 }, peak: 0.36, a: 0.004, d: 0.310 },
      { k: 'seq', wave: 'sine', notes: [987.77, 1318.5], every: 0.060, peak: 0.07, a: 0.030, d: 0.400, wet: 0 },
    ],
  },
  surge_end: {
    // The best moment in the game, and the only rising instrument figure that isn't a pickup.
    cap: 1, wet: 0.45, surge: true,
    layers: [
      { k: 'noise', n: 'brown', filt: ['lowpass', 600, 0.7, 3000, 0.900], peak: 0.26, a: 0.030, d: 0.900 },
      { k: 'tone', wave: 'sine', f: 55, f2: 110, ft: 0.700, peak: 0.24, a: 0.020, d: 0.900 },
      { k: 'tone', wave: 'sine', f: 392, f2: 587.33, ft: 0.500, peak: 0.14, a: 0.020, d: 0.520, wet: 0 },
    ],
  },
  surge_start: {
    // The 1.2 s LINEAR attack on the dread sub is the whole cue. There is no transient. The
    // ABSENCE of an attack is what makes this dread rather than impact — you notice it has
    // already been happening. Two organ waves 0.62 Hz apart give a slow nauseating wobble for
    // the price of zero LFOs.
    cap: 1, wet: 0, surge: true,
    layers: [
      { k: 'tone', wave: 'sine', f: 34, peak: 0.52, a: 1.20, aLin: true, d: 0.010, sus: 1.0, s: 0.6, r: 0.70 },
      { k: 'tone', wave: 'organ', f: 55.00, shape: true, filt: ['lowpass', 240, 3.0, 900, 2.0], peak: 0.26, a: 0.40, d: 0.010, sus: 1.0, s: 1.4, r: 0.70 },
      { k: 'tone', wave: 'organ', f: 55.62, shape: true, filt: ['lowpass', 240, 3.0, 900, 2.0], peak: 0.26, a: 0.40, d: 0.010, sus: 1.0, s: 1.4, r: 0.70 },
      { k: 'noise', n: 'brown', filt: ['lowpass', 180, 1.2, 900, 2.2], peak: 0.36, a: 0.350, d: 1.40, sus: 1.0, s: 0.4, r: 0.60 },
      { k: 'noise', n: 'white', filt: ['highpass', 4000, 0.7], peak: 0.03, a: 0.30, d: 0.010, sus: 1.0, s: 1.9, r: 0.40, ctrl: 0.06 },
    ],
  },
};

// ── the three warnings. One grammar: the instrument BLIPS, the body ANSWERS underneath ──────
//
// SPEC §3.18-3.20, and the single most likely thing in this file to be got wrong. `hazard:warn`
// carries `eta` (2.5 s of lead by default). A lone beep 2.5 s early is not a warning, it is
// trivia — THE ACCELERATION IS THE INFORMATION. Three pulses at eta-1.9 / eta-1.0 / eta-0.35.
//
// Discrimination uses all three lenses at once, because any one alone fails in a firefight:
// RHYTHM (survives spectral masking, which timbre does not), PITCH CONTOUR, and TIMBRE FAMILY.
const WARN = {
  warn_ring_gate: {      // the thing AHEAD — a single sharp tick, high and clean
    wet: 0.35, blipDur: 0.070,
    blip: [
      { k: 'tone', wave: 'sine', f: 880, peak: 0.22, a: 0.004, d: 0.066, wet: 0 },
      { k: 'tone', wave: 'sine', f: 2640, peak: 0.22 / 6, a: 0.004, d: 0.066, wet: 0 },
      { k: 'noise', n: 'white', filt: ['highpass', 3000, 0.7], peak: 0.10, a: 0.0008, d: 0.005, wet: 0 },
    ],
    body: [{ k: 'noise', n: 'white', filt: ['bandpass', 190, 8], peak: 0.14, a: 0.001, d: 0.900, sus: 0.70, s: 0.6, r: 0.70 }],
  },
  warn_aortic_squeeze: { // the WALLS — a double-tap, mid, with a 9 Hz flutter
    wet: 0.40, blipDur: 0.045, taps: [0, 0.090],
    blip: [
      { k: 'tone', wave: 'triangle', f: 698.46, filt: ['bandpass', 1000, 2], peak: 0.22, a: 0.004, d: 0.041, am: { f: 9, depth: 0.55 }, wet: 0 },
    ],
    body: [
      { k: 'tone', wave: 'sine', f: 41, peak: 0.16, a: 0.20, d: 0.010, sus: 1.0, s: 1.2, r: 0.40, am: { f: 1.2, depth: 0.55 } },
      { k: 'noise', n: 'white', filt: ['bandpass', 130, 4], peak: 0.12, a: 0.20, d: 0.010, sus: 1.0, s: 1.2, r: 0.40 },
    ],
  },
  warn_reflux_surge: {   // the thing BEHIND — long-long-LONG, and THE ONLY DESCENDING MOTIF
    wet: 0, surge: true, blipDur: 0.110, durs: [0.110, 0.110, 0.170],
    notes: [1046.5, 994, 944, 897],   // ×0.95 each
    blip: [{ k: 'tone', wave: 'sine', f: 1046.5, peak: 0.22, a: 0.006, d: 0.104, wet: 0 }],
    body: [
      { k: 'tone', wave: 'sine', f: 33, peak: 0.18, a: 0.060, d: 1.60, sus: 0.4, s: 0.6, r: 0.50 },
      { k: 'noise', n: 'white', filt: ['highpass', 2500, 0.7], peak: 0.10, a: 0.10, d: 0.010, sus: 1.0, s: 1.3, r: 0.50, am: { f: 7, depth: 0.35 } },
    ],
  },
};

// ── the heartbeat pulse. THE ENGINE OWNS THE TEMPO; THIS FILE OWNS THE SOUND. ───────────────
// play('heartbeat', { d, gap }) where d = the danger scalar 0..1 and gap = the S1->S2 spacing
// the engine computed from its own period. We do not schedule beats and we do not keep time.
const HEART = {
  s1: { f: 62, fd: 9, f2: 38, ft: 0.090, peak: 0.55, a: 0.006, d: 0.150, slapPeak: 0.30, slapD: 0.075, floorPeak: 0.22, floorD: 0.200 },
  s2: { f: 74, fd: 0, f2: 46, ft: 0.090, peak: 0.55, a: 0.006, d: 0.150, slapPeak: 0.30, slapD: 0.075, floorPeak: 0.22, floorD: 0.200, gainMul: 0.65, decayMul: 0.72 },
  // The DANGER RESPONSE is the identity, so it is data too. It used to be four bare thresholds
  // and three magic numbers inline in beat(), which meant "the heart should strain earlier"
  // required reading the renderer. Every value here is "at danger d, ...".
  dyn: {
    gainBase: 0.55, gainD: 0.45,     // body peak = peak * (gainBase + gainD*d)
    valveF: 120, valveFD: 140,       // valve-slap lowpass cutoff opens with danger — the anxiety
    floorF: 34,                      // the sub floor under every beat
    tDistort: 0.70,                  // above this, S1 goes through the shaper (working too hard)
    tStrain: 0.45, strainPeak: 0.10, // the high tick: strain WITHOUT loudness
    tMurmur: 0.60, murmurPeak: 0.18, // the low murmur
    tSingle: 0.88,                   // above this the double-thump COLLAPSES to a single thump
    tJitter: 0.30, jitter: 0.015,    // humanise the S1->S2 gap only while calm
  },
};

// Feed static on death: a FIXED table, not a live RNG. House style (hud.js hashes p.s,
// comms.js round-robins) — deterministic means a bug reproduces.
const STATIC_STEPS = [0.30, 0.05, 0.22, 0.02, 0.35, 0.10, 0.04, 0.28, 0.06, 0.18, 0.03, 0.12];

// Cannon detune: ±3 % from an 8-entry table, cycled. Fixed pitch at 8 shots/s produces a
// periodic comb the ear decodes as a buzz; ±20 % reads as TWO DIFFERENT WEAPONS and destroys
// event identity. ±3 % from a table splits the difference and stays reproducible.
const CANNON_DT = [1.000, 0.978, 1.022, 0.991, 1.012, 0.969, 1.004, 1.031];
const CANNON_GAIN = [1.00, 0.94, 0.98, 0.91];

const CEILING_DEFAULT = 24;

// The engine builds FOUR synths on one ctx (sfx / warn / comms / heart). Everything below is
// immutable, ctx-scoped, and identical in all four — ~10 s of Float32 noise (three O(n) passes
// each), a PeriodicWave and a 1024-point curve. Built per-instance that is ~7.7 MB and ~8M sample
// iterations SYNCHRONOUSLY on the unlock gesture, which is exactly the allocation LAW 4 exists to
// prevent, just moved to the integration seam. Keyed on ctx (WeakMap, so a discarded ctx frees
// its buffers). AudioBuffers are read-only once filled, so sharing them across instances is safe;
// nothing per-instance (the cavity, the surge chain, the scrape voice) is cached here, because
// those are wired to a `destination` that differs per instance.
const SHARED = new WeakMap();

export function createSynth(ctx, destination, { ceiling = CEILING_DEFAULT, rnd } = {}) {
  // Hard bail if we were handed nothing usable. Never throw — a dead synth is a quiet game,
  // a thrown synth is a dead game (ASSETS-OPTIONAL LAW, applied to our own inputs).
  if (!ctx || !destination) return stub();

  // Deterministic texture RNG (house determinism law: no wall-clock randomness — qa.sh greps
  // for it). The engine hands us its ?seed=-derived stream so noise/grains/jitter reproduce on
  // every machine; a direct caller falls back to a fixed seed and is still reproducible.
  const rand = rnd || mulberry32(0x51ED7357);

  let disposed = false;
  const timers = [];
  const live = [];              // { name, vca, srcs, until }
  const perm = [];              // permanent sources (scrape, chase, static) — stopped in dispose
  const nodes = [];             // permanent graph nodes to disconnect in dispose

  const now = () => ctx.currentTime + 0.005;   // 5 ms of slack: scheduling in the past = click
  const alive = () => !disposed && ctx.state !== 'closed';

  /**
   * A tracked setTimeout. EVERY timer this module creates goes through here so dispose() can
   * clear it — an untracked timer that fires after teardown touches a graph that is already gone.
   * It removes its own id on fire, so `timers` cannot grow across a session of lockouts/surges.
   * Deliberately NOT guarded on `disposed`: these callbacks only ever disconnect dead nodes, and
   * dispose() itself schedules some of them AFTER clearing the list.
   */
  function later(fn, ms) {
    const id = setTimeout(() => {
      const i = timers.indexOf(id);
      if (i >= 0) timers.splice(i, 1);
      try { fn(); } catch { /* graph already gone */ }
    }, ms);
    timers.push(id);
    return id;
  }

  /** Disconnect a set of now-silent nodes once their fades have landed. Never before. */
  function dropLater(list, ms) {
    later(() => { for (const n of list) { try { n.disconnect(); } catch { /* gone */ } } }, ms);
  }

  // ═══ built-once resources — once per CONTEXT, not once per instance (see SHARED) ══════════

  let shared = SHARED.get(ctx);
  if (!shared) {
    try {
      shared = {
        NZ: {
          white: noiseBuffer(2.0, 'white'),
          pink: noiseBuffer(2.0, 'pink'),
          brown: noiseBuffer(4.0, 'brown'),
          ctrl: controlNoiseBuffer(2.0),
        },
        organ: buildOrganWave(),
        curve: buildShaperCurve(),
      };
      SHARED.set(ctx, shared);
    } catch {
      // A closing ctx throws on createBuffer. Nothing below can work without these, and the
      // ASSETS-OPTIONAL LAW applied to our own inputs says: degrade to silence, never throw.
      return stub();
    }
  }
  const NZ = shared.NZ;
  const WAVE_ORGAN = shared.organ;
  const SHAPER_CURVE = shared.curve;

  function noiseBuffer(seconds, kind) {
    const n = Math.max(1, (ctx.sampleRate * seconds) | 0);
    const buf = ctx.createBuffer(1, n, ctx.sampleRate);
    const d = buf.getChannelData(0);
    let b0 = 0, b1 = 0, b2 = 0, last = 0;
    for (let i = 0; i < n; i++) {
      const w = rand() * 2 - 1;
      if (kind === 'white') d[i] = w;
      else if (kind === 'pink') {
        // Kellet 3-pole economy filter — pink enough for game audio at a third of the cost.
        b0 = 0.99765 * b0 + w * 0.0990460;
        b1 = 0.96300 * b1 + w * 0.2965164;
        b2 = 0.57000 * b2 + w * 1.0526913;
        d[i] = b0 + b1 + b2 + w * 0.1848;
      } else {
        // LEAKY integrator (0.996), not a pure one. A pure integrator random-walks into
        // unbounded DC and will pin the limiter after a couple of seconds of a session-long
        // looping bed. This is invisible in a unit test and ruins a long session.
        last = 0.996 * last + w * 0.06;
        d[i] = last;
      }
    }
    // Mean subtraction + peak normalisation. Non-negotiable: any DC offset here is headroom
    // stolen from every voice that uses the buffer, forever.
    let mean = 0;
    for (let i = 0; i < n; i++) mean += d[i];
    mean /= n;
    let peak = 1e-9;
    for (let i = 0; i < n; i++) { d[i] -= mean; const a = d[i] < 0 ? -d[i] : d[i]; if (a > peak) peak = a; }
    const g = 0.95 / peak;
    for (let i = 0; i < n; i++) d[i] *= g;
    return buf;
  }

  // Smoothed sub-audio noise, ~14 Hz, centred on 0.5 and scaled -0.5..0.5. Used as a CONTROL
  // signal into an AudioParam: control-rate randomness for free, zero JS per frame.
  function controlNoiseBuffer(seconds) {
    const n = Math.max(2, (ctx.sampleRate * seconds) | 0);
    const buf = ctx.createBuffer(1, n, ctx.sampleRate);
    const d = buf.getChannelData(0);
    const stride = Math.max(1, (ctx.sampleRate / 14) | 0);
    let a = rand(), b = rand();
    for (let i = 0; i < n; i++) {
      const k = i % stride;
      if (k === 0) { a = b; b = rand(); }
      const x = k / stride;
      d[i] = (a + (b - a) * (x * x * (3 - 2 * x))) - 0.5;   // smoothstep, so no stair-steps
    }
    return buf;
  }

  // One PeriodicWave, band-limited per octave by the API, so it never aliases at any f0.
  function buildOrganWave() {
    return ctx.createPeriodicWave(
      new Float32Array([0, 0, 0, 0, 0, 0, 0, 0]),
      new Float32Array([0, 1, 0.28, 0.44, 0.09, 0.19, 0.05, 0.11]),
      { disableNormalization: false },
    );
  }

  // Odd-symmetric ON PURPOSE. An asymmetric curve generates even harmonics AND a DC term the
  // highpass then has to clean up after the limiter has already lost the headroom to it.
  function buildShaperCurve() {
    const c = new Float32Array(1024);
    for (let i = 0; i < 1024; i++) {
      const x = (i / 1023) * 2 - 1;
      c[i] = Math.tanh(x * 2.6) * 0.85;
    }
    return c;
  }

  // ═══ the cavity: one send, doubling as the reverb ════════════════════════════════════════
  // "Biological" cues go through it; nothing from the instrument family does. That split is the
  // sound identity, implemented as one chain instead of two reverbs.
  //
  // NOTE TO THE ENGINE: this send is INTERNAL and already mixed into our output. Do not put the
  // synth's output through a second cavity/convolver — you will double the tails.
  const cavityIn = ctx.createGain();
  const cavityLP = biquad('lowpass', 1600, 0.5);
  const cavityHP = biquad('highpass', 120, 0.7);
  const cavityRet = ctx.createGain();
  cavityRet.gain.value = 0.24;
  cavityIn.connect(cavityLP);
  cavityLP.connect(cavityHP);           // bypass until the IR lands, so early wet isn't silent
  cavityHP.connect(cavityRet);
  cavityRet.connect(destination);
  nodes.push(cavityIn, cavityLP, cavityHP, cavityRet);

  let convolver = null;
  // Deferred: ~12 ms of buffer generation must not land inside time-to-interactive.
  timers.push(setTimeout(() => {
    if (!alive()) return;
    try {
      // The IR is generated once per CONTEXT and shared, like the noise buffers — four instances
      // generating four identical impulse responses is the same waste one level down. The
      // ConvolverNode itself stays per-instance; only the buffer is shared.
      let ir = shared.ir;
      if (!ir) {
        const dur = ctx.sampleRate > 48000 ? 0.5 : 0.7;
        const n = (ctx.sampleRate * dur) | 0;
        ir = ctx.createBuffer(1, n, ctx.sampleRate);
        const d = ir.getChannelData(0);
        for (let i = 0; i < n; i++) {
          const t = i / ctx.sampleRate;
          // The 6 ms of leading silence is what keeps early reflections off the attack
          // transients. A naive IR with energy at t=0 is exactly why cannon shots turn to mush.
          d[i] = t < 0.006 ? 0 : (rand() * 2 - 1) * Math.pow(1 - t / dur, 4.5);
        }
        shared.ir = ir;
      }
      convolver = ctx.createConvolver();
      convolver.normalize = true;
      convolver.buffer = ir;
      cavityLP.disconnect();
      cavityLP.connect(convolver);
      convolver.connect(cavityHP);
      nodes.push(convolver);
    } catch { /* no convolution: the dry bypass above is already connected and audible */ }
  }, 0));

  // ═══ the rear channel (surgeChain) ═══════════════════════════════════════════════════════
  // "Behind you" in stereo is mostly DULLER and WIDER. As the surge closes, the highshelf goes
  // -6 -> 0 dB and the left delay 12 -> 2 ms: it BRIGHTENS AND NARROWS TO A POINT as it reaches
  // you. That is the entire level's tension in two parameters, and it is the honest way to fake
  // position without importing anything from flight/ or combat/ (House Law 1).
  //
  // BASS IS SUMMED TO CENTRE BEFORE THE HAAS SPLIT, and that is not a nicety. A 12 ms delay on one
  // side is a comb filter when the two sides are summed — which is what happens on every laptop
  // and phone speaker — with its first notch at 1/(2*0.012) = 41.7 Hz. surge_start's whole point
  // is a 34 Hz dread sub and a 55/55.62 Hz organ pair; those sit right in the notch and would
  // partially CANCEL on the most common playback device, and on headphones they would be a hole
  // in the middle instead of weight. So: everything under 150 Hz goes straight down the middle,
  // and only the band above it gets widened. The width cue lives in the top end anyway — that is
  // how ears localise, and it is why every mastering chain in the world is mono below ~120 Hz.
  const surgeIn = ctx.createGain();
  const surgeSub = biquad('lowpass', 150, 0.7);     // centre path — never delayed, never panned
  const surgeHi = biquad('highpass', 150, 0.7);     // wide path
  const surgeShelf = ctx.createBiquadFilter();
  surgeShelf.type = 'highshelf';
  surgeShelf.frequency.value = 3500;
  surgeShelf.gain.value = -6;
  const surgeDelayL = ctx.createDelay(0.05);
  surgeDelayL.delayTime.value = 0.012;
  const surgePanL = panner(-0.85);
  const surgePanR = panner(0.85);
  surgeIn.connect(surgeSub);
  surgeSub.connect(destination);
  surgeIn.connect(surgeHi);
  surgeHi.connect(surgeShelf);
  surgeShelf.connect(surgeDelayL);
  surgeDelayL.connect(surgePanL || destination);
  surgeShelf.connect(surgePanR || destination);
  if (surgePanL) surgePanL.connect(destination);
  if (surgePanR) surgePanR.connect(destination);
  // Every node on this path goes in `nodes` — the panners were previously connected to the
  // destination and never disconnected, which is a graph leak on every level transition.
  nodes.push(surgeIn, surgeSub, surgeHi, surgeShelf, surgeDelayL);
  if (surgePanL) nodes.push(surgePanL);
  if (surgePanR) nodes.push(surgePanR);

  // ═══ generic builders ════════════════════════════════════════════════════════════════════

  /**
   * Anchor a param at its CURRENT value so a following ramp starts from where we actually are,
   * clamped away from zero. Both halves matter: without the anchor the ramp starts from the last
   * SCHEDULED value (not the audible one) and jumps; and an anchor of exactly 0 makes a following
   * exponential ramp illegal — Chrome silently no-ops it and the gain stays stuck where it was.
   * cancelAndHoldAtTime would do this in one call but does not exist in Firefox.
   */
  function anchor(param, t) {
    const v = Number.isFinite(param.value) ? param.value : 0;
    param.cancelScheduledValues(t);
    param.setValueAtTime(Math.max(v, 1e-4), t);
    return param;
  }

  function biquad(type, f, q) {
    const b = ctx.createBiquadFilter();
    b.type = type;
    b.frequency.value = f;
    b.Q.value = q ?? 1;
    return b;
  }

  function panner(v) {
    if (!ctx.createStereoPanner) return null;   // ancient Safari — mono is a fine degradation
    const p = ctx.createStereoPanner();
    p.pan.value = v;
    return p;
  }

  /**
   * THE envelope. Every cue in this file is expressed in it, which is why no cue can click.
   * a/d/s/r in SECONDS; `sus` is a 0..1 fraction of peak (0 disables the sustain stage).
   * Returns { node, end, park }:
   *   park  the instant the gain is pinned at TRUE zero. NOTHING may still be summing into this
   *         gain param after `park` — an LFO or control-noise source that keeps running past it
   *         is added to an intrinsic value of 0, so the VCA RE-OPENS to ±depth after the note is
   *         supposed to be over, and then hard-cuts when the source stops. Every modulator gets
   *         faded out and stopped at `park`, never at `end`.
   *   end   when the SOURCE may be stopped — deliberately later than `park`, so the source is
   *         still running (into a zero gain) while the park lands. Stopping a source is a step
   *         discontinuity in its own output; doing it behind a closed VCA is what makes it silent.
   */
  function adsr(t, peak, a, d, sus = 0, s = 0, r = 0.02, aLin = false) {
    const g = ctx.createGain();
    const p = Math.max(peak, 1e-4);
    a = Math.max(a, 0.002);                     // LAW 1: floor the attack
    d = Math.max(d, 0.002);
    g.gain.setValueAtTime(0, t);                // LAW 2: explicit anchor, or the ramp no-ops
    g.gain.linearRampToValueAtTime(p, t + a);   // attack is ALWAYS linear (aLin is documentary)
    let end;
    if (sus > 0) {
      const sv = Math.max(p * sus, 1e-4);
      g.gain.exponentialRampToValueAtTime(sv, t + a + d);
      g.gain.setValueAtTime(sv, t + a + d + s);
      const rr = Math.max(r, 0.006);            // LAW 1: floor the release
      g.gain.exponentialRampToValueAtTime(1e-4, t + a + d + s + rr);
      end = t + a + d + s + rr;
    } else {
      g.gain.exponentialRampToValueAtTime(1e-4, t + a + d);   // LAW 2: never ramp TO zero
      end = t + a + d;
    }
    const park = end + 0.001;
    g.gain.setValueAtTime(0, park);             // park at true zero once the ramp has landed
    return { node: g, end: end + 0.02, park };
  }

  /**
   * Schedule a modulator's depth so it has reached EXACTLY zero by the time the envelope it feeds
   * parks. Anything summed into a gain param must die before the param does; see adsr's `park`.
   * The 6 ms taper is LAW 1 applied to the modulation path itself — snapping a depth to 0 is the
   * same step discontinuity as snapping a gain to 0, it is just one level of indirection away.
   */
  function modDepth(param, depth, t, park) {
    const off = Math.max(park - 0.006, t + 0.0005);
    const to = Math.max(park, off + 0.0005);
    param.setValueAtTime(depth, t);
    param.setValueAtTime(depth, off);
    param.linearRampToValueAtTime(0, to);
    return to;
  }

  function oscOf(wave, f, t) {
    const o = ctx.createOscillator();
    if (wave === 'organ') o.setPeriodicWave(WAVE_ORGAN);
    else o.type = wave || 'sine';
    o.frequency.setValueAtTime(Math.max(f, 0.0001), t);
    return o;
  }

  function noiseOf(kind, t) {
    const src = ctx.createBufferSource();
    const buf = NZ[kind] || NZ.white;
    src.buffer = buf;
    src.loop = true;
    // Random START OFFSET, every time. Without it every squelch in the game begins on the
    // identical sample sequence and the ear decodes it as A SAMPLE rather than as noise.
    // (Loop-offset jitter: a texture, not a game value — so it draws from the seeded stream
    // like everything else here, keeping the same ?seed reproducible on every machine.)
    src.start(t, rand() * Math.max(0.01, buf.duration - 0.5));
    return src;
  }

  function shaperNode() {
    const w = ctx.createWaveShaper();
    w.curve = SHAPER_CURVE;
    w.oversample = '2x';
    return w;
  }

  /** Apply a [type, f, Q, f2?, ft?] filter spec, with an optional second-stage sweep. */
  function applyFilt(spec, t, mul, then) {
    const b = biquad(spec[0], spec[1] * mul, spec[2]);
    if (spec[3] != null) {
      b.frequency.setValueAtTime(spec[1] * mul, t);
      b.frequency.exponentialRampToValueAtTime(Math.max(spec[3] * mul, 1), t + (spec[4] ?? 0.1));
      if (then) b.frequency.exponentialRampToValueAtTime(Math.max(then[0] * mul, 1), t + (spec[4] ?? 0.1) + then[1]);
    }
    return b;
  }

  // ═══ the renderer: DATA -> NODES ═════════════════════════════════════════════════════════

  /**
   * Render one cue's layers. `o` carries the per-play modulations:
   *   gain (master multiplier), detune (cents), step (semitone ladder), mul (peak multiplier),
   *   d (danger, heartbeat only), routeSurge (send through the rear channel instead of dry).
   */
  function render(cue, layers, t0, o, sink) {
    const srcs = [];
    const vca = ctx.createGain();          // dry path
    vca.gain.value = 1;
    const wetSum = ctx.createGain();       // wet path, summed per voice
    wetSum.gain.value = 1;

    const dryG = ctx.createGain();
    dryG.gain.value = cue.dry ?? 1;
    vca.connect(dryG);
    dryG.connect(sink);
    wetSum.connect(cavityIn);
    // Dry and wet are two nodes, not one, so that stealing a voice silences BOTH. Routing the
    // wet send straight off the layer would leave a 0.7 s reverb tail of a voice we just killed.
    const bus = { vca, wetSum };

    let end = t0 + 0.05;
    for (const L of layers) {
      const reps = L.rep ?? 1;
      for (let ri = 0; ri < reps; ri++) {
        const at = t0 + (L.at ?? 0) + ri * (L.every ?? 0);
        const e = buildLayer(L, at, o, bus, srcs, cue);
        if (e > end) end = e;
      }
    }
    return { vca, wetSum, srcs, end };
  }

  /** Route one layer's output: dry through the voice VCA, wet into the voice's cavity send. */
  function sinkFor(L, cue, bus, gainOut) {
    const wet = L.wet ?? cue.wet ?? 0;
    gainOut.connect(bus.vca);
    if (wet > 0) {
      const w = ctx.createGain();
      w.gain.value = wet;
      gainOut.connect(w);
      w.connect(bus.wetSum);
    }
  }

  function buildLayer(L, t, o, bus, srcs, cue) {
    const lad = L.lad === 0 ? 1 : Math.pow(2, (o.step ?? 0) / 12);
    const mul = (o.mul ?? 1) * (o.gain ?? 1);
    const peak = (L.peak ?? 0.2) * mul;
    const dur = L.durOverride ?? L.d;

    if (L.k === 'seq') {
      // A sequence of fixed tones — used wherever the FIGURE is the sound (rising fifths,
      // confirm triads, the descending reflux motif).
      let end = t;
      const notes = L.notes || [];
      for (let i = 0; i < notes.length; i++) {
        const at = t + (L.ats ? L.ats[i] : i * (L.every ?? 0.06));
        const e = buildLayer(
          { ...L, k: 'tone', f: notes[i], at: 0, notes: undefined,
            peak: (L.peaks ? L.peaks[i] : L.peak), d: (L.ds ? L.ds[i] : L.d) },
          at, o, bus, srcs, cue,
        );
        if (e > end) end = e;
      }
      return end;
    }

    if (L.k === 'bank') {
      // Inharmonic partials with per-partial decay. Higher partials die FIRST — that is the
      // difference between "struck structure" and "organ chord", and it is entirely in the ds[].
      let end = t;
      for (let i = 0; i < L.fs.length; i++) {
        const env = adsr(t, L.ps[i] * mul, L.a ?? 0.002, L.ds[i]);
        const osc = oscOf('sine', L.fs[i] * lad, t);
        if (o.detune) osc.detune.setValueAtTime(o.detune, t);
        osc.connect(env.node);
        sinkFor(L, cue, bus, env.node);
        osc.start(t);
        osc.stop(env.end);
        srcs.push(osc);
        if (env.end > end) end = env.end;
      }
      return end;
    }

    // ── tone / noise share the envelope + filter + routing tail ──
    const env = adsr(t, peak, L.a ?? 0.005, dur ?? 0.05, L.sus ?? 0, L.s ?? 0, L.r ?? 0.02, L.aLin);
    let head = null;   // the node the source ultimately reaches the envelope through
    let src = null;

    if (L.k === 'tone') {
      src = oscOf(L.wave, L.f * lad, t);
      if (o.detune) src.detune.setValueAtTime(o.detune, t);
      if (L.f2 != null) {
        src.frequency.setValueAtTime(L.f * lad, t);
        if (L.sweep === 'lin') src.frequency.linearRampToValueAtTime(L.f2 * lad, t + L.ft);
        else src.frequency.exponentialRampToValueAtTime(Math.max(L.f2 * lad, 0.0001), t + L.ft);
      }
      if (L.vib) {
        // Vibrato that DECAYS to zero — a constant vibrato reads as a synth patch, a decaying
        // one reads as something losing its grip. That is the sputter in surge_stall.
        const lfo = oscOf('sine', L.vib.f, t);
        const amt = adsr(t, L.vib.cents, 0.004, L.vib.decay);
        lfo.connect(amt.node);
        amt.node.connect(src.detune);
        lfo.start(t);
        lfo.stop(env.end);
        srcs.push(lfo);
      }
      // LAW 3, both halves: an oscillator must be start()ed (noiseOf does its own, because it
      // needs the random buffer offset) and it must be stop()ed at the tail — and start MUST
      // come first, or stop() throws InvalidStateError and the whole cue is silently lost.
      src.start(t);
      head = src;
    } else {
      src = noiseOf(L.n, t);
      head = src;
      if (L.split != null) {
        // A stereo pair with the right channel delayed — decorrelation without a second source.
        const pl = panner(-L.split), pr = panner(L.split);
        const dl = ctx.createDelay(0.05);
        dl.delayTime.value = L.splitDelay ?? 0.011;
        if (pl && pr) {
          const f1 = L.filt ? applyFilt(L.filt, t, lad, L.filtThen) : ctx.createGain();
          src.connect(f1);
          f1.connect(pl); pl.connect(env.node);
          f1.connect(dl); dl.connect(pr); pr.connect(env.node);
          sinkFor(L, cue, bus, env.node);
          src.stop(env.end);
          srcs.push(src);
          return env.end;
        }
      }
    }

    let chain = head;
    if (L.shape) {
      const sh = shaperNode();
      if (L.shapeFade) {
        // Crossfade OUT of the shaper: distorted for the first N ms, clean after. The blast is
        // saturated at the transient and clean in the tail, which is how real loud things decay.
        const gS = ctx.createGain(), gC = ctx.createGain();
        gS.gain.setValueAtTime(1, t);
        gS.gain.setValueAtTime(1, t + L.shapeFade[0]);
        gS.gain.linearRampToValueAtTime(0, t + L.shapeFade[0] + L.shapeFade[1]);
        gC.gain.setValueAtTime(0, t);
        gC.gain.setValueAtTime(0, t + L.shapeFade[0]);
        gC.gain.linearRampToValueAtTime(1, t + L.shapeFade[0] + L.shapeFade[1]);
        chain.connect(sh); sh.connect(gS);
        chain.connect(gC);
        const sum = ctx.createGain();
        gS.connect(sum); gC.connect(sum);
        chain = sum;
      } else {
        chain.connect(sh);
        chain = sh;
      }
    }

    if (L.filt) {
      const f1 = applyFilt(L.filt, t, lad, L.filtThen);
      chain.connect(f1);
      chain = f1;
      if (L.filt2) {
        const f2 = applyFilt(L.filt2, t, lad, null);
        // Q STEPS, it never sweeps — a swept high-Q biquad is unstable. A step on Q is inaudible.
        if (L.qStep) f2.Q.setValueAtTime(L.qStep[0], t + L.qStep[1]);
        chain.connect(f2);
        chain = f2;
      }
    }

    if (L.choke) {
      // surge_stall: slow LINEAR swell, then a fast EXPONENTIAL choke. The asymmetry is the
      // whole point — it is what "freeze" sounds like. Overwrites the adsr for this layer.
      const g = env.node.gain;
      g.cancelScheduledValues(t);
      g.setValueAtTime(0, t);
      g.linearRampToValueAtTime(peak, t + L.choke[0]);
      g.exponentialRampToValueAtTime(1e-4, t + L.choke[0] + L.choke[1]);
      g.setValueAtTime(0, t + L.choke[0] + L.choke[1] + 0.001);
    }

    chain.connect(env.node);

    if (L.am) {
      // AudioParam-rate AM: the LFO is SUMMED INTO the envelope's gain param. Must be start()ed
      // and stop()ed with the voice or it leaks a running oscillator forever (LAW 3).
      //
      // depth is a FRACTION OF PEAK, not an absolute gain. Absolute depth has two failure modes
      // and this file shipped both: retuning `peak` silently changed the modulation index, and a
      // depth larger than peak (dart: 0.30 against a peak of 0.26) drove the gain param NEGATIVE
      // for most of every LFO cycle — polarity-inverting ring modulation, which is buzzy and
      // cheap, not the muscular push the recipe is describing. As a fraction it cannot invert.
      const lfo = oscOf('sine', L.am.f, t);
      const dep = ctx.createGain();
      lfo.connect(dep);
      dep.connect(env.node.gain);
      // Faded to zero and stopped at the PARK, not at env.end: 19 ms of ±depth on a closed VCA
      // is a re-opened note followed by a hard cut, on every dart in the game.
      modDepth(dep.gain, L.am.depth * peak, t, env.park);
      lfo.start(t);
      lfo.stop(env.park);
      srcs.push(lfo);
    }

    if (L.ctrl) {
      // Control-rate noise into the gain param: free, smooth, zero JS per frame. Same park rule
      // as `am` — this one is a looping buffer, so left running it would flicker a dead VCA.
      const c = ctx.createBufferSource();
      c.buffer = NZ.ctrl;
      c.loop = true;
      const cg = ctx.createGain();
      c.connect(cg);
      cg.connect(env.node.gain);
      modDepth(cg.gain, L.ctrl * mul, t, env.park);
      c.start(t, rand() * 1.5);
      c.stop(env.park);
      srcs.push(c);
    }

    if (L.pan != null) {
      const amt = L.panAmt ?? 0.12;
      const p = panner(L.pan === 'alt' ? (o.side ? amt : -amt) : L.pan);
      if (p) {
        env.node.connect(p);
        sinkFor(L, cue, bus, p);
      } else sinkFor(L, cue, bus, env.node);
    } else {
      sinkFor(L, cue, bus, env.node);
    }

    src.stop(env.end);
    srcs.push(src);
    return env.end;
  }

  // ═══ voice bookkeeping ═══════════════════════════════════════════════════════════════════

  function prune() {
    const t = ctx.currentTime;
    for (let i = live.length - 1; i >= 0; i--) if (live[i].until < t) live.splice(i, 1);
  }

  /** Steal = ramp to silence and stop. NEVER disconnect() a sounding node — that is a click. */
  function kill(v, t) {
    try {
      for (const n of [v.vca, v.wetSum]) {
        if (!n) continue;
        const g = n.gain;
        g.cancelScheduledValues(t);
        g.setValueAtTime(g.value, t);
        g.linearRampToValueAtTime(1e-4, t + 0.008);
      }
      // stop() at a time earlier than a source's scheduled start simply means it never plays —
      // which is exactly what we want for a stolen voice whose pulses were still in the future.
      for (const s of v.srcs) { try { s.stop(t + 0.01); } catch { /* already stopped */ } }
    } catch { /* node already gone */ }
  }

  const lastAt = new Map();     // name -> ctx time of the last accepted voice (retrigger gate)

  function admit(name, cue, t) {
    prune();
    const rt = cue?.retrig ?? 0;
    if (rt > 0 && t - (lastAt.get(name) ?? -1e9) < rt) return false;
    const cap = cue?.cap ?? 4;
    let n = 0, oldest = null;
    for (const v of live) if (v.name === name) { n++; if (!oldest || v.start < oldest.start) oldest = v; }
    if (n >= cap) {
      if (!oldest) return false;
      kill(oldest, t);
      live.splice(live.indexOf(oldest), 1);
    } else if (live.length >= ceiling) {
      // Global ceiling with nothing of our own name to steal: DROP. Stealing across names is
      // how a cannon burst eats your surge warning.
      return false;
    }
    lastAt.set(name, t);
    return true;
  }

  function emit(name, cue, layers, t, o, sink) {
    // prune() lives HERE and not only in admit(), because the cues that bypass admit() are
    // exactly the ones that fire forever: the heartbeat (1-3/s for the whole session) and the
    // warnings (7 emits each). Pruned only from admit(), `live` grew ~2-6 entries/s during any
    // stretch with no admitted cue, each retaining a GainNode and 3-5 AudioNodes that could then
    // never be collected — five idle minutes was ~9000 retained nodes, and dispose() then had to
    // kill() every one of them. Pruning on the way IN costs one pass over a list that is, by
    // construction, at most `ceiling` long.
    prune();
    const r = render(cue, layers, t, o, sink || destination);
    live.push({ name, vca: r.vca, wetSum: r.wetSum, srcs: r.srcs, until: r.end + 0.2, start: t });
    return r;
  }

  // ═══ stateful voices ═════════════════════════════════════════════════════════════════════

  // ── wall_scrape: ONE continuous voice, built on first use, then never stopped ─────────────
  // This is a DSP hazard, not a taste call. The cue fires many times per second while you grind
  // a wall; a voice per cue is a machine-gun stutter that burns the voice budget and is the
  // single biggest source of mix mud in this genre. Instead: a permanently running noise source
  // whose VCA is re-excited per cue and decays asymptotically. ~0.1 % of a core, forever.
  //
  // LAZY, because the engine builds four synths and routes wall_scrape to exactly ONE of them.
  // Built eagerly it was four always-running voices — two looping BufferSources and four biquads
  // each — three of which could never be excited by anything. "Forever" is a promise you only get
  // to make about a voice something can actually play.
  let scrapeVCA = null, scrapeBP = null, scrapeBuilt = false;
  function buildScrape() {
    scrapeBuilt = true;
    try {
      const src = ctx.createBufferSource();
      src.buffer = NZ.pink;
      src.loop = true;
      scrapeBP = biquad('bandpass', 1100, 3.5);
      const lp = biquad('lowpass', 500, 1.0);          // parallel "meat" path
      scrapeVCA = ctx.createGain();
      scrapeVCA.gain.value = 0;
      src.connect(scrapeBP); scrapeBP.connect(scrapeVCA);
      src.connect(lp); lp.connect(scrapeVCA);
      scrapeVCA.connect(destination);
      const w = ctx.createGain();
      w.gain.value = 0.70;
      scrapeVCA.connect(w); w.connect(cavityIn);
      // jitter: a second looping noise source driving the bandpass = the texture of DRAGGING
      const j = ctx.createBufferSource();
      j.buffer = NZ.ctrl;
      j.loop = true;
      const jg = ctx.createGain();
      jg.gain.value = 400;
      j.connect(jg); jg.connect(scrapeBP.frequency);
      src.start(0, rand() * 1.5);
      j.start(0, rand() * 1.5);
      perm.push(src, j);
      nodes.push(scrapeBP, lp, scrapeVCA, w, jg);
    } catch { scrapeVCA = null; }
  }

  function scrape(amount, t) {
    if (!scrapeBuilt) buildScrape();
    if (!scrapeVCA) return false;
    // `6` is a normalisation floor for `amount`, chosen because combat/balance.js cannot be
    // imported (House Law 1). Retune here if wall damage is rebalanced.
    const peak = 0.12 + 0.20 * Math.min((amount ?? 3) / 6, 1);
    const g = scrapeVCA.gain;
    g.cancelScheduledValues(t);
    g.setValueAtTime(g.value, t);          // anchor at the CURRENT value, or the ramp starts
    g.linearRampToValueAtTime(peak, t + 0.012);   // from a stale SCHEDULED value and jumps
    g.setTargetAtTime(0, t + 0.012, 0.11);        // asymptotic: below -80 dB in ~0.9 s
    const f = scrapeBP.frequency;
    f.cancelScheduledValues(t);
    f.setValueAtTime(f.value, t);
    f.linearRampToValueAtTime(900 + rand() * 700, t + 0.05);
    return true;
  }

  // ── overheat: a LOCKOUT STATE, not a beep ────────────────────────────────────────────────
  // A one-shot here is a design bug: the player mashes fire into silence and concludes the game
  // is broken. The steam SUSTAINS until the engine calls play('overheat_clear') on the falling
  // edge of combat:state.overheated (there is no overheat_clear cue in the bus — §9 asks C for
  // a `weapon:heat_clear` edge; until then the engine tracks it).
  let heatVoice = null;

  function overheat(t, o) {
    overheatClear(t, true);
    const srcs = [];
    const vca = ctx.createGain();
    vca.gain.value = 1;
    vca.connect(destination);

    // steam: sustains indefinitely (r is applied later, by overheatClear)
    const steam = ctx.createGain();
    const hold = Math.max(0.10 * (o.gain ?? 1), 1e-4);
    steam.gain.setValueAtTime(0, t);
    steam.gain.linearRampToValueAtTime(0.34 * (o.gain ?? 1), t + 0.010);
    steam.gain.exponentialRampToValueAtTime(hold, t + 0.130);
    // SAFETY NET. This layer is designed to sustain until the engine calls overheat_clear on the
    // falling edge of combat:state.overheated — but if that call never arrives (a routing bug, a
    // missed edge, a level torn down mid-lockout) the steam would hiss forever, and "the audio is
    // permanently broken" is a far worse failure than "the lockout tone ended early". So the
    // release is ALSO pre-scheduled on the audio clock at +12 s, which is far longer than any
    // real lockout. overheat_clear cancels it, so in the normal path this never fires.
    const CAP = 12;
    steam.gain.setValueAtTime(hold, t + CAP);
    steam.gain.exponentialRampToValueAtTime(1e-4, t + CAP + 0.4);
    steam.gain.setValueAtTime(0, t + CAP + 0.41);
    const sf = biquad('highpass', 2400, 0.7);
    sf.frequency.setValueAtTime(2400, t);
    sf.frequency.exponentialRampToValueAtTime(5200, t + 0.700);
    const sn = noiseOf('white', t);
    sn.connect(sf); sf.connect(steam); steam.connect(vca);
    // 3.2 Hz tremolo on the held steam — a STATE that breathes, so it never becomes wallpaper.
    // Its depth is a fraction of the SUSTAINED level, and it must be ramped out with the steam:
    // left at full depth through the release, the steam does not fade to silence, it fades to a
    // 78 ms tremolo ~8 dB down and then clicks off. The gain and its modulator die together or
    // neither of them does. overheatClear() ramps this; the +12 s safety net below pre-schedules
    // the same ramp so the un-cleared path releases just as cleanly.
    const trem = oscOf('sine', 3.2, t);
    const td = ctx.createGain();
    const tdDepth = hold * 0.40;
    td.gain.setValueAtTime(tdDepth, t);
    td.gain.setValueAtTime(tdDepth, t + CAP);
    td.gain.linearRampToValueAtTime(0, t + CAP + 0.4);
    trem.connect(td); td.connect(steam.gain);
    trem.start(t);
    srcs.push(sn, trem);

    // fault tone: two pitches beating at ~128 Hz. A FAULT, not a siren (§8 bans sirens).
    const ftPeak = 0.28 * (o.gain ?? 1);
    const ft = adsr(t, ftPeak, 0.006, 0.010, 1.0, 0.60, 0.12);
    const fb = biquad('bandpass', 1000, 3);
    const sq = oscOf('square', 1046.5, t);
    const si = oscOf('sine', 1174.7, t);
    const sig = ctx.createGain();
    sig.gain.value = 0.5;
    sq.connect(fb);
    si.connect(sig); sig.connect(fb);
    fb.connect(ft.node); ft.node.connect(vca);
    // The 11 Hz gate. THREE things were wrong with it and all three were audible:
    //  - it was a SQUARE, i.e. an instantaneous jump on a live gain param eleven times a second.
    //    That is LAW 1 broken by proxy — the discontinuity is in the modulator, but the click
    //    comes out of the VCA all the same. A triangle gates just as hard to the ear and has no
    //    step in it; the fault reads as a relay chattering, not as a fizz of clicks.
    //  - its depth was an absolute 0.5 against a tone sustaining at 0.28, so it inverted polarity
    //    every cycle and ran the fault tone at ~1.8x its designed peak. Now it is a fraction.
    //  - it ran 19 ms past the envelope's park, re-opening a dead VCA at ±0.5 and then hard-
    //    stopping: the loudest click in the file. It now fades and stops at ft.park.
    const gate = oscOf('triangle', 11, t);
    const gd = ctx.createGain();
    gate.connect(gd); gd.connect(ft.node.gain);
    modDepth(gd.gain, 0.45 * ftPeak, t, ft.park);
    sq.start(t); si.start(t); gate.start(t);
    sq.stop(ft.end); si.stop(ft.end); gate.stop(ft.park);
    srcs.push(sq, si, gate);

    // seize: the mechanism stopping
    const sz = adsr(t, 0.24 * (o.gain ?? 1), 0.004, 0.200);
    const so = oscOf('sine', 150, t);
    so.frequency.setValueAtTime(150, t);
    so.frequency.exponentialRampToValueAtTime(60, t + 0.080);
    so.connect(sz.node); sz.node.connect(vca);
    so.start(t); so.stop(sz.end);
    srcs.push(so);

    // A later stop() supersedes an earlier one, so overheat_clear's 0.30 s stop wins over this.
    sn.stop(t + CAP + 0.5);
    trem.stop(t + CAP + 0.5);
    heatVoice = { vca, srcs, steam, td, sustained: [sn, trem] };
    return true;
  }

  function overheatClear(t, silent = false) {
    if (!heatVoice) return false;
    const v = heatVoice;
    heatVoice = null;
    try {
      const g = anchor(v.steam.gain, t);
      g.exponentialRampToValueAtTime(1e-4, t + 0.220);          // 220 ms release
      g.setValueAtTime(0, t + 0.222);
      // The tremolo depth releases WITH the steam, over the same 220 ms — see overheat(). Held
      // flat it would be all that is left after the gain parks, and a release that ends in a
      // tremolo is not a release.
      if (v.td) {
        const depth = Number.isFinite(v.td.gain.value) ? v.td.gain.value : 0;
        v.td.gain.cancelScheduledValues(t);
        modDepth(v.td.gain, depth, t, t + 0.220);
      }
      for (const s of v.sustained) { try { s.stop(t + 0.30); } catch { /* stopped */ } }
      for (const s of v.srcs) { try { s.stop(t + 0.30); } catch { /* stopped */ } }
      // The lockout's own VCA is not in `nodes` (a session can have many lockouts and `nodes` is
      // for the permanent graph), so it is disconnected here once the release has landed.
      dropLater([v.vca], 400);
      if (!silent) {
        // the clear tick RISES (Law A: you got something back)
        const e = adsr(t, 0.12, 0.002, 0.090);
        const o = oscOf('sine', 1600, t);
        o.frequency.setValueAtTime(1600, t);
        o.frequency.exponentialRampToValueAtTime(2200, t + 0.008);
        o.connect(e.node); e.node.connect(destination);
        o.start(t); o.stop(e.end);
      }
    } catch { /* context gone */ }
    return true;
  }

  // ── the surge chase loop ─────────────────────────────────────────────────────────────────
  // Starts with surge_start, runs until surge_end. Its gain follows proximity — but ALWAYS via
  // setTargetAtTime, never setValueAtTime per frame, which zippers audibly.
  let chase = null;
  const BASE = 0.10;    // the loop's nominal gain
  const WOB = 0.30;     // 7 Hz wobble depth, AS A FRACTION OF THE GAIN — see startChase

  function startChase(t) {
    if (chase) return;
    try {
      const src = noiseOf('white', t);
      const hp = biquad('highpass', 2500, 0.7);
      const vca = ctx.createGain();
      vca.gain.setValueAtTime(1e-4, t);
      vca.gain.linearRampToValueAtTime(BASE, t + 1.5);
      // The 7 Hz wobble's depth is a FRACTION of the chase gain and it is ramped with it, always.
      // Fixed at 0.030 against a base of 0.10 it broke every transition this loop has: the fade-in
      // popped in at ±0.030 instead of rising from silence, the 1.2 s fade-out bottomed out at
      // -10.5 dB of throbbing instead of reaching zero, and `stall` (base*0.35 = 0.035) came out
      // as 86 % modulation — a violent throb, not "thin and quiet". A modulator that cannot be
      // ramped is a gain that cannot be ramped.
      const lfo = oscOf('sine', 7, t);
      const ld = ctx.createGain();
      ld.gain.setValueAtTime(1e-6, t);
      ld.gain.linearRampToValueAtTime(BASE * WOB, t + 1.5);
      lfo.connect(ld); ld.connect(vca.gain);
      lfo.start(t);
      src.connect(hp); hp.connect(vca); vca.connect(surgeIn);
      // a continuous 33 Hz floor under the fizz — the mass of the thing, not its surface
      const sub = oscOf('sine', 33, t);
      const sg = ctx.createGain();
      sg.gain.setValueAtTime(1e-4, t);
      sg.gain.linearRampToValueAtTime(0.06, t + 1.5);
      sub.connect(sg); sg.connect(surgeIn);
      sub.start(t);
      chase = { src, hp, vca, lfo, ld, sub, sg, base: BASE, wob: WOB, stallT: 0 };
      perm.push(src, lfo, sub);
    } catch { chase = null; }
  }


  /** proximity 0..1 (1 = on top of you). Drives the chase gain AND the rear channel's colour. */
  function chaseTo(p, t) {
    p = p > 1 ? 1 : p < 0 ? 0 : p;
    if (chase && !chase.stallT) {
      // Not while stalled: proximity keeps arriving during a stall (hazard:proximity fires either
      // way) and would fight the thinning ramp for control of the same param.
      const g = Math.max(chase.base * (0.5 + 1.6 * p), 1e-4);
      chase.vca.gain.setTargetAtTime(g, t, 0.08);
      chase.ld.gain.setTargetAtTime(Math.max(g * chase.wob, 1e-6), t, 0.08);
      chase.sg.gain.setTargetAtTime(Math.max(0.06 * (0.5 + 1.4 * p), 1e-4), t, 0.08);
    }
    // brighter and narrower as it closes: the whole level's tension in two params
    surgeShelf.gain.setTargetAtTime(-6 + 6 * p, t, 0.4);
    surgeDelayL.delayTime.setTargetAtTime(0.012 - 0.010 * p, t, 0.4);
    return true;
  }

  // A neutralised surge is parked for balance.js's `neutralizeDuration` (10 s at time of writing).
  // SELF-HEALING, for the same reason overheat() pre-schedules its own release: nothing in the
  // repo emits `surge_unstall`, so a stall that waited for a caller would leave the rear channel
  // thin and quiet FOR THE REST OF THE LEVEL after a single antacid torpedo — an audible
  // regression, not a missing feature. The recovery is therefore scheduled on the audio clock at
  // +13 s (comfortably past any real stall). If `surge_unstall` ever does arrive it simply
  // cancels this and recovers early, which is the correct precedence.
  const STALL_CAP = 13;

  function stall(t, on) {
    if (!chase) return;
    const c = chase;
    const f = c.hp.frequency, g = c.vca.gain, ld = c.ld.gain;
    const thin = Math.max(c.base * 0.35, 1e-4);
    // All three params are re-anchored together; `ld` moves with `g` everywhere in this file.
    f.cancelScheduledValues(t); f.setValueAtTime(f.value, t);
    g.cancelScheduledValues(t); g.setValueAtTime(Math.max(g.value, 1e-4), t);
    ld.cancelScheduledValues(t); ld.setValueAtTime(Math.max(ld.value, 1e-6), t);
    if (on) {
      // thin and quiet: the acid is still there, it just cannot reach you for a moment
      c.stallT = t;
      f.exponentialRampToValueAtTime(6000, t + 0.250);
      g.linearRampToValueAtTime(thin, t + 0.250);
      ld.linearRampToValueAtTime(Math.max(thin * c.wob, 1e-6), t + 0.250);
      // ...and the recovery, pre-scheduled on the audio clock right now. See STALL_CAP.
      const r = t + STALL_CAP;
      f.exponentialRampToValueAtTime(6000, r);
      f.exponentialRampToValueAtTime(2500, r + 0.800);
      g.setValueAtTime(thin, r);
      g.linearRampToValueAtTime(c.base, r + 0.800);
      ld.setValueAtTime(Math.max(thin * c.wob, 1e-6), r);
      ld.linearRampToValueAtTime(Math.max(c.base * c.wob, 1e-6), r + 0.800);
      // the JS-side flag, so chaseTo() takes the params back once that ramp has landed
      later(() => { if (chase === c) c.stallT = 0; }, (STALL_CAP + 0.8) * 1000);
    } else {
      c.stallT = 0;
      f.exponentialRampToValueAtTime(2500, t + 0.800);
      g.linearRampToValueAtTime(c.base, t + 0.800);
      ld.linearRampToValueAtTime(Math.max(c.base * c.wob, 1e-6), t + 0.800);
    }
  }

  function endChase(t) {
    if (!chase) return;
    const c = chase;
    chase = null;
    try {
      anchor(c.vca.gain, t).exponentialRampToValueAtTime(1e-4, t + 1.2);
      anchor(c.sg.gain, t).exponentialRampToValueAtTime(1e-4, t + 1.2);
      // The wobble depth fades WITH the gain. Left at full depth the "fade" bottoms out about
      // 10 dB below the chase, throbbing at 7 Hz, until the source is cut dead — which is the
      // one transition in the level the player is guaranteed to be listening to.
      c.ld.gain.cancelScheduledValues(t);
      c.ld.gain.setValueAtTime(Math.max(c.ld.gain.value, 1e-6), t);
      c.ld.gain.linearRampToValueAtTime(1e-6, t + 1.15);
      for (const s of [c.src, c.lfo, c.sub]) {
        try { s.stop(t + 1.25); } catch { /* stopped */ }
        const i = perm.indexOf(s);
        if (i >= 0) perm.splice(i, 1);
      }
      dropLater([c.hp, c.vca, c.ld, c.sg], 1400);   // not in `nodes`: one per surge, not permanent
    } catch { /* context gone */ }
    surgeShelf.gain.setTargetAtTime(-6, t, 0.4);
    surgeDelayL.delayTime.setTargetAtTime(0.012, t, 0.4);
  }

  // ── death: a global state change, not a cue ──────────────────────────────────────────────
  // WE DO NOT FILL THE SILENCE. Everybody's instinct is to put a tone in the gap; don't. A feed
  // that drops does not make a sound — that is what dropping MEANS. The 180 ms of nothing is the
  // loudest thing in the game, and it is the engine's master duck that produces it. What this
  // module owns is what comes back AFTER: broken static, and one distant body thump.
  //
  // The 1 kHz flatline tone was explicitly rejected — it is a hospital cliché that reaches
  // outside the fiction, and the engine's carrier detune does the same job diegetically.
  let staticVoice = null;

  function death(t, o) {
    if (staticVoice) return true;                 // idempotent: two death paths can both fire
    try {
      const src = noiseOf('white', t);
      const bp = biquad('bandpass', 3400, 2);
      const g = ctx.createGain();
      const st = t + 0.350;
      g.gain.setValueAtTime(0, t);
      g.gain.linearRampToValueAtTime(STATIC_STEPS[0] * 0.22, st + 0.002);
      // Stepped, NOT ramped — this is the one place a discontinuity is the point: it is meant
      // to sound like a broken decoder. Only the first and last edges are ramped, so the burst
      // itself doesn't click against the bus. 12 s of table, held until respawn.
      let k = 0;
      for (let rep = 0; rep < 25; rep++) {
        for (let i = 0; i < STATIC_STEPS.length; i++, k++) {
          g.gain.setValueAtTime(STATIC_STEPS[i] * 0.22 * (o.gain ?? 1), st + k * 0.040);
        }
      }
      // THE LAST EDGE, which the comment above has always promised and the code did not deliver:
      // the table used to end holding at whatever step it landed on and the source was simply
      // stopped, which is a step to zero — the exact click LAW 1 exists to prevent, in the game's
      // most exposed moment of silence. 120 ms of release, then park, then stop behind it.
      const off = st + k * 0.040;
      g.gain.linearRampToValueAtTime(1e-4, off + 0.120);
      g.gain.setValueAtTime(0, off + 0.122);
      src.connect(bp); bp.connect(g); g.connect(destination);
      const stopAt = off + 0.2;
      src.stop(stopAt);
      staticVoice = { src, g, stopAt };
      perm.push(src);
      // SELF-CLEARING, and it has to be: nothing in the repo emits `respawn`, and death() is
      // idempotent on `staticVoice`. Latched non-null forever, the SECOND death in a session
      // produced no audio at all. The handle is released when the static has actually finished,
      // so a later death re-arms. (respawn() clears it early; this is only the floor.)
      later(() => {
        if (staticVoice && staticVoice.src === src) {
          staticVoice = null;
          const i = perm.indexOf(src);
          if (i >= 0) perm.splice(i, 1);
        }
      }, Math.max((stopAt - ctx.currentTime) * 1000 + 50, 50));

      // The body's last thump: 55 Hz through a 200 Hz lowpass, mostly dry. It is NOT "distance" —
      // the wet copy is largely eaten by the cavity's 120 Hz highpass, so what you actually hear
      // is a close, dull thud. That is the honest description, and it is the right sound: the
      // body is not far away, it is right here and it has stopped.
      const e = adsr(t + 0.900, 0.30 * (o.gain ?? 1), 0.040, 1.40);
      const lp = biquad('lowpass', 200, 0.7);
      const osc = oscOf('sine', 55, t + 0.900);
      osc.connect(lp); lp.connect(e.node);
      const w = ctx.createGain();
      w.gain.value = 0.9;
      e.node.connect(w); w.connect(cavityIn);
      e.node.connect(destination);
      osc.start(t + 0.900);
      osc.stop(e.end);
      // Tracked as a voice, so dispose() can FADE it. Untracked it was reachable from nothing —
      // a 55 Hz thump that kept playing into the engine's bus for up to 1.44 s after teardown.
      live.push({ name: 'death_thump', vca: e.node, srcs: [osc], until: e.end + 0.2, start: t + 0.900 });
    } catch { /* context gone */ }
    return true;
  }

  function respawn(t, o) {
    // the scanner locking back on
    if (staticVoice) {
      const v = staticVoice;
      staticVoice = null;
      try {
        anchor(v.g.gain, t).exponentialRampToValueAtTime(1e-4, t + 0.300);
        v.src.stop(t + 0.32);
        const i = perm.indexOf(v.src);
        if (i >= 0) perm.splice(i, 1);
      } catch { /* stopped */ }
    }
    emit('respawn', { wet: 0.2 }, [
      { k: 'noise', n: 'pink', at: 0.10, filt: ['lowpass', 200, 0.8, 4000, 0.400], peak: 0.18, a: 0.020, d: 0.400 },
      { k: 'noise', n: 'white', at: 0.10, filt: ['bandpass', 2400, 6], peak: 0.10, a: 0.001, d: 0.018, rep: 3, every: 0.090 },
      { k: 'tone', wave: 'sine', at: 0.50, f: 1046.5, peak: 0.16, a: 0.006, d: 0.140, wet: 0 },
    ], t, o);
    return true;
  }

  // ── the warnings ─────────────────────────────────────────────────────────────────────────
  //
  // THE ACCELERANDO IS THE INFORMATION, so the ratio between the two gaps has to be big enough to
  // hear as acceleration and not merely as "three beeps". At eta 2.5 the offsets below give
  // inter-onsets of 1.10 s then 0.60 s — a ratio of 1.83, comfortably inside the 1.6-2.0 that
  // reads as "closing". The previous [-1.9, -1.0, -0.35] gave 0.90 / 0.65 = 1.38, which is
  // detectable but leaves nearly all the "NOW" to the lift and the +3 semitones on pulse 3.
  // ⚠ engine.js schedules its warning ducks off a private copy of these offsets — if you retune
  // WARN_OFFS, retune that copy too or the ducks drift off the beeps.
  const WARN_OFFS = [-2.00, -0.90, -0.30];
  const WARN_TIGHT = [0, 0.180, 0.360];    // eta too short to accelerate into: just fire three
  // Pulse 3's lift, as a RATIO. It was written `0.34 / 0.22`, where 0.22 was silently the blip
  // `peak` from the WARN table — retune a blip peak and the lift became an arbitrary number.
  const WARN_LAST_LIFT = 1.55;

  function warn(name, t, o) {
    const W = WARN[name];
    if (!W) return false;
    // Warnings bypass admit(), so a warning can never be DROPPED because a firefight filled the
    // voice list — that is the exact failure the mix is built to avoid. (They still push to
    // `live` and so still occupy ceiling slots against later cues; "exempt" means they cannot be
    // refused, not that they are free.) They get a retrigger guard instead, so a re-armed hazard
    // cannot stack two accelerandos.
    if (t - (lastAt.get(name) ?? -1e9) < 1.0) return false;
    lastAt.set(name, t);
    const cue = { wet: W.wet };
    const sink = W.surge ? surgeIn : destination;

    // ETA scheduling. `eta` comes straight off hazard:warn (seconds of lead, default 2.5).
    const eta = Number.isFinite(o.eta) ? o.eta : 2.5;
    const rel = eta >= 1.2 ? WARN_OFFS.map((x) => eta + x) : WARN_TIGHT;

    for (let i = 0; i < 3; i++) {
      const at = Math.max(t + rel[i], t + 0.02);
      const last = i === 2;
      // Pulse 3 is louder AND +3 semitones — the accelerando plus a lift is what turns three
      // beeps into "it is arriving NOW".
      const g = last ? WARN_LAST_LIFT : 1.0;
      // EXCEPTION, and it is deliberate: reflux does NOT pitch up on pulse 3. It is the only
      // descending motif in the game (Law A: pitch falls = you lost) and raising the last note
      // would break the one contour that tells you the thing is BEHIND you.
      const step = last && name !== 'warn_reflux_surge' ? 3 : 0;
      const dur = W.durs ? W.durs[i] : W.blipDur;
      const taps = W.taps || [0];

      for (const tap of taps) {
        const layers = W.blip.map((L) => {
          // blipDur/durs set the length of the PITCHED blip only. Applied to every layer it
          // stretched warn_ring_gate's deliberate 5 ms transient click (L.d 0.005) to ~66 ms,
          // turning "a single sharp tick, high and clean" into a noise wash. A transient layer
          // keeps its own decay; it is a transient because it is short.
          const c = L.k === 'tone'
            ? { ...L, durOverride: Math.max(dur - (L.a ?? 0.004), 0.004) }
            : { ...L };
          // reflux walks down its own note table across the three pulses
          if (W.notes && c.k === 'tone') c.f = W.notes[Math.min(i, W.notes.length - 1)];
          return c;
        });
        emit(name, cue, layers, at + tap, { ...o, mul: (o.mul ?? 1) * g, step }, sink);
      }
    }

    // The fourth reflux note, 150 ms after the last pulse: the motif completing as it lands.
    if (W.notes) {
      const at = Math.max(t + rel[2], t + 0.02) + 0.150;
      emit(name, cue, [{ ...W.blip[0], f: W.notes[3], durOverride: 0.170 }], at, { ...o, mul: (o.mul ?? 1) * WARN_LAST_LIFT }, sink);
    }

    // The body: one sustained answer underneath the WHOLE warning, wet. Its sustain is stretched
    // to cover the last pulse, because the pulses are placed relative to `eta` and the body is
    // not: at the table's nominal ~2.2 s, any eta above ~2.6 fired pulse 3 (and the reflux fourth
    // note) after the body had already died, and "the instrument BLIPS, the body ANSWERS
    // underneath" is the entire grammar of the three warnings. It is one max(), and without it
    // the grammar silently breaks on exactly the long-lead warnings that need it most.
    const bodyFor = Math.max(rel[2], 0.02) + 0.35;
    const body = W.body.map((L) => {
      if (!(L.sus > 0)) return L;
      const head = (L.a ?? 0.005) + (L.d ?? 0.05);
      return { ...L, s: Math.max(L.s ?? 0, bodyFor - head) };
    });
    emit(name, cue, body, t, o, sink);
    return true;
  }

  // ── the heartbeat ────────────────────────────────────────────────────────────────────────
  // The engine calls this on its own ctx.currentTime schedule. `opts.d` is the danger scalar,
  // `opts.gap` the S1->S2 spacing it computed. Danger colours TIMBRE, not just rate — that is
  // what keeps it a body rather than a metronome.
  function beat(t, o, which) {
    prune();          // beat() pushes to `live` directly; without this the list never shrinks
    const d = Math.min(Math.max(o.d ?? 0, 0), 1);
    const gain = o.gain ?? 1;
    const D = HEART.dyn;

    const one = (P, at, isLub) => {
      const srcs = [];
      const vca = ctx.createGain();
      vca.gain.value = 1;
      vca.connect(destination);
      const gm = (P.gainMul ?? 1) * gain;
      const dm = P.decayMul ?? 1;

      // body — the pitch RISES with danger (62 -> 71 Hz) as well as the tempo
      const body = adsr(at, P.peak * (D.gainBase + D.gainD * d) * gm, P.a, P.d * dm);
      const bo = oscOf('sine', P.f + P.fd * d, at);
      bo.frequency.setValueAtTime(P.f + P.fd * d, at);
      bo.frequency.exponentialRampToValueAtTime(P.f2, at + P.ft);
      if (isLub && d > D.tDistort) {
        // Harmonic distortion, NOT volume, is how a heart sounds like it is working too hard.
        const sh = shaperNode();
        bo.connect(sh); sh.connect(body.node);
      } else bo.connect(body.node);
      body.node.connect(vca);
      bo.start(at); bo.stop(body.end);
      srcs.push(bo);

      // valve slap — the cutoff OPENING with danger is the anxiety. Same note, tenser body.
      const slap = adsr(at, P.slapPeak * gm, 0.004, P.slapD * dm);
      const lp = biquad('lowpass', D.valveF + D.valveFD * d, 1.4);
      const sn = noiseOf('brown', at);
      sn.connect(lp); lp.connect(slap.node); slap.node.connect(vca);
      sn.stop(slap.end);
      srcs.push(sn);

      // floor
      const fl = adsr(at, P.floorPeak * gm, 0.008, P.floorD * dm);
      const fo = oscOf('sine', D.floorF, at);
      fo.connect(fl.node); fl.node.connect(vca);
      fo.start(at); fo.stop(fl.end);
      srcs.push(fo);

      if (isLub && d > D.tStrain) {
        // strain WITHOUT loudness
        const c = adsr(at, D.strainPeak * ((d - D.tStrain) / (1 - D.tStrain)) * gm, 0.001, 0.008);
        const cb = biquad('bandpass', 900, 6);
        const cn = noiseOf('white', at);
        cn.connect(cb); cb.connect(c.node); c.node.connect(vca);
        cn.stop(c.end);
        srcs.push(cn);
      }
      if (isLub && d > D.tMurmur) {
        const m = adsr(at, D.murmurPeak * ((d - D.tMurmur) / (1 - D.tMurmur)) * gm, 0.002, 0.040);
        const mb = biquad('bandpass', 240, 5);
        const mn = noiseOf('white', at);
        mn.connect(mb); mb.connect(m.node); m.node.connect(vca);
        mn.stop(m.end);
        srcs.push(mn);
      }
      live.push({ name: 'heartbeat', vca, srcs, until: at + 0.6, start: at });
    };

    if (which === 's2') { one(HEART.s2, t, false); return true; }
    one(HEART.s1, t, true);
    if (which === 's1') return true;

    // Above HEART.dyn.tSingle: THE DOUBLE-THUMP COLLAPSES TO A SINGLE THUMP. At that rate the ventricle cannot
    // fill. It is anatomically correct, and it is the most frightening thing in this file —
    // because the player hears the RHYTHM break, not just the tempo.
    if (d <= D.tSingle) {
      // Humanise only while calm. At high danger the beat should be mechanical.
      const jitter = d < D.tJitter ? (rand() * 2 - 1) * D.jitter : 0;
      const gap = (o.gap ?? 0.36) * (1 + jitter);
      one(HEART.s2, t + gap, false);
    }
    return true;
  }

  // ═══ per-cue play hooks (the small amount of state a cue may own) ═════════════════════════

  let cannonI = 0, dartSide = 0, cannonSide = 0;
  let hitN = 0, hitAt = -1e9;         // enemy_hit consecutive ladder
  let coinN = 0, coinAt = -1e9;       // pickup coin ladder
  let sampleN = 0;                    // biopsy samples this level

  const SPECIAL = {
    cannon(t, o) {
      const cue = T.cannon;
      if (!admit('cannon', cue, t)) return false;
      // NO VOICE-SUM TAPER HERE. There used to be one (an obfuscated `1 - 0.375*min(n/3,1)`,
      // written as `taper/0.32`) and the engine applies exactly the same guard before it calls
      // us, forwarding the result in `gain`. Applied twice, sustained fire pushed the synth layer
      // to 0.625 x 0.625 = -8 dB while the sampled layer (cannon is a SAMPLE_IS_LAYER cue) only
      // took the single 0.625 — so the synthesized click and body, the parts that must never be
      // delegated to a sample, collapsed underneath it exactly when you are firing hardest, and
      // the sampled and synth-only builds ended up with different cannon dynamics. One owner:
      // the engine. If that guard is ever removed there, reinstate it here.
      const i = cannonI++;
      const dt = CANNON_DT[i & 7];
      const heat = Math.min(Math.max(o.heat ?? 0, 0), 1);
      cannonSide ^= 1;
      const layers = T.cannon.layers.map((L) => {
        const c = { ...L };
        if (c.k === 'tone') { c.f = L.f * dt; c.f2 = L.f2 * dt; }
        // The gun audibly SOURS before it locks out, so `overheat` is never a surprise. Free
        // telegraphy: one detune term and one Q term, no new UI, no new event.
        if (c.k === 'noise' && c.filt && c.filt[0] === 'bandpass') {
          c.filt = [c.filt[0], c.filt[1], 1.2 + 2.8 * heat, c.filt[3], c.filt[4]];
        }
        return c;
      });
      emit('cannon', cue, layers, t, {
        ...o,
        detune: (o.detune ?? 0) - 200 * heat,
        mul: (o.mul ?? 1) * CANNON_GAIN[i & 3],
        side: cannonSide,
      });
      return true;
    },

    dart(t, o) {
      if (!admit('dart', T.dart, t)) return false;
      dartSide ^= 1;
      // Alternating pan is a FAKE and it is the weakest thing in this file. It needs an azimuth
      // from Lane C (see the report) — until then, at least consecutive darts separate.
      emit('dart', T.dart, T.dart.layers, t, { ...o, side: dartSide });
      return true;
    },

    enemy_hit(t, o) {
      if (!admit('enemy_hit', T.enemy_hit, t)) return false;
      hitN = t - hitAt < 0.9 ? Math.min(hitN + 1, 5) : 0;
      hitAt = t;
      emit('enemy_hit', T.enemy_hit, T.enemy_hit.layers, t, { ...o, step: o.step ?? hitN });
      return true;
    },

    enemy_die(t, o) {
      if (!admit('enemy_die', T.enemy_die, t)) return false;
      hitN = 0;
      // Chained kills walk a chromatic scale. THIS is where combo lives — the heart belongs to
      // the patient, not to your kill streak. Danger raises the pulse; skill sharpens the readout.
      const step = Math.min(o.combo ?? 0, 7);
      emit('enemy_die', T.enemy_die, T.enemy_die.layers, t, { ...o, step });
      return true;
    },

    pickup(t, o) {
      if (!admit('pickup', T.pickup, t)) return false;
      coinN = t - coinAt < 1.5 ? Math.min(coinN + 2, 8) : 0;
      coinAt = t;
      emit('pickup', T.pickup, T.pickup.layers, t, { ...o, step: o.step ?? coinN });
      return true;
    },

    pickup_sample(t, o) {
      if (!admit('pickup_sample', T.pickup_sample, t)) return false;
      sampleN++;
      const layers = T.pickup_sample.layers.slice();
      if ((o.third ?? sampleN) >= 3) {
        // the set is complete — one low note that was not there for samples 1 and 2
        layers.push({ k: 'tone', wave: 'sine', f: 261.63, peak: 0.14, a: 0.040, d: 0.900, wet: 0.3 });
      }
      emit('pickup_sample', T.pickup_sample, layers, t, o);
      return true;
    },

    wall_scrape(t, o) { return scrape(o.amount, t); },

    overheat(t, o) { return overheat(t, o); },
    overheat_clear(t) { return overheatClear(t); },

    surge_start(t, o) {
      if (!admit('surge_start', T.surge_start, t)) return false;
      emit('surge_start', T.surge_start, T.surge_start.layers, t, o, surgeIn);
      startChase(t + 1.5);        // the 2.6 s cue hands off to the loop as it decays
      return true;
    },
    // `p`, NOT `gain`. `gain` is the master multiplier in the contract (see play()'s doc block and
    // `mul` in buildLayer), so reading proximity out of it meant that the moment anyone wired this
    // cue through the generic router — which always passes gain 1 — the chase would pin to
    // maximum permanently and never move again. Two meanings for one key is a trap, not an API.
    surge_chase(t, o) { return chaseTo(o.p ?? 0, t); },
    surge_stall(t, o) {
      if (!admit('surge_stall', T.surge_stall, t)) return false;
      emit('surge_stall', T.surge_stall, T.surge_stall.layers, t, o, surgeIn);
      stall(t, true);
      return true;
    },
    surge_unstall(t) { stall(t, false); return true; },
    surge_end(t, o) {
      if (!admit('surge_end', T.surge_end, t)) return false;
      emit('surge_end', T.surge_end, T.surge_end.layers, t, o, surgeIn);
      endChase(t);
      return true;
    },

    death(t, o) { return death(t, o); },
    // Note: sampleN deliberately survives a death — biopsy samples persist across respawns within
    // a level, so the "third of the set" note must not re-fire. It is only a FALLBACK: the engine
    // currently always sends `third`, so this counter is normally shadowed by it.
    respawn(t, o) { return respawn(t, o); },

    heartbeat(t, o) { return beat(t, o, 'both'); },
    heart_s1(t, o) { return beat(t, o, 's1'); },
    heart_s2(t, o) { return beat(t, o, 's2'); },

    warn_ring_gate(t, o) { return warn('warn_ring_gate', t, o); },
    warn_aortic_squeeze(t, o) { return warn('warn_aortic_squeeze', t, o); },
    warn_reflux_surge(t, o) { return warn('warn_reflux_surge', t, o); },
  };

  const ALL = [...new Set([...Object.keys(T), ...Object.keys(SPECIAL), ...Object.keys(WARN)])].sort();

  // ═══ the public surface ══════════════════════════════════════════════════════════════════

  function stub() {
    return { play: () => false, has: () => false, names: () => [], dispose() {} };
  }

  return {
    /**
     * play(name, opts) -> true if it produced a voice, false if unknown / gated / not alive.
     * opts: { gain?, when?, detune? } per the contract, plus these cue-specific extras, all
     * optional and all safely absent:
     *   eta     (warn_*)        seconds of lead, straight off hazard:warn
     *   d, gap  (heartbeat)     danger scalar 0..1 and the S1->S2 spacing the engine computed
     *   heat    (cannon)        combat:state.heat / heatMax — sours the gun before lockout
     *   combo   (enemy_die)     combo {n} — the chromatic kill ladder
     *   amount  (wall_scrape)   player:damage.amount — how hard you are grinding
     *   third   (pickup_sample) sample index; we count our own when it is absent
     *   p       (surge_chase)   0..1 proximity, 1 = on top of you. NOT `gain` — that is the
     *                           master multiplier and means something else on every other cue.
     */
    play(name, opts = {}) {
      if (!alive()) return false;
      try {
        const t = Math.max(opts.when ?? 0, now());
        const fn = SPECIAL[name];
        if (fn) return fn(t, opts) !== false;
        const cue = T[name];
        if (!cue) return false;
        if (!admit(name, cue, t)) return false;
        emit(name, cue, cue.layers, t, opts, cue.surge ? surgeIn : destination);
        return true;
      } catch {
        // A suspended or closing context throws on node creation. A missing sound is a bug;
        // a thrown sound is a crashed frame. Never throw. (ASSETS-OPTIONAL LAW, generalised.)
        return false;
      }
    },

    has: (name) => ALL.includes(name),
    names: () => ALL.slice(),

    dispose() {
      if (disposed) return;
      disposed = true;                            // FIRST, so nothing in flight can reschedule
      const t = ctx.state === 'closed' ? 0 : ctx.currentTime;
      for (const id of timers) clearTimeout(id);
      timers.length = 0;
      try {
        // Fade every sounding voice out over 8 ms rather than disconnecting it. Disconnecting a
        // node that is producing signal is a step to zero — a click on every level transition.
        for (const v of live) kill(v, t);
        live.length = 0;
        if (heatVoice) overheatClear(t, true);
        if (chase) endChase(t);
        // Everything with a VCA gets the same 8 ms fade before its source is stopped — the death
        // static included. It used to be stopped bare via `perm`, so disposing during a death
        // (i.e. a level teardown at the worst possible moment) clicked.
        for (const g of [scrapeVCA && scrapeVCA.gain, staticVoice && staticVoice.g.gain]) {
          if (!g) continue;
          g.cancelScheduledValues(t);
          g.setValueAtTime(Math.max(g.value, 1e-4), t);
          g.linearRampToValueAtTime(1e-4, t + 0.008);
        }
        staticVoice = null;
        for (const s of perm) { try { s.stop(t + 0.02); } catch { /* already stopped */ } }
        perm.length = 0;
        // Disconnect the permanent graph only AFTER the fades have landed, or we clip them.
        // Through later(), so this timer is tracked like every other one in the file. (It is
        // pushed after the clearTimeout sweep above, so it survives to do its job.)
        later(() => {
          for (const n of nodes) { try { n.disconnect(); } catch { /* gone */ } }
          nodes.length = 0;
        }, 60);
      } catch { /* context already closed — nothing to tear down */ }
      // We never close the ctx: the ENGINE owns it (and may outlive us).
    },
  };
}
