// audio/engine.js (Lane E) — the WebAudio graph, the cue router, the bed, the heartbeat.
//
// THE FICTION: a cheap surgical scanner failing inside a living animal. A hairline 2.5 kHz
// carrier runs under everything (the instrument's own electronics); sounds are either DRY,
// pitched and centred (the instrument) or WET, unpitched and wide (the tissue). Under both, a
// heart beats at 50 bpm and speeds up when you are about to die. Damage is the feed CORRUPTING
// — never an alarm tone, never a health-linked filter sweep. See §BANNED at the bottom.
//
// DIVISION OF LABOUR: this file owns the GRAPH, the ROUTING, the BED and the heartbeat CLOCK.
// audio/synth.js owns the SOUND of individual cues. The contract is:
//   createSynth(ctx, destination) -> { play(name, {gain, when, detune}) -> bool, has, names, dispose }
//
// HOUSE LAWS OBSERVED
//   1. BUS-ONLY: no import from flight/** or combat/**. Every number arrives as an event.
//   2. ASSETS-OPTIONAL: every one of the 24 cues, the bed and the heartbeat are audible with an
//      empty manifest (?localassets=0). Samples are LAYER OVERRIDES on a synthesis path that is
//      primary — never the reverse. That is the state this engine is designed in.
//   3. FLAGS: flags.mute makes no sound at all; flags.shots trims the frame budget.
//   5. 60FPS: the per-frame handlers (player:state, combat:state) copy scalars into one
//      preallocated object and do ZERO node work. All node work is on discrete cues + the pump.
//   6. DISPOSE-CLEAN: see dispose() at the bottom — every sub, timer, listener and node.
//
// FIVE DSP RULES that override every aesthetic decision in here:
//   R1 No instantaneous gain change, anywhere. `gain.value = x` on a live node is a step
//      discontinuity = a full-Nyquist click. Min attack 2 ms, min release 6 ms, mute included.
//   R2 exponentialRamp may not touch or cross zero (floor 1e-4, then park with setValueAtTime),
//      and may not start from an UNSCHEDULED value — every ramp needs a setValueAtTime anchor
//      first or Chrome silently does nothing.
//   R3 Oscillator/BufferSource are one-shot by spec and CANNOT be pooled. Allocate per voice,
//      release on ended. Never disconnect() a sounding node — ramp it out and stop() it.
//   R4 Zero allocation in a per-frame handler.
//   R5 The clock is ctx.currentTime. setTimeout appears exactly ONCE, as a pump asking "what
//      falls in the next 200 ms of audio time?" — never as the beat source. This is the only
//      pattern that survives a throttled background tab.

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

const CTOR = typeof window !== 'undefined' ? (window.AudioContext || window.webkitAudioContext) : null;

// DETERMINISM LAW (tools/qa.sh greps for it): the global RNG is banned everywhere outside
// core/rng.js. Audio texture is the one place it feels harmless to reach for it — noise buffers,
// grain scatter, sample-offset jitter — and that instinct is exactly what the law exists to stop:
// a mix bug you cannot reproduce is a mix bug you cannot fix. One seeded stream, so the same
// ?seed= produces the same noise, the same grains and the same jitter on every machine forever.
// Its own stream constant keeps it from perturbing world/enemy sequences.
const AUDIO_STREAM = 0x61756469;   // 'audi'


// ── Bus levels. Target: master ~-20 LUFS at rest, peaks under -6 dBFS. ────────────────────────
const LEVEL = {
  bed: 0.34,      // sampled beds only
  drone: 0.30,    // synthesized bed + the surge chase loop
  heart: 0.42,    // heartbeat only (its duck node rides 0.35 -> 0.85 linear in D)
  sfx: 0.55,      // all 19 non-warning, non-comms cues
  warn: 0.80,     // warn_*, overheat, surge_start/stall/end
  comms: 0.30,    // comms_open. Exempt from every duck — it is the thing doing the ducking.
  cavity: 0.24,   // the wet return
  master: 0.70,   // user volume, persisted
};

// Beds are loudnorm'd to -16 LUFS, sfx peak-normalised to ~-3.5 dBFS, my synthesis peaks near
// -11. Without this trim the default build and ?localassets=0 are two DIFFERENT MIXES and every
// duck depth below is tuned for only one of them.
const SAMPLE_TRIM = 0.55;

const VOICE_CEILING = 24;       // global; drops to 12 under ?shots
const CARRIER_HZ = 2489;        // the identity layer. Its partner sits 4 Hz up to beat slowly.
// Carrier amplitude into preMaster, BEFORE master (0.70) and user volume (0.70). At the original
// 0.002 that landed near -60 dBFS: not "subliminal" but genuinely under the noise floor of laptop
// speakers, so the carrier's whole job — stuttering on hull_hit/gate_hit, detuning on death, so
// the player FEELS the instrument fail — was being done at a level nobody could hear. 0.0045 is
// still ~-47 dBFS at the output: you stop noticing it in twenty seconds, which is the point, but
// its absence and its gestures register. CARRIER_DEAD is the post-feed-drop level.
const CARRIER_LVL = 0.0045;
const CARRIER_DEAD = 0.0020;

// ── CUE -> BUS. Which bus a cue lands on decides what ducks it and what it ducks. ─────────────
const WARN_CUES = new Set([
  'warn_reflux_surge', 'warn_aortic_squeeze', 'warn_ring_gate',
  'overheat', 'surge_start', 'surge_stall', 'surge_end',
]);

// ── CUE -> MANIFEST KEY. This is a DECISION, not a lookup. ────────────────────────────────────
// Lane D ships exactly four sfx keys — pellet, hit_squelch, boost, pickup — and NONE of them is
// spelled like a cue name, so a naive audioUrl('sfx', cue.name) finds a sample for `boost` and
// `pickup` only and silently misses the other two files. Hence this table.
//
// The four keys are deliberately NOT spread across every plausible cue. `dart`, `coat_hit`,
// `hull_hit` and `wall_scrape` are the obvious extra customers for hit_squelch/pellet, and they
// are refused: sharing one sample across enemy fire, player fire and BOTH damage layers destroys
// the exact discriminations the mix is built on (you must be able to tell your cannon from their
// dart, and coat-absorbed from hull-breached, in one frame inside a firefight). Four shipped
// keys, five cue sites, clean. Everything else synthesizes — which is the primary path anyway.
const SAMPLE_KEY = {
  cannon: 'pellet',            // body+air; the CLICK always synthesizes on top (see below)
  boost: 'boost',              // air layer only; lift + carrier glide stay synth
  pickup: 'pickup',
  pickup_sample: 'pickup',     // note 1 only — the 3-note rise must survive
  enemy_hit: 'hit_squelch',    // the squelch layer; click + body sine stay synth
};
// Cues where the sample REPLACES only part of the voice, so the synth still plays underneath.
// cannon: a decoded sample's attack is at the mercy of its own file pre-roll, and sample-accurate
// punch cannot be guaranteed from an asset you did not cut. The click is never delegated.
const SAMPLE_IS_LAYER = new Set(['cannon', 'boost', 'pickup_sample', 'enemy_hit']);

// Per-cue voice caps and minimum retrigger (seconds). The retrigger doubles as the same-frame
// de-dupe the brief asks for: two cannon emits in one frame are 0 ms apart, far under 45 ms.
const CUE_CAP = {
  cannon: 4, dart: 3, enemy_hit: 4, enemy_die: 3, coat_hit: 2, hull_hit: 1,
  boost: 1, torpedo: 2, torpedo_blast: 2, overheat: 1, gate_pass: 2, gate_hit: 2,
  checkpoint: 1, pickup: 4, pickup_sample: 2, comms_open: 1,
  warn_reflux_surge: 1, warn_aortic_squeeze: 1, warn_ring_gate: 1,
  surge_start: 1, surge_stall: 1, surge_end: 1, death: 1, wall_scrape: 1,
};
const CUE_MIN_GAP = {
  cannon: 0.045, dart: 0.060, enemy_hit: 0.030, coat_hit: 0.090, hull_hit: 0.120,
  pickup: 0.050, comms_open: 0.200, wall_scrape: 0.030,
};

// ── THE DUCK MATRIX. dB, then attack/hold/release in seconds. ─────────────────────────────────
// THE LOAD-BEARING ROW is `warn_*` ducking SFX. That is the whole answer to "a firefight must not
// mask the surge warning". Making the warning LOUDER does not work — the limiter erases the
// difference. Pulling the MASKER down 5-9 dB for 600 ms cuts a hole the warning sits in, at zero
// cost to peak level. Second: comms_open ducks sfx for 2.4 s because the cue is 140 ms but the
// LINE it announces is ~3 s and carries gameplay information — 2.90 s, matching comms.js's own
// HOLD = 3.0 (comms.js:84). At the original 2.40 the last ~600 ms of EVERY line played against a
// restored mix, which is the part of a line you are still reading. Third: `cannon` ducks NOTHING — at
// 8 shots/s any duck becomes a tremolo pumping the entire mix.
const DUCKS = {
  warn_ring_gate:     { bed: -9,  drone: -5, sfx: -5, a: 0.008, h: 0.09, r: 0.12 },
  warn_aortic_squeeze:{ bed: -9,  drone: -5, sfx: -6, a: 0.008, h: 0.09, r: 0.12 },
  warn_reflux_surge:  { bed: -12, drone: -6, sfx: -9, a: 0.008, h: 0.09, r: 0.12 },
  surge_start:        { bed: -5,  drone: -6, sfx: -4, heart: -3, a: 0.30, h: 1.60, r: 0.90 },
  torpedo_blast:      { bed: -6,  drone: -4, sfx: -4, heart: -6, a: 0.02, h: 0.40, r: 0.50 },
  torpedo:            { bed: -3,  a: 0.02, h: 0.20, r: 0.30 },
  enemy_die:          { bed: -2,  a: 0.01, h: 0.06, r: 0.12 },
  checkpoint:         { bed: -3,  a: 0.03, h: 0.40, r: 0.50 },
  pickup_sample:      { bed: -4,  a: 0.03, h: 0.40, r: 0.40 },
  comms_open:         { bed: -3,  sfx: -4, heart: -1, a: 0.15, h: 2.90, r: 0.70 },
  overheat:           { bed: -4,  sfx: -3, a: 0.10, h: 0.60, r: 0.30 },
};
// hull_hit and gate_hit duck the MASTER instead — they are impacts, not states, and the HOLE is
// the impact. 40-70 ms, the only sanctioned full-mix subtractions besides overheat and death.
const MASTER_DUCK = { hull_hit: [0.55, 0.004, 0.040, 0.120], gate_hit: [0.50, 0.004, 0.030, 0.090] };

// ── THE SYNTHESIZED BED, per biome. ───────────────────────────────────────────────────────────
// NOTE FOR THE NEXT READER: the sound spec's table named a `colon` biome. There is no such id.
// web/js/world/biomes.js defines oral / esophagus / stomach / small_intestine / large_intestine /
// appendix, plus a `neutral` fallback that world/biomes.js substitutes for any unknown id — so
// `neutral` DOES reach player:state.biome and needs a row or the bed goes silent on it.
// lp: L1 cavity lowpass. res: three L2 wall resonances, deliberately NON-HARMONIC so the layer
// reads as a SPACE and never as a chord. per: L4 peristalsis rate. grain: L5 density multiplier.
// car: carrier multiplier — stomach and large_intestine pull it down, because you are deeper in
// and the signal is worse. That is the whole biome story in one number.
const BIOME_BED = {
  oral:            { lp: 620, res: [210, 340, 545], per: 0.06, grain: 0.5, car: 1.00 },
  esophagus:       { lp: 390, res: [84, 137, 219],  per: 0.11, grain: 1.0, car: 1.00 },
  stomach:         { lp: 260, res: [58, 96, 151],   per: 0.07, grain: 1.8, car: 0.85 },
  small_intestine: { lp: 470, res: [118, 193, 308], per: 0.19, grain: 2.4, car: 1.00 },
  large_intestine: { lp: 210, res: [46, 74, 121],   per: 0.05, grain: 3.0, car: 0.70 },
  appendix:        { lp: 300, res: [66, 108, 172],  per: 0.04, grain: 1.4, car: 0.80 },
  neutral:         { lp: 400, res: [90, 148, 236],  per: 0.09, grain: 1.0, car: 1.00 },
};

// ── THE DANGER SCALAR AND THE HEARTBEAT — the numbers, in one place. ──────────────────────────
// The headline feature was the only system in this file with no table: its constants were spread
// across targetD, bpmFromD, the pump, thump and maybeGrain, so a mix pass meant a hunt through
// logic. Every WHY is still at §9 where the mapping is documented; only the numbers moved.
const WARN_PEAK = 0.6;           // tWarn's height on hazard:warn, decaying to 0 over eta+1 s
const HEART = {
  bpmBase: 50,                   // EXACTLY 50 — bed-esophagus is 24.000 s with 50 bpm baked in
  bpmSpan: 88,                   // ceiling 138: faster reads as a drum machine, not as a body
  bpmExp: 1.30,                  // keeps the low third near rest, so movement MEANS something
  bpmUp: 8, bpmDn: 4,            // bpm slew per second, asymmetric
  dUp: 0.10, dDn: 0.020,         // D slew per tick: ~0.25 s up, ~1.3 s down
  surgeDecay: 0.86,              // per tick once hazard:proximity falls silent
  threatW: 0.80, heatW: 0.28,    // sum >1 deliberately: maxed threat AND a hot fight should pin
  lvlBase: 0.35, lvlSpan: 0.50,  // the heart bus's own D-linked level
  shelfDb: -14,                  // bed lowshelf at D=1, dissolving D's baked heartbeat
  calm: 0.30,                    // below this D the heart is "at rest"
  humanise: 0.03,                // ±1.5% period jitter — calm only, and only with no bed to flam
  grainBase: 0.14, grainSpan: 0.41,   // L5 grain probability floor and D-linked span
  // The FALLBACK thump's valve-slap band. It used to open 120 -> 260 Hz, which is under the
  // passband of every laptop and phone speaker there is: at rest the headline feature had no
  // audible content at all on the most common playback device, and the 62 -> 38 Hz sine underneath
  // it is no help. Opening from 300 Hz keeps the slap a slap while putting its body where small
  // speakers can actually reproduce it. (synth.js owns this when it is present.)
  slapLo: 300, slapSpan: 420,
};

const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
const dbToLin = (db) => Math.pow(10, db / 20);

export function createAudio({ bus, flags = {}, assets = null, level = null, world = null, mount = null } = {}) {
  void level; void world; void mount;   // accepted for factory-signature parity; unused in round 1.
                                        // world.flowPulse (grain phase-alignment) is a round-2 ask
                                        // — House Law 1 forbids reaching for it, F must hand it in.

  // The noop MUST have the same SHAPE as the real handle, getters included. A settings slider bound
  // to `audio.volume` under ?mute (or on a browser with no AudioContext) would otherwise read
  // undefined and render NaN, and `audio.setMuted(true)` would throw — a silent build is a feature,
  // a crashing one is not.
  const noop = {
    update() {}, setVolume() {}, setMuted() {}, cue() {}, dispose() {},
    get volume() { return 0.7; }, get muted() { return !!flags.mute; },
    get danger() { return 0; }, get bpm() { return 50; },
  };
  if (!bus) return noop;

  // ?mute => never construct an AudioContext at all. A suspended-but-existing context still shows
  // Chrome's tab-audio indicator, still costs a render thread, and leaks if close() is missed.
  // This also satisfies "skip scheduling work so muted runs stay cheap" — there is nothing to skip
  // because nothing is built. ?mute OVERRIDES but does NOT WRITE localStorage: a flag must never
  // corrupt a stored preference.
  if (flags.mute || !CTOR) return noop;

  let ctx;
  try { ctx = new CTOR({ latencyHint: 'interactive' }); } catch (e) {
    console.info('[audio] no AudioContext —', e.message);
    return noop;
  }

  // Seeded off ?seed= when the run is pinned, else a fixed constant — never a wall-clock value,
  // which would silently reintroduce the irreproducibility the law forbids.
  const rnd = mulberry32(((flags.seed != null ? flags.seed : 0x9E3779B9) ^ AUDIO_STREAM) >>> 0);

  let disposed = false;
  let unlocked = false;
  const offs = [];
  const shots = !!flags.shots;
  // ?shots constructs audio NORMALLY (a shots run may be a video capture, and screenshots are
  // silent regardless) but disables the bed's grain scheduler and halves the voice ceiling to keep
  // the frame budget clean for the capture. hud.js hides-after-building and comms.js skips-entirely;
  // this is a third route and it is deliberate. Flagged to F for ratification.
  const ceiling = shots ? 12 : VOICE_CEILING;

  const t0 = () => ctx.currentTime + 0.005;   // scheduling in the past loses the attack => click

  // ═══ 1. THE GRAPH ═══════════════════════════════════════════════════════════════════════════
  //  sources ─ voiceVCA ─┬─ [busGain ─ busDuck] ─┬─ preMaster ─ hp1 ─ hp2 ─ limiter ─ master ─ out
  //                      └─ cavitySend ─ LP1600 ─ convolver ─ HP120 ─ cavityRet ┘
  //
  // Every bus is busGain -> busDuck -> preMaster. Ducks write busDuck ONLY; the user volume writes
  // master ONLY. They never touch the same node, which removes a whole class of bug: a duck that
  // wrote the bus's own gain would fight the volume setting and leave buses stuck quiet.
  const master = ctx.createGain();
  const limiter = ctx.createDynamicsCompressor();
  limiter.threshold.value = -8; limiter.knee.value = 0; limiter.ratio.value = 20;
  limiter.attack.value = 0.003; limiter.release.value = 0.25;
  // Cascaded 24 Hz highpasses = 12 dB/oct. NOT optional: three cues sweep sines under 30 Hz and
  // every lowpassed brown-noise source has a nonzero mean. Sub-25 Hz energy is inaudible but
  // steals headroom, moves real cones, and pumps the limiter on content nobody can hear.
  const hp1 = ctx.createBiquadFilter(); hp1.type = 'highpass'; hp1.frequency.value = 24; hp1.Q.value = 0.707;
  const hp2 = ctx.createBiquadFilter(); hp2.type = 'highpass'; hp2.frequency.value = 24; hp2.Q.value = 0.707;
  const preMaster = ctx.createGain(); preMaster.gain.value = 1;

  preMaster.connect(hp1); hp1.connect(hp2); hp2.connect(limiter);
  limiter.connect(master); master.connect(ctx.destination);

  // Volume/mute persistence. Safari private mode throws on ACCESS, not just write.
  let userVol = 0.7, userMuted = false;
  try {
    const raw = localStorage.getItem('guts.audio');
    if (raw) {
      const s = JSON.parse(raw);
      if (typeof s.master === 'number' && isFinite(s.master)) userVol = clamp01(s.master);
      userMuted = !!s.muted;
    }
  } catch (e) { void e; }
  const baseMaster = () => (userMuted ? 0 : userVol * LEVEL.master);
  master.gain.value = baseMaster();

  let storeTimer = null;
  function persist() {                       // debounced 400 ms — volume sliders emit fast
    if (storeTimer) clearTimeout(storeTimer);
    storeTimer = setTimeout(() => {
      storeTimer = null;
      try { localStorage.setItem('guts.audio', JSON.stringify({ master: userVol, muted: userMuted })); }
      catch (e) { void e; }
    }, 400);
  }

  function mkBus(level) {
    const g = ctx.createGain(); g.gain.value = level;
    const d = ctx.createGain(); d.gain.value = 1;
    g.connect(d); d.connect(preMaster);
    return { in: g, duck: d, base: 1, floor: 1, until: 0 };
  }
  const B = {
    bed: mkBus(LEVEL.bed), drone: mkBus(LEVEL.drone), heart: mkBus(LEVEL.heart),
    sfx: mkBus(LEVEL.sfx), warn: mkBus(LEVEL.warn), comms: mkBus(LEVEL.comms),
  };
  // The heartbeat's duck node is not a duck — it is the heart's own D-linked level (0.35 -> 0.85).
  B.heart.base = HEART.lvlBase; B.heart.duck.gain.value = HEART.lvlBase;

  // A lowshelf on the BED, not on the mix. As danger rises this dissolves the body of the
  // heartbeat Lane D baked into bed-esophagus, so the LIVE heart can take the low register over
  // without the two of them flamming. It is subtractive but it is aimed at one layer for one
  // reason — it is not the banned health-linked lowpass on the mix. See §BANNED.
  const bedShelf = ctx.createBiquadFilter();
  bedShelf.type = 'lowshelf'; bedShelf.frequency.value = 250; bedShelf.gain.value = 0;
  B.bed.in.disconnect(); B.bed.in.connect(bedShelf); bedShelf.connect(B.bed.duck);

  // Cavity send: ONE chain that is both the "heard through meat" filter and the reverb. Everything
  // biological goes through it; nothing from the instrument family does. Send-only, never an insert.
  const cavitySend = ctx.createGain(); cavitySend.gain.value = 1;
  const cavLP = ctx.createBiquadFilter(); cavLP.type = 'lowpass'; cavLP.frequency.value = 1600; cavLP.Q.value = 0.5;
  const convolver = ctx.createConvolver();
  const cavHP = ctx.createBiquadFilter(); cavHP.type = 'highpass'; cavHP.frequency.value = 120;
  const cavityRet = ctx.createGain(); cavityRet.gain.value = LEVEL.cavity;
  cavitySend.connect(cavLP); cavLP.connect(convolver); convolver.connect(cavHP);
  cavHP.connect(cavityRet); cavityRet.connect(preMaster);

  // ── Built-once resources ────────────────────────────────────────────────────────────────────
  const SR = ctx.sampleRate;
  function finishBuffer(d) {
    // Mean subtraction + peak normalise. Non-negotiable and invisible until it ruins a long
    // session: a noise buffer with DC offset pins the limiter on content nobody can hear.
    let sum = 0; for (let i = 0; i < d.length; i++) sum += d[i];
    const mean = sum / d.length;
    let peak = 0; for (let i = 0; i < d.length; i++) { d[i] -= mean; const a = Math.abs(d[i]); if (a > peak) peak = a; }
    if (peak > 0) { const k = 0.95 / peak; for (let i = 0; i < d.length; i++) d[i] *= k; }
  }
  function whiteBuf(sec) {
    const b = ctx.createBuffer(1, (SR * sec) | 0, SR), d = b.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = rnd() * 2 - 1;
    finishBuffer(d); return b;
  }
  function pinkBuf(sec) {                     // Kellet 3-pole
    const b = ctx.createBuffer(1, (SR * sec) | 0, SR), d = b.getChannelData(0);
    let b0 = 0, b1 = 0, b2 = 0;
    for (let i = 0; i < d.length; i++) {
      const w = rnd() * 2 - 1;
      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;
    }
    finishBuffer(d); return b;
  }
  function brownBuf(sec) {
    const b = ctx.createBuffer(1, (SR * sec) | 0, SR), d = b.getChannelData(0);
    let last = 0;
    // 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.
    for (let i = 0; i < d.length; i++) { last = 0.996 * last + (rnd() * 2 - 1) * 0.06; d[i] = last; }
    finishBuffer(d); return b;
  }
  const NZ = { white: whiteBuf(2), pink: pinkBuf(2), brown: brownBuf(4) };

  // Equal-power crossfade curves, built once, reused by every crossfade forever.
  const FADE_N = 32;
  const fadeOut = new Float32Array(FADE_N), fadeIn = new Float32Array(FADE_N);
  for (let i = 0; i < FADE_N; i++) {
    const x = i / (FADE_N - 1);
    fadeOut[i] = Math.cos(x * Math.PI / 2);
    fadeIn[i] = Math.sin(x * Math.PI / 2);
  }

  // The impulse response is generated AFTER first paint — ~12 ms of buffer generation must not
  // land in time-to-interactive. The 6 ms of leading zero is what keeps early reflections off the
  // attack transients; a naive IR is exactly why cannon shots turn to mush.
  let irTimer = setTimeout(() => {
    irTimer = null;
    if (disposed) return;
    try {
      const dur = SR > 48000 ? 0.5 : 0.7;
      const n = (SR * dur) | 0, ir = ctx.createBuffer(1, n, SR), d = ir.getChannelData(0);
      for (let i = 0; i < n; i++) {
        const t = i / SR;
        d[i] = t < 0.006 ? 0 : (rnd() * 2 - 1) * Math.pow(1 - t / dur, 4.5);
      }
      convolver.buffer = ir;
    } catch (e) { void e; }   // no reverb is a mix flavour, not a failure
  }, 0);

  // ── THE CARRIER — the identity layer. ───────────────────────────────────────────────────────
  // Two sines 4 Hz apart at -54 dB, direct to preMaster, dry, never ducked by any rule. You stop
  // consciously hearing it in twenty seconds, which IS the point: when it stutters (hull_hit,
  // gate_hit) or detunes (death, boost) the player feels the instrument fail without being told.
  // Cheapest identity in the whole design: two oscillators, forever.
  const carrierGain = ctx.createGain(); carrierGain.gain.value = 0;
  carrierGain.connect(preMaster);
  const carrierA = ctx.createOscillator(), carrierB = ctx.createOscillator();
  carrierA.type = carrierB.type = 'sine';
  carrierA.frequency.value = CARRIER_HZ; carrierB.frequency.value = CARRIER_HZ + 4;
  carrierA.connect(carrierGain); carrierB.connect(carrierGain);
  let carrierStarted = false;
  let carrierMul = 1;                          // biome multiplier (deeper in = worse signal)

  // ═══ 2. POOLS, VOICES, HELPERS ══════════════════════════════════════════════════════════════
  // R3: only reusable node types are pooled. Oscillators and BufferSources are one-shot by spec.
  // Filters are deliberately NOT pooled: every filter this engine builds is configured once and
  // lives as long as its voice, so a pool would only add a bucket nobody drains.
  const pool = { gain: [], panner: [] };
  function takeGain() {
    const g = pool.gain.pop() || ctx.createGain();
    g.gain.cancelScheduledValues(0);           // mandatory: a recycled node carrying stale
    return g;                                   // automation will jump mid-ramp in its next life
  }
  function takePanner(pan) {
    if (!ctx.createStereoPanner) return null;
    const p = pool.panner.pop() || ctx.createStereoPanner();
    p.pan.cancelScheduledValues(0); p.pan.value = pan;
    return p;
  }
  function give(node, bucket) {
    try { node.disconnect(); } catch (e) { void e; }
    if (pool[bucket].length < 32) pool[bucket].push(node);
  }

  const voices = [];   // { name, src, vca, extra[], at }

  function releaseVoice(v) {
    const i = voices.indexOf(v);
    if (i >= 0) voices.splice(i, 1);
    try { v.src.disconnect(); } catch (e) { void e; }
    give(v.vca, 'gain');
    for (const n of v.extra) {
      if (n.__k) give(n, n.__k); else { try { n.disconnect(); } catch (e) { void e; } }
    }
    v.extra.length = 0;
  }
  // Steal the oldest voice OF THE SAME CUE NAME ONLY, and return whether we found one. There is no
  // across-names fallback: a cannon burst that steals your surge warning is the one failure this
  // mix cannot survive, so at the global ceiling with nothing of our own name to take we DROP the
  // new voice instead. synth.js:862 makes exactly the same choice; the two files must agree or the
  // sampled and synthesized builds diverge under load.
  function steal(name) {
    let oldest = null;
    for (const v of voices) if (v.name === name && (!oldest || v.at < oldest.at)) oldest = v;
    if (!oldest) return false;
    const t = ctx.currentTime;
    try {
      oldest.vca.gain.cancelScheduledValues(t);
      oldest.vca.gain.setValueAtTime(Math.max(oldest.vca.gain.value, 1e-4), t);
      oldest.vca.gain.exponentialRampToValueAtTime(1e-4, t + 0.008);   // ramp out, never disconnect
      oldest.src.stop(t + 0.01);
    } catch (e) { void e; }
    return true;
  }
  function countOf(name) { let n = 0; for (const v of voices) if (v.name === name) n++; return n; }

  // ── The envelope helper. Every internally-synthesized voice goes through it, so none can click.
  function adsr(t, peak, a, d, sustain = 0, s = 0, r = 0.02) {
    const g = takeGain();
    g.gain.setValueAtTime(0, t);                                    // R2 anchor
    g.gain.linearRampToValueAtTime(peak, t + Math.max(a, 0.002));   // R1 floor
    const ta = t + Math.max(a, 0.002);
    if (sustain > 0) {
      const sv = Math.max(peak * sustain, 1e-4);
      g.gain.exponentialRampToValueAtTime(sv, ta + d);
      g.gain.setValueAtTime(sv, ta + d + s);
      g.gain.exponentialRampToValueAtTime(1e-4, ta + d + s + r);
      g.gain.setValueAtTime(0, ta + d + s + r + 0.001);
    } else {
      g.gain.exponentialRampToValueAtTime(1e-4, ta + d);
      g.gain.setValueAtTime(0, ta + d + 0.001);
    }
    return g;
  }

  // Fire a one-shot buffer voice (noise or decoded sample).
  function bufVoice(name, buffer, dest, vca, life, { rate = 1, loop = false, offset = null, pan = 0 } = {}) {
    // Dropped at the ceiling: hand the caller's VCA back to the pool rather than orphaning it.
    if (voices.length >= ceiling && !steal(name)) { give(vca, 'gain'); return null; }
    const src = ctx.createBufferSource();
    src.buffer = buffer; src.loop = loop;
    src.playbackRate.value = rate;
    const extra = [];
    const p = pan ? takePanner(pan) : null;
    if (p) { p.__k = 'panner'; vca.connect(p); p.connect(dest); extra.push(p); }
    else vca.connect(dest);
    src.connect(vca);
    const v = { name, src, vca, extra, at: ctx.currentTime };
    voices.push(v);
    src.onended = () => releaseVoice(v);
    const t = life.t;
    // Noise voices ALWAYS start at a random offset. Without it every squelch in the game begins on
    // the identical sample sequence and the ear decodes it as a SAMPLE, not as noise.
    const off = offset != null ? offset : (loop || buffer.duration > 1 ? rnd() * Math.max(0, buffer.duration - 0.5) : 0);
    // If start() throws (closed/closing context, bad time) `onended` will NEVER fire, so the voice
    // would sit in `voices` forever holding a slot. 24 of those and every later cue is permanently
    // in steal(). Reclaim the slot here rather than trusting a callback that cannot arrive.
    try { src.start(t, off); } catch (e) { void e; releaseVoice(v); return null; }
    if (life.stop) { try { src.stop(life.stop); } catch (e) { void e; } }
    return v;
  }

  // ── Ducking. One function, parameterised. Not a DynamicsCompressor sidechain — WebAudio has no
  // sidechain input and faking one is more nodes, more latency and less predictable.
  function duck(slot, depthDb, attack, hold, release, t) {
    let to = slot.base * dbToLin(depthDb);
    // ARBITRATION: ducks do not stack and do not multiply. A new duck applies only if it is DEEPER
    // or LATER than the one in flight. Two torpedo_blasts 100 ms apart must not take the bed to
    // -12 dB and hold it there.
    const endAt = t + attack + hold + release;
    const active = t < slot.until;
    // Shallower AND no longer than what is in flight: it has nothing to add. Skip it entirely.
    if (active && to > slot.floor && endAt <= slot.until) return;
    // Shallower but LONGER is the case that used to break the load-bearing row: a torpedo (bed -3,
    // 0.52 s) landing 50 ms into a warn_reflux_surge (bed -12, 0.218 s) passed the guard and then
    // ramped the bed UP from -12 to -3, filling in the hole the warning was sitting in. Extending
    // in time must never raise the floor — clamp the target to the depth already in flight, which
    // also keeps slot.floor and the actual param agreeing from here on.
    if (active && to > slot.floor) to = slot.floor;
    slot.floor = active ? Math.min(slot.floor, to) : to;
    slot.until = Math.max(slot.until, endAt);
    const g = slot.duck.gain;
    g.cancelScheduledValues(t);
    g.setValueAtTime(g.value, t);        // MANDATORY anchor. cancelAndHoldAtTime does not exist in
    g.linearRampToValueAtTime(to, t + attack);   // Firefox; without this the ramp starts from the
    g.setValueAtTime(to, t + attack + hold);     // last SCHEDULED value, not the current one, and
    g.setTargetAtTime(slot.base, t + attack + hold, Math.max(release, 0.01) / 3);  // ducks stack
  }                                                                                // into silence
  // The warn accelerando's 2nd and 3rd ducks, waiting for the audio clock to reach them.
  // WHY A QUEUE AND NOT THREE SCHEDULED CALLS: duck() anchors with setValueAtTime(g.value, t), and
  // g.value is the param's value RIGHT NOW. Anchoring a time two seconds in the future to the
  // value the param happens to hold in this tick asserts a step discontinuity at that future
  // instant — the exact full-Nyquist click R1 exists to ban — and it erases any duck (surge_start
  // holds the bed for 1.6 s) that is in flight when the future anchor lands. The pump drains this
  // instead, so each pulse's duck is computed against the mix as it actually is at that moment.
  const pendingDucks = [];   // { name, at }
  function applyDucks(name, t) {
    const d = DUCKS[name];
    if (d) {
      if (d.bed != null) duck(B.bed, d.bed, d.a, d.h, d.r, t);
      if (d.drone != null) duck(B.drone, d.drone, d.a, d.h, d.r, t);
      if (d.sfx != null) duck(B.sfx, d.sfx, d.a, d.h, d.r, t);
      if (d.heart != null) duck(B.heart, d.heart, d.a, d.h, d.r, t);
    }
    const m = MASTER_DUCK[name];
    if (m) {
      const g = master.gain, base = baseMaster();
      g.cancelScheduledValues(t); g.setValueAtTime(g.value, t);
      g.linearRampToValueAtTime(base * m[0], t + m[1]);
      g.setValueAtTime(base * m[0], t + m[1] + m[2]);
      g.setTargetAtTime(base, t + m[1] + m[2], m[3] / 3);
    }
    // boost LIFTS the bed rather than ducking it. Ducking the world under a player-empowerment cue
    // is emotionally backwards — the tube should open around you. It is doppler you don't compute.
    if (name === 'boost') {
      const g = B.bed.duck.gain;
      g.cancelScheduledValues(t); g.setValueAtTime(g.value, t);
      g.linearRampToValueAtTime(B.bed.base * dbToLin(1.5), t + 0.04);
      g.setTargetAtTime(B.bed.base, t + 0.24, 0.5 / 3);
      carrierGlide(t);
    }
  }

  // Carrier gestures — the instrument failing, diegetically.
  function carrierStutter(t, gaps) {
    const g = carrierGain.gain, lvl = CARRIER_LVL * carrierMul;
    g.cancelScheduledValues(t); g.setValueAtTime(g.value, t);
    for (const [off, on] of gaps) {
      g.setValueAtTime(0, t + off);
      g.linearRampToValueAtTime(lvl, t + on);
    }
  }
  function carrierGlide(t) {
    const f = carrierA.frequency;
    f.cancelScheduledValues(t); f.setValueAtTime(f.value, t);
    f.linearRampToValueAtTime(CARRIER_HZ + 71, t + 0.2);
    f.linearRampToValueAtTime(CARRIER_HZ, t + 0.9);
  }

  // ═══ 3. THE SYNTH (audio/synth.js) ══════════════════════════════════════════════════════════
  // Loaded with a DYNAMIC import, deliberately. synth.js is written in parallel by another
  // engineer; a static import would make this module fail to parse if that file is late or broken,
  // and boot.js loads all of Lane E in one Promise.all — so a bad synth.js would take the HUD and
  // the comms crew down with it. Dynamic + catch degrades to samples-plus-internal-fallbacks
  // instead. It resolves within a frame from the module cache, and no cue is audible before the
  // user gesture that unlocks the context anyway, so in practice it is always ready in time.
  //
  // The contract is createSynth(ctx, destination) — ONE destination. Cues need four different
  // buses (sfx / warn / comms / heart), so we instantiate one synth per bus. Each is a closure
  // that allocates per play(); the cost of the extra instances is effectively zero, and it is the
  // only way to honour both the one-destination contract and the ducking matrix.
  const synths = { sfx: null, warn: null, comms: null, heart: null };
  let synthReady = false;
  import('./synth.js').then((m) => {
    if (disposed || typeof m.createSynth !== 'function') return;
    synths.sfx = m.createSynth(ctx, B.sfx.in, { rnd });
    synths.warn = m.createSynth(ctx, B.warn.in, { rnd });
    synths.comms = m.createSynth(ctx, B.comms.in, { rnd });
    synths.heart = m.createSynth(ctx, B.heart.in, { rnd });
    synthReady = true;
  }).catch((e) => {
    console.info('[audio] synth.js unavailable, running on samples + internal fallbacks —', e.message);
  });

  // Names that are NOT bus cues but must reach the WARN instance, because that is where their
  // state lives. Each synth is a separate closure: `overheat` plays on warn, so warn holds the
  // heatVoice that `overheat_clear` has to cancel, and `surge_start` plays on warn, so warn holds
  // the chase loop that surge_chase/surge_unstall modulate. Routing any of them to the sfx
  // instance addresses a null — the call returns false, the state it was supposed to end never
  // ends, and (for overheat_clear) the engine ALSO plays its fallback tick, a double signal on
  // every cooldown while the warn instance keeps hissing until synth.js's 12 s safety net.
  const WARN_ROUTED = new Set(['overheat_clear', 'surge_chase', 'surge_unstall']);
  function synthFor(name) {
    if (!synthReady) return null;
    if (name === 'heartbeat') return synths.heart;
    if (name === 'comms_open') return synths.comms;
    return (WARN_CUES.has(name) || WARN_ROUTED.has(name)) ? synths.warn : synths.sfx;
  }
  const synthHas = (name) => { const s = synthFor(name); return !!(s && s.has(name)); };

  // ═══ 4. SAMPLE CACHE ════════════════════════════════════════════════════════════════════════
  // fetch + decodeAudioData ONCE per key; cheap BufferSourceNodes thereafter. A decode failure
  // caches `null`, which routes that cue back to the synth forever — it must never throw and must
  // never retry in a loop.
  // Keyed by "cat/key", never by key alone. Bed keys are biome ids and sfx keys are pellet/boost/
  // pickup/hit_squelch, so nothing collides TODAY — but a manifest that later ships an sfx named
  // after a biome would cross-wire a 24-second looping bed into a cue, which is the kind of bug
  // that gets diagnosed as "the audio engine is broken" rather than as a one-character cache key.
  const sampleCache = new Map();    // "cat/key" -> AudioBuffer | null
  const sampleLoading = new Map();  // "cat/key" -> Promise
  // Precomputed so the cue path never builds a string (R4): the cannon hits emit() ~8x/second.
  const SAMPLE_CK = {};
  for (const n in SAMPLE_KEY) SAMPLE_CK[n] = 'sfx/' + SAMPLE_KEY[n];
  const bedCK = (id) => 'beds/' + id;   // biome path only — rare, a string here is free

  function entryOf(cat, key) {
    try { return assets && typeof assets.audio === 'function' ? assets.audio(cat, key) : null; }
    catch (e) { void e; return null; }
  }
  function urlOf(cat, key) {
    // audioUrl() already probes canPlayType and returns ogg-or-m4a for THIS browser. Never
    // hardcode an extension. It builds a fresh <audio> probe on EVERY call, so it is only ever
    // called once per key here, on the load path — never per cue, never per frame.
    try { return assets && typeof assets.audioUrl === 'function' ? assets.audioUrl(cat, key) : null; }
    catch (e) { void e; return null; }
  }
  function loadSample(cat, key) {
    const ck = cat + '/' + key;
    if (sampleCache.has(ck)) return Promise.resolve(sampleCache.get(ck));
    if (sampleLoading.has(ck)) return sampleLoading.get(ck);
    const url = urlOf(cat, key);
    if (!url) { sampleCache.set(ck, null); return Promise.resolve(null); }
    const p = fetch(url)
      .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(new Error('HTTP ' + r.status))))
      .then((ab) => new Promise((res, rej) => {
        // Callback form: Safari's decodeAudioData does not reliably return a Promise.
        try { ctx.decodeAudioData(ab, res, rej); } catch (e) { rej(e); }
      }))
      .then((buf) => { sampleCache.set(ck, buf); sampleLoading.delete(ck); return buf; })
      .catch((e) => {
        console.info(`[audio] sample "${ck}" unavailable, synthesizing —`, e.message);
        sampleCache.set(ck, null); sampleLoading.delete(ck); return null;
      });
    sampleLoading.set(ck, p);
    return p;
  }
  // Warm the five sfx keys we actually map, once, right after construction. Not on first use:
  // decoding a 200 ms file the first time the cannon fires would miss that shot.
  function warmSamples() {
    const want = new Set(Object.values(SAMPLE_KEY));
    for (const k of want) if (entryOf('sfx', k)) loadSample('sfx', k);
  }

  function playSample(name, key, t, { rate = 1, gainMul = 1, pan = 0 } = {}) {
    const buf = sampleCache.get(SAMPLE_CK[name]);
    if (!buf) return false;
    const e = entryOf('sfx', key) || {};
    // Respect the per-entry gain. D loudness-normalised the beds and peak-normalised the sfx, so
    // raw playback has sfx MUCH hotter than the bed by design — without entry.gain the mix is
    // simply wrong, and without SAMPLE_TRIM the sampled and synthesized builds are different mixes.
    const peak = (e.gain != null ? e.gain : 0.5) * SAMPLE_TRIM * gainMul;
    const vca = takeGain();
    vca.gain.setValueAtTime(0, t);
    vca.gain.linearRampToValueAtTime(peak, t + 0.002);          // R1: never a raw .value on a live node
    const dur = (e.seconds || buf.duration) / Math.max(rate, 0.01);
    vca.gain.setValueAtTime(peak, t + Math.max(dur - 0.006, 0.004));
    vca.gain.linearRampToValueAtTime(0, t + Math.max(dur, 0.01));
    // The return matters: at the voice ceiling with nothing of our own name to steal, bufVoice
    // DROPS. Claiming `true` there would tell emit() a sample played and suppress the synth layer
    // as well, turning a dropped voice into a silent cue.
    return !!bufVoice(name, buf, B.sfx.in, vca, { t, stop: t + dur + 0.02 }, { rate, offset: 0, pan });
  }

  // ═══ 5. INTERNAL FALLBACK VOICES ════════════════════════════════════════════════════════════
  // If synth.js is missing AND no sample maps, the game must still be audible — that is the
  // assets-optional law taken one step further than the brief requires (it also covers a missing
  // TEAMMATE, not just a missing asset). These are deliberately crude: a tone shaped by the two
  // readability laws, so even the degraded build stays legible.
  //   LAW A — pitch RISES = you gained; pitch FALLS = you lost.
  //   LAW B — player fire DESCENDS, enemy fire ASCENDS. If incoming and outgoing fire share a
  //           contour you cannot tell them apart in a firefight without looking.
  const FALLBACK = {
    cannon:        ['square', 380, 95, 0.07, 0.24],
    dart:          ['sawtooth', 640, 1180, 0.10, 0.20],   // ascends — Law B
    enemy_hit:     ['sine', 420, 560, 0.12, 0.16],
    enemy_die:     ['sine', 160, 46, 0.34, 0.30],
    coat_hit:      ['sine', 150, 95, 0.13, 0.16],
    hull_hit:      ['sine', 190, 38, 0.30, 0.34],
    wall_scrape:   ['sawtooth', 300, 260, 0.10, 0.10],
    boost:         ['sine', 90, 200, 0.35, 0.20],         // rises — Law A
    torpedo:       ['triangle', 220, 60, 0.28, 0.28],
    torpedo_blast: ['sine', 88, 28, 0.50, 0.50],
    overheat:      ['square', 1046, 1046, 0.50, 0.20],
    gate_pass:     ['triangle', 587, 880, 0.22, 0.16],
    gate_hit:      ['sine', 320, 300, 0.40, 0.30],        // the one sanctioned semitone: "no"
    checkpoint:    ['triangle', 587, 1174, 0.30, 0.22],
    pickup:        ['sine', 1046, 1568, 0.20, 0.20],
    pickup_sample: ['sine', 659, 1318, 0.45, 0.18],
    comms_open:    ['sine', 1900, 1900, 0.03, 0.05],
    death:         ['sine', 120, 40, 0.90, 0.28],
    surge_start:   ['sine', 34, 55, 2.00, 0.40],
    surge_stall:   ['sine', 180, 130, 0.30, 0.30],
    surge_end:     ['sine', 55, 110, 0.90, 0.22],
    warn_reflux_surge:   ['sine', 1046, 897, 0.30, 0.24],  // descends — the thing BEHIND you
    warn_aortic_squeeze: ['triangle', 698, 698, 0.20, 0.22],
    warn_ring_gate:      ['sine', 880, 880, 0.12, 0.22],
  };
  function fallbackVoice(name, dest, t, gainMul, detuneCents) {
    const f = FALLBACK[name];
    if (!f) return false;
    const [type, f1, f2, dur, peak] = f;
    if (voices.length >= ceiling && !steal(name)) return false;
    const k = Math.pow(2, (detuneCents || 0) / 1200);
    const osc = ctx.createOscillator();
    osc.type = type;
    osc.frequency.setValueAtTime(f1 * k, t);
    if (f2 !== f1) osc.frequency.exponentialRampToValueAtTime(Math.max(f2 * k, 1e-4), t + dur);
    const vca = adsr(t, peak * gainMul, 0.004, dur);
    osc.connect(vca); vca.connect(dest);
    const v = { name, src: osc, vca, extra: [], at: ctx.currentTime };
    voices.push(v);
    osc.onended = () => releaseVoice(v);
    // See bufVoice: a throw here means onended never fires, so the slot must be reclaimed by hand.
    try { osc.start(t); osc.stop(t + dur + 0.05); } catch (e) { void e; releaseVoice(v); return false; }
    return true;
  }

  // ═══ 6. THE CUE ROUTER ══════════════════════════════════════════════════════════════════════
  const lastCueT = new Map();
  // Fixed 8-entry detune table, cycled by an index — not the RNG. Fixed pitch at 8 shots/s
  // produces a periodic comb the ear reads as a buzz, but +/-20% random reads as two different
  // weapons and destroys event identity. +/-3% from a table satisfies both, matches the house
  // no-RNG convention (hud.js hashes p.s, comms.js round-robins) and makes bugs reproducible.
  // Noise BUFFER offsets still use the seeded stream — different problem, different answer.
  const DETUNE = [0, -38, 38, -16, 21, -55, 7, 53];
  let cannonIx = 0, dartIx = 0;
  // Preallocated: the cue path must not allocate (R4) — the cannon runs it ~8x/second.
  const extras = { heat: 0, combo: 0, eta: 0, amount: 0, third: 0 };

  let hitChain = 0, hitChainT = 0;     // consecutive-hit ladder (enemy_hit)
  let pickChain = 0, pickChainT = 0;   // coin ladder (pickup)
  let comboN = 0;
  let sampleCount = 0;                 // biopsy samples this level, for the third-of-three flourish
  let lastDamageAmount = 1;            // latched off player:damage, consumed by the wall_scrape cue
  let staticVoice = null;              // the death feed-static loop, released on re-acquire
  let chaseP = -1;                     // last proximity handed to synth.js's chase loop (-1 = none)
  let surgeStalled = false;            // last hazard:proximity.stalled, for its FALLING edge

  // No `opts = {}` default: a defaulted object literal ALLOCATES on every call, and the cannon
  // runs this ~8x/second — which is the R4 claim this file makes two comments below.
  function cue(name, opts) {
    if (disposed || !unlocked || !name) return;
    const t = t0();

    // i-frames suppress hull_hit ENTIRELY, carrier included — so the check has to be here, above
    // applyDucks. Down in the per-cue branch it was already too late: MASTER_DUCK.hull_hit had
    // scheduled a silent 45% dip of the WHOLE MIX for 164 ms on a hit that makes no sound.
    if (name === 'hull_hit' && sig.iframes) return;

    // De-dupe / rate-limit. Two emits in the same frame are 0 ms apart, far under any gap here.
    const gap = CUE_MIN_GAP[name];
    if (gap != null) {
      const last = lastCueT.get(name);
      if (last != null && t - last < gap) return;
    }
    lastCueT.set(name, t);

    const cap = CUE_CAP[name] || 4;
    if (countOf(name) >= cap) steal(name);

    // warn_* ducks once PER PULSE further down, so it must not also take an unconditional duck
    // here — that would be a fourth, earlier, un-aligned hole in the masker.
    if (!name.startsWith('warn_')) applyDucks(name, t);

    let gainMul = opts && opts.gain != null ? opts.gain : 1;
    const detune = opts && opts.detune ? opts.detune : 0;
    let pan = 0;

    // ── SIGNALS, not shaping. ──────────────────────────────────────────────────────────────────
    // DIVISION OF LABOUR, and getting this wrong is the loudest bug available: synth.js owns the
    // cannon detune table, the enemy_hit consecutive ladder, the pickup coin ladder, the
    // enemy_die combo ladder, the pickup_sample third-specimen flourish and the warn_* eta
    // accelerando. This engine owns the BUS SIGNALS those effects are computed FROM, because the
    // synth is bus-blind by contract. So we forward raw numbers and apply NOTHING ourselves —
    // doing both would double every ladder and turn one 3-pulse warning into nine pulses.
    // The equivalent ladders further down are the FALLBACK path only, for a missing synth.
    const ext = extras;
    ext.heat = 0; ext.combo = 0; ext.eta = 0; ext.amount = 0; ext.third = 0;
    if (name === 'cannon') {
      // Heat coupling is free telegraphy: the gun audibly sours before it locks out, so `overheat`
      // is never a surprise. Only this engine can see combat:state, so only it can supply it.
      ext.heat = clamp01(sig.heat);
      // Voice-sum guard stays HERE — it is a mix concern, not a sound-design one. A held trigger
      // must stop summing before it becomes a wall, and only the engine counts live voices.
      gainMul *= 1 - 0.375 * clamp01(countOf('cannon') / 4);
    } else if (name === 'dart') {
      // Alternating pan is a FAKE and it is the weakest thing in this engine. It needs an azimuth
      // from Lane C — see the notes at the bottom.
      pan = (dartIx++ & 1) ? 0.5 : -0.5;
    } else if (name === 'enemy_die') {
      ext.combo = comboN;
    } else if (name === 'pickup_sample') {
      ext.third = sampleCount;
    } else if (name === 'wall_scrape') {
      // A CONTINUOUS voice, never a one-shot: this cue can fire many times per second while you
      // grind a wall, and a voice per cue is a machine-gun stutter that burns the voice budget.
      // The synth keeps its own permanent scrape voice, so delegate the excitation to it and only
      // run ours if it cannot. Either way this returns early — no per-cue voice, no duck.
      if (!(synthHas('wall_scrape') && trySynth(name, t, gainMul, detune, { amount: lastDamageAmount })))
        scrape(lastDamageAmount);
      return;
    } else if (name === 'surge_end') {
      // THE AUTHORITATIVE end-of-surge signal. hazard:proximity has no clear event, so without
      // this the danger term would only bleed off via the pump's 250 ms half-life and the heart
      // would keep racing for a second after the threat is provably gone — turning the best
      // moment in the game (the relief) into a lag. Forced to zero, not decayed.
      sig.surge = 0; sig.surgeStamp = -99; sig.warn = 0;
      // The chase loop is gone with it (synth.js endChase), so forget its state or the next surge
      // opens against a stale proximity and a stall that is no longer true.
      chaseP = -1; surgeStalled = false;
      // Queued warn pulses belong to the surge that just ended: firing their ducks now would cut
      // holes in the mix for a threat the player has provably outrun.
      pendingDucks.length = 0;
    } else if (name === 'hull_hit') {
      // (the i-frame suppression is at the top of cue(), above applyDucks)
      // The carrier stutters: contamination of one signal by another, which is the literal audio
      // implementation of ART_BIBLE's "damage is feed corruption" law.
      carrierStutter(t, [[0, 0.030], [0.048, 0.066]]);
    } else if (name === 'gate_hit') {
      carrierStutter(t, [[0, 0.024]]);
    } else if (name.startsWith('warn_')) {
      // The warning is a 3-pulse ACCELERANDO, not a beep: a lone tone 2.5 s early is not a warning,
      // it is trivia — THE ACCELERATION IS THE INFORMATION. synth.js schedules the three pulses
      // itself from `eta`, so we forward eta and must NOT schedule them too (that is the nine-pulse
      // bug). What stays ours is the DUCK, because the synth cannot see the buses: one duck per
      // pulse, at the same offsets, so the hole in the masker tracks the accelerando instead of
      // being one long smear the player stops noticing.
      ext.eta = sig.warnEta > 0 ? sig.warnEta : 2.5;
      const offs3 = ext.eta >= 1.2 ? [ext.eta - 1.9, ext.eta - 1.0, ext.eta - 0.35] : [0, 0.18, 0.36];
      // Queued, not scheduled — see pendingDucks above. Anything already inside the pump's
      // lookahead is applied now; the rest waits for the clock.
      for (let i = 0; i < 3; i++) {
        const at = Math.max(t + 0.02, t + offs3[i]);
        if (at <= t + 0.05) applyDucks(name, at);
        else pendingDucks.push({ name, at });
      }
    }

    const dest = name === 'comms_open' ? B.comms.in : (WARN_CUES.has(name) ? B.warn.in : B.sfx.in);
    emit(name, dest, t, gainMul, detune, pan, ext);
  }

  // One cue -> one or two voices. Sample first where one is mapped, synth otherwise; where the
  // sample is a LAYER the synth also plays, so the parts the asset cannot carry survive.
  // Preallocated opts (R4: no allocation on the cue path, which the cannon hits ~8x/second).
  // `when` is ctx.currentTime-domain ABSOLUTE — verified against synth.js, which does
  // `Math.max(opts.when ?? 0, now())`. Delay-vs-absolute is exactly the ambiguity that ships broken.
  const playOpts = { gain: 1, when: 0, detune: 0, heat: 0, combo: 0, eta: 0, amount: 0, third: 0 };
  function trySynth(name, t, gainMul, detune, ext) {
    const s = synthFor(name);
    if (!s) return false;
    playOpts.gain = gainMul; playOpts.when = t; playOpts.detune = detune;
    playOpts.heat = (ext && ext.heat) || 0; playOpts.combo = (ext && ext.combo) || 0;
    playOpts.eta = (ext && ext.eta) || 0; playOpts.amount = (ext && ext.amount) || 0;
    playOpts.third = (ext && ext.third) || 0;
    try { return !!s.play(name, playOpts); } catch (e) { void e; return false; }
  }

  function emit(name, dest, t, gainMul, detune, pan, ext) {
    const key = SAMPLE_KEY[name];
    let played = false;
    if (key && sampleCache.get(SAMPLE_CK[name])) {
      const rate = Math.pow(2, detune / 1200);
      played = playSample(name, key, t, { rate, gainMul, pan });
    }
    // Where a sample is only a LAYER, the synth still plays underneath at reduced gain so the
    // parts the asset cannot carry (the cannon's click, boost's lift, the 3-note sample rise)
    // survive. Where the sample is the whole voice, the synth is skipped.
    if (!played || SAMPLE_IS_LAYER.has(name)) {
      const ok = trySynth(name, t, gainMul * (played ? 0.7 : 1), detune, ext);
      played = played || ok;
    }
    if (!played) fallbackVoice(name, dest, t, gainMul, detune + fallbackLadder(name, t));
  }

  // The ladders, FALLBACK PATH ONLY. synth.js owns these when it is present (it keeps its own
  // hitN/coinN/cannon-detune counters); running them in both places would double every step. They
  // live here so that a build with no synth still gets the rising hit ladder and the coin ladder,
  // which are the cheapest feel wins available and should not vanish with the synth.
  function fallbackLadder(name, t) {
    if (name === 'cannon') return DETUNE[cannonIx++ & 7] - 200 * clamp01(sig.heat);
    if (name === 'enemy_hit') {
      if (t - hitChainT > 0.9) hitChain = 0;
      hitChainT = t;
      return 100 * Math.min(hitChain++, 5);
    }
    if (name === 'pickup') {
      if (t - pickChainT > 1.5) pickChain = 0;
      pickChainT = t;
      return 200 * Math.min(pickChain++, 4);
    }
    if (name === 'enemy_die') return 100 * Math.min(comboN, 7);
    return 0;
  }

  // ═══ 7. WALL SCRAPE — a CONTINUOUS voice, not a one-shot ════════════════════════════════════
  // This is a DSP hazard, not a taste call. `wall_scrape` can fire 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 largest source of mix mud in this genre. One looping source, allocated once, excited by
  // each cue and left to decay asymptotically.
  let scrapeSrc = null, scrapeBP = null, scrapeVCA = null;
  function buildScrape() {
    scrapeSrc = ctx.createBufferSource();
    scrapeSrc.buffer = NZ.pink; scrapeSrc.loop = true;
    scrapeBP = ctx.createBiquadFilter(); scrapeBP.type = 'bandpass';
    scrapeBP.frequency.value = 1100; scrapeBP.Q.value = 3.5;
    const scrapeLP = ctx.createBiquadFilter(); scrapeLP.type = 'lowpass';
    scrapeLP.frequency.value = 500; scrapeLP.Q.value = 1.0;    // the parallel "meat" path
    scrapeVCA = ctx.createGain(); scrapeVCA.gain.value = 0;
    scrapeSrc.connect(scrapeBP); scrapeBP.connect(scrapeVCA);
    scrapeSrc.connect(scrapeLP); scrapeLP.connect(scrapeVCA);
    scrapeVCA.connect(B.sfx.in);
    scrapeVCA.connect(cavitySend);
    try { scrapeSrc.start(0, rnd() * 1.5); } catch (e) { void e; }
  }
  function scrape(amount) {
    // Built on demand, not at unlock: when synth.js is present it keeps its own permanent scrape
    // voice and this one would be a second looping source running forever for nothing.
    if (!scrapeSrc) { try { buildScrape(); } catch (e) { void e; return; } }
    if (!scrapeVCA) return;
    const t = t0();
    // 6 is a normalisation floor for `amount` — balance constants cannot be imported (House Law 1).
    const peak = 0.12 + 0.20 * Math.min((amount || 1) / 6, 1);
    const g = scrapeVCA.gain;
    g.cancelScheduledValues(t);
    g.setValueAtTime(g.value, t);       // anchor at the CURRENT value, or the ramp starts from a
    g.linearRampToValueAtTime(peak, t + 0.012);   // 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 + rnd() * 700, t + 0.05);
  }

  // ═══ 8. THE BED ═════════════════════════════════════════════════════════════════════════════
  // Two halves that coexist: D's SAMPLED bed where one ships for this biome, and a SYNTHESIZED
  // drone that never stops. When a sampled bed is playing the drone ducks to 0.25x and loses its
  // grain layer; it is the floor of the world. The consequence is the good one: a missing bed for
  // `stomach` is inaudible AS A FAILURE — it just sounds like a different, drier room.
  const droneGraphs = new Map();   // biome -> { out, nodes[], grainP }
  let curBiome = null, pendingBiome = null, fading = false, fadeTimer = null;
  let bedSlotA = null, bedSlotB = null, bedUsingA = true;
  let bedStartTime = 0;            // the exact ctx time the sampled bed started — the heart phase-locks to it
  let bedPlaying = false;

  function buildDrone(id) {
    const cfg = BIOME_BED[id] || BIOME_BED.neutral;
    const nodes = [];
    const out = ctx.createGain(); out.gain.value = 0;
    out.connect(B.drone.in);
    out.connect(cavitySend);

    // L1 cavity — the outside world heard through meat.
    const src = ctx.createBufferSource(); src.buffer = NZ.pink; src.loop = true;
    const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 45;
    const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cfg.lp; lp.Q.value = 0.7;
    const cav = ctx.createGain(); cav.gain.value = 0.30;
    src.connect(hp); hp.connect(lp); lp.connect(cav); cav.connect(out);
    nodes.push(src, hp, lp, cav);

    // L2 wall resonances + L3 breathing. The breathing LFOs drive the band VCAs' gain AudioParams
    // directly (AudioParam-to-AudioParam AM) — the correct WebAudio idiom, and ZERO per-frame JS.
    // The three rates are mutually irrational-ish so the composite never audibly repeats.
    const bandGains = [0.16, 0.11, 0.08], breathHz = [0.070, 0.113, 0.181];
    for (let i = 0; i < 3; i++) {
      const bp = ctx.createBiquadFilter(); bp.type = 'bandpass';
      bp.frequency.value = cfg.res[i]; bp.Q.value = [7, 9, 6][i];
      const vca = ctx.createGain(); vca.gain.value = bandGains[i] * 0.65;
      const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = breathHz[i];
      const depth = ctx.createGain(); depth.gain.value = bandGains[i] * 0.35;
      lfo.connect(depth); depth.connect(vca.gain);
      hp.connect(bp); bp.connect(vca); vca.connect(out);
      try { lfo.start(); } catch (e) { void e; }
      nodes.push(bp, vca, lfo, depth);
    }

    // L4 peristalsis — the tube swallowing. LFO offsets the L1 cutoff.
    const per = ctx.createOscillator(); per.type = 'sine'; per.frequency.value = cfg.per;
    const perDepth = ctx.createGain(); perDepth.gain.value = 130;
    per.connect(perDepth); perDepth.connect(lp.frequency);
    try { per.start(); } catch (e) { void e; }
    nodes.push(per, perDepth);

    try { src.start(0, rnd() * 1.0); } catch (e) { void e; }
    return { out, nodes, grainP: cfg.grain, car: cfg.car };
  }
  function droneFor(id) {
    const key = BIOME_BED[id] ? id : 'neutral';
    if (!droneGraphs.has(key)) droneGraphs.set(key, buildDrone(key));
    return droneGraphs.get(key);
  }

  // setValueCurveAtTime COPIES the array it is given, so one module-level scratch buffer is safe
  // and removes the only allocation on the biome path.
  const fadeScratch = new Float32Array(FADE_N);
  function xfade(param, from, to, dur, t) {
    // The rising leg is sin-shaped and the falling leg is cos-shaped: that is what makes the PAIR
    // equal-power. Using the same shape for both (or interpolating linearly) puts a ~3 dB hole in
    // the middle of every biome transition, which the player hears as the world briefly vanishing.
    if (to >= from) for (let i = 0; i < FADE_N; i++) fadeScratch[i] = from + (to - from) * fadeIn[i];
    else            for (let i = 0; i < FADE_N; i++) fadeScratch[i] = to + (from - to) * fadeOut[i];
    // setValueCurveAtTime throws NotSupportedError on OVERLAPPING automation windows, so cancel
    // first — and see setBiome(), which refuses to start a fade while one is in flight.
    param.cancelScheduledValues(t);
    try { param.setValueCurveAtTime(fadeScratch, t, dur); }
    catch (e) { void e; param.setValueAtTime(param.value, t); param.linearRampToValueAtTime(to, t + dur); }
  }

  // Fade and STOP both bed slots. This has to exist as its own function because startSampledBed
  // only ever stops the old slot on its SUCCESS path: when the new biome ships no bed (the manifest
  // ships exactly one, `esophagus`, and it is the OPENING biome) the old loop:true source was never
  // touched, so the esophagus bed played on under stomach, small_intestine and everything after it
  // for the rest of the run — and `bedPlaying`, never once assigned false, pinned the drone at
  // 0.25x and suppressed the L5 grain layer for the whole level.
  function stopSampledBeds(t, fade = 1.5) {
    for (const slot of [bedSlotA, bedSlotB]) {
      if (!slot) continue;
      slot.vca.gain.cancelScheduledValues(t);
      slot.vca.gain.setValueAtTime(slot.vca.gain.value, t);
      slot.vca.gain.linearRampToValueAtTime(0, t + fade);
      try { slot.src.stop(t + fade + 0.1); } catch (e) { void e; }
    }
    bedSlotA = bedSlotB = null;
    bedUsingA = true;
    bedPlaying = false;
  }

  // Bring the current biome's drone to its correct level for whether a sampled bed is playing.
  // Called from the DEFERRED bed load too: setBiome computes the drone target from this function's
  // synchronous result, and beds are not warmed, so the first setBiome ALWAYS missed and left the
  // drone at 1.0 — the shipped build then opened with D's bed plus a full-level synth drone, the
  // "two bodies" the bed section argues against, while ?localassets=0 sounded cleaner. A plain
  // anchored ramp, not xfade(): this can land mid-biome-fade, and setValueCurveAtTime throws
  // NotSupportedError on overlapping windows.
  function droneToBedLevel(t, dur = 1.5) {
    const g = droneGraphs.get(BIOME_BED[curBiome] ? curBiome : 'neutral');
    if (!g) return;
    const to = bedPlaying ? 0.25 : 1;
    const p = g.out.gain;
    if (Math.abs(p.value - to) < 0.01) return;
    p.cancelScheduledValues(t);
    p.setValueAtTime(p.value, t);
    p.linearRampToValueAtTime(to, t + dur);
  }

  function startSampledBed(id, t) {
    const e = entryOf('beds', id);
    if (!e) return false;
    const buf = sampleCache.get(bedCK(id));
    if (!buf) {
      loadSample('beds', id).then((b) => {
        if (!b || disposed || curBiome !== id) return;
        if (startSampledBed(id, t0())) droneToBedLevel(t0());   // re-duck: see droneToBedLevel
      });
      return false;
    }
    const vca = ctx.createGain(); vca.gain.value = 0;
    const src = ctx.createBufferSource();
    src.buffer = buf; src.loop = true;
    src.loopStart = 0; src.loopEnd = e.seconds || buf.duration;
    // NO gap logic and NO seam fade. Lane D crossfaded the tail into the head (seam 0.32); adding
    // fade logic here would CREATE a seam where there is none.
    src.connect(vca); vca.connect(B.bed.in);
    try { src.start(t); } catch (ex) { void ex; return false; }
    const peak = (e.gain != null ? e.gain : 0.5) * SAMPLE_TRIM;
    vca.gain.setValueAtTime(0, t);
    vca.gain.linearRampToValueAtTime(peak, t + 1.5);
    const slot = { src, vca, peak };
    const old = bedUsingA ? bedSlotA : bedSlotB;
    if (bedUsingA) bedSlotB = slot; else bedSlotA = slot;
    bedUsingA = !bedUsingA;
    if (old) {
      old.vca.gain.cancelScheduledValues(t);
      old.vca.gain.setValueAtTime(old.vca.gain.value, t);
      old.vca.gain.linearRampToValueAtTime(0, t + 1.5);
      try { old.src.stop(t + 1.6); } catch (ex) { void ex; }
    }
    bedStartTime = t;   // heart phase anchor
    bedPlaying = true;
    return true;
  }

  function setBiome(id) {
    if (id === curBiome) return;
    // Flapping across a biome boundary is a real scenario and it WILL crash the engine via
    // overlapping setValueCurveAtTime windows. Queue the target instead of starting a second fade.
    if (fading) { pendingBiome = id; return; }
    const prev = curBiome;
    curBiome = id;
    const t = t0();
    fading = true;

    const next = droneFor(id);
    const prevG = prev != null ? droneGraphs.get(BIOME_BED[prev] ? prev : 'neutral') : null;

    // If the new biome's bed is not playable RIGHT NOW — no manifest entry, or an entry whose
    // buffer has not decoded (or 404'd and cached null) — silence the bed that is playing. It
    // belongs to a biome you have left. Only when both buffers are in hand does startSampledBed's
    // own crossfade run; that is the one case where letting the old bed ring on is correct.
    const bedReady = !!(entryOf('beds', id) && sampleCache.get(bedCK(id)));
    if (!bedReady) stopSampledBeds(t);
    // Start the sampled bed FIRST so the drone's fade target is known before we schedule it. Two
    // setValueCurveAtTime calls on one param in the same tick is the overlapping-automation
    // NotSupportedError this whole function is built to avoid — so the target is computed once.
    startSampledBed(id, t);
    // Equal-power, never linear: two uncorrelated drones crossfaded linearly dip ~3 dB in the
    // middle and the player hears a hole exactly at the transition you wanted to feel smooth.
    // The synth drone NEVER stops — it ducks to 0.25x under a sampled bed and is the floor of the
    // world. That is why a missing bed for a biome is inaudible AS A FAILURE.
    xfade(next.out.gain, next.out.gain.value, bedPlaying ? 0.25 : 1, 1.5, t);
    if (prevG && prevG !== next) xfade(prevG.out.gain, prevG.out.gain.value, 0, 1.5, t);

    carrierMul = next.car;
    const cg = carrierGain.gain;
    cg.cancelScheduledValues(t); cg.setValueAtTime(cg.value, t);
    cg.linearRampToValueAtTime((carrierStarted ? CARRIER_LVL : 0) * carrierMul, t + 1.5);

    if (fadeTimer) clearTimeout(fadeTimer);
    fadeTimer = setTimeout(() => {
      fadeTimer = null; fading = false;
      if (pendingBiome && pendingBiome !== curBiome) { const p = pendingBiome; pendingBiome = null; setBiome(p); }
      else pendingBiome = null;
    }, 1600);
  }

  // L5 fluid grains — scheduled from the heartbeat pump, NEVER a second timer. This is what makes
  // a drone read as a BODY rather than as a synth pad. Suppressed while a sampled bed plays (D's
  // bed has this baked in and doubling it sounds like two bodies) and under ?shots.
  let grainCount = 0;
  function maybeGrain(beatT, period) {
    if (shots || bedPlaying) return;
    if (grainCount >= 2) return;
    const g = droneGraphs.get(BIOME_BED[curBiome] ? curBiome : 'neutral');
    if (!g) return;
    const p = (HEART.grainBase + HEART.grainSpan * D) * g.grainP;   // busier when you are in trouble
    if (rnd() > p) return;
    const t = beatT + rnd() * period;
    const src = ctx.createBufferSource(); src.buffer = NZ.brown; src.loop = true;
    const bp = ctx.createBiquadFilter(); bp.type = 'bandpass';
    bp.frequency.value = 180 + rnd() * 720; bp.Q.value = 8 + rnd() * 8;
    const dur = 0.06 + rnd() * 0.16;
    const vca = adsr(t, 0.07, 0.004, dur);
    src.connect(bp); bp.connect(vca); vca.connect(B.drone.in); vca.connect(cavitySend);
    grainCount++;
    src.onended = () => { grainCount--; try { src.disconnect(); bp.disconnect(); } catch (e) { void e; } give(vca, 'gain'); };
    try { src.start(t, rnd() * 3); src.stop(t + dur + 0.05); } catch (e) { void e; }
  }

  // ═══ 9. THE DANGER SCALAR AND THE HEARTBEAT ═════════════════════════════════════════════════
  //
  // THE MAPPING, documented as the charter requires:
  //
  //   THREAT = max(tSurge, tHull, tCoat, tWarn)      <- MAX, not a sum. The nearest death
  //     tSurge  hazard:proximity.distance, signed,     dominates. Summing makes a slow fight with
  //             >0 = you are ahead. 1 at <=3u          low coat read as "surge behind you", a lie.
  //             (catchRadius), 0 at 220u (proximityRange), x0.35 while stalled.
  //     tHull   (1 - hull/hullMax)^1.5   from player:state
  //     tCoat   0.55*(1 - coat/coatMax), latched to >=0.55 below 25% coat. The 0.25 threshold
  //             MATCHES comms.js exactly, so Voss's "coat low" line and the heart agree on the
  //             same edge rather than disagreeing by a frame.
  //     tWarn   0.6 on hazard:warn, decaying linearly to 0 over eta+1s.
  //
  //   HEAT = 0.55*min(enemies/6,1) + 0.35*overheated + 0.25*min(combo/8,1)   <- ADDITIVE. This is
  //     how HOT the fight is, not how close death is.  (combat:state.enemies / .overheated / combo)
  //
  //   D_target = clamp01(0.80*THREAT + 0.28*HEAT)     coefficients sum >1 deliberately: a maxed
  //   bpm      = 50 + 88 * D^1.30                     threat AND a hot fight should pin.
  //
  // WHY COMBO IS ONLY 0.25 INSIDE 0.28: the heart belongs to the PATIENT, not to you. Wiring combo
  // hard to bpm makes the body excited about your kill streak. Danger raises the pulse; skill
  // sharpens the readout — which is why combo's real home is the enemy_hit and enemy_die ladders
  // in the router above. The charter asks for combo in the heartbeat and a small term pays that
  // honestly, because a big streak IS a hot fight.
  //
  // WHY 50.000 bpm EXACTLY AT IDLE: bed-esophagus is 24.000 s with a heartbeat baked in at 50 bpm
  // — 20 beats, dividing the loop exactly. A synth heart at ANY other idle rate FLAMS against the
  // baked one: a smeared double-thump that decodes as a broken audio decoder, not as danger. It
  // will not show up in a spec review and it is the first thing anybody hears in the build.
  const sig = {   // preallocated. The 60 Hz handlers write scalars into this and do nothing else.
    coatT: 0, hullT: 0, surge: 0, surgeStamp: -99, warn: 0, warnEta: 0,
    // No `alive` field: player:death / player:spawn are the authoritative edges (see §11) and a
    // per-frame copy of p.alive was written every frame and read by nothing.
    heat: 0, overheated: 0, foes: 0, iframes: false,
  };
  let D = 0;
  let beating = true, dying = false, lastDeathT = -99;
  let nextBeat = 0, curBpm = 50;

  function targetD() {
    const threat = Math.max(sig.surge, sig.hullT, sig.coatT, sig.warn);
    const heat = clamp01(0.55 * Math.min(sig.foes / 6, 1) + 0.35 * sig.overheated + 0.25 * Math.min(comboN / 8, 1));
    return clamp01(HEART.threatW * threat + HEART.heatW * heat);
  }
  function bpmFromD() {
    // ^1.30 keeps the first third of the range nearly at rest, so the heart MEANS something when
    // it moves. 138 is the ceiling — faster reads as a drum machine, not as a body.
    const want = HEART.bpmBase + HEART.bpmSpan * Math.pow(D, HEART.bpmExp);
    // Slew the bpm too: +8/s up, -4/s down. Tempo is recomputed once per beat AT THE BOUNDARY,
    // never mid-cycle — a continuously varying period stutters and decodes as a bug.
    const dt = 60 / Math.max(curBpm, 1);
    const up = HEART.bpmUp * dt, dn = HEART.bpmDn * dt;
    curBpm += Math.max(-dn, Math.min(up, want - curBpm));
    return curBpm;
  }

  // The pulse. The synth owns the SOUND where it offers heart_s1/heart_s2; this is the fallback so
  // that the headline feature never depends on a file written in another process.
  function thump(t, hz1, hz2, peak, dur) {
    const osc = ctx.createOscillator(); osc.type = 'sine';
    osc.frequency.setValueAtTime(hz1, t);
    osc.frequency.exponentialRampToValueAtTime(Math.max(hz2, 1e-4), t + dur * 0.6);
    const vca = adsr(t, peak, 0.006, dur);   // 6 ms on a 62 Hz sine is under half a cycle: correct
    osc.connect(vca); vca.connect(B.heart.in);   // and necessary, a longer attack swallows the transient
    const v = { name: 'heart', src: osc, vca, extra: [], at: ctx.currentTime };
    voices.push(v);
    osc.onended = () => releaseVoice(v);
    // See bufVoice: onended cannot fire if start() threw, so reclaim the slot inline.
    try { osc.start(t); osc.stop(t + dur + 0.05); } catch (e) { void e; releaseVoice(v); }
    // valve slap: the cutoff OPENING with danger is the anxiety
    const nsrc = ctx.createBufferSource(); nsrc.buffer = NZ.brown; nsrc.loop = true;
    const lp = ctx.createBiquadFilter(); lp.type = 'lowpass';
    lp.frequency.value = HEART.slapLo + HEART.slapSpan * D; lp.Q.value = 1.4;
    const nv = adsr(t, peak * 0.55, 0.004, dur * 0.5);
    nsrc.connect(lp); lp.connect(nv); nv.connect(B.heart.in);
    nsrc.onended = () => { try { nsrc.disconnect(); lp.disconnect(); } catch (e) { void e; } give(nv, 'gain'); };
    try { nsrc.start(t, rnd() * 3); nsrc.stop(t + dur * 0.5 + 0.05); } catch (e) { void e; }
  }
  function scheduleBeat(t) {
    const period = 60 / curBpm;
    // The S1->S2 gap compresses LESS than proportionally with tempo, exactly as a real heart does.
    // A fixed fraction sounds like a sped-up tape; a fixed absolute gap blurs into one thump at 138.
    const gap = Math.max(0.14, Math.min(0.36, 0.30 * period));
    // THE ENGINE OWNS THE TEMPO; synth.js OWNS THE SOUND. One call carries both S1 and S2, plus
    // the danger scalar — the synth does its own timbre colouring off `d` (valve click above 0.45,
    // murmur above 0.60, waveshaper above 0.70, and the S2 collapse above 0.88, where the
    // ventricle can no longer fill). Duplicating any of that here would double every layer.
    let used = false;
    const s = synthFor('heartbeat');
    if (s && s.has('heartbeat')) {
      try { used = !!s.play('heartbeat', { when: t, d: D, gap, gain: 1 }); } catch (e) { void e; }
    }
    if (!used) {
      // Fallback only: a crude two-thump heart so the headline feature survives a missing synth.
      const lubG = 0.55 * (0.55 + 0.45 * D);
      thump(t, 62 + 9 * D, 38, lubG, 0.15);
      if (D <= 0.88) thump(t + gap, 74, 46, lubG * 0.65, 0.108);
    }
    maybeGrain(t, period);
  }

  // ── The pump. THE ONLY setTimeout used for scheduling in this file. ─────────────────────────
  const LOOKAHEAD = 0.200, TICK_MS = 25;
  let pumpTimer = null;
  function pump() {
    pumpTimer = null;
    if (disposed) return;
    const now = ctx.currentTime;

    // hazard:proximity has NO clear event — it is emitted every frame but ONLY while |gap| < 220,
    // so SILENCE means "no surge nearby". Decay it here, in the pump, on the audio clock. It must
    // NOT live in the player:state handler: player:state stops emitting the instant you die and
    // stays stopped for the whole respawn window, so any decay driven by it freezes at its
    // death-time value exactly when the sequencing matters.
    if (now - sig.surgeStamp > 0.30) sig.surge *= HEART.surgeDecay;   // ~250 ms half-life:
    // tWarn is documented as 0.6 decaying to 0 over eta+1 SECONDS, so the per-tick step is the
    // full height times the tick, over that span. The old constant (0.025) was the step for a
    // height of 1.0 and reached zero in 0.6*(eta+1) s — 40% early, i.e. the hole in the mix closed
    // before the hazard it is warning about arrived.
    if (sig.warn > 0) sig.warn = Math.max(0, sig.warn - WARN_PEAK * (TICK_MS / 1000) / Math.max(sig.warnEta + 1, 0.5));

    // Drain the warn accelerando's queued ducks (see pendingDucks). Scanned rather than shifted:
    // two overlapping warnings can queue out of order.
    for (let i = pendingDucks.length - 1; i >= 0; i--) {
      if (pendingDucks[i].at <= now + 0.03) {
        applyDucks(pendingDucks[i].name, Math.max(pendingDucks[i].at, now + 0.005));
        pendingDucks.splice(i, 1);
      }
    }
                                                          // fast enough that outrunning the surge
    // Asymmetric slew: ~0.25 s up, ~1.3 s down. Danger arrives fast and leaves slowly; the reverse
    // feels twitchy and unearned.                          is felt as RELIEF, slow enough that a
    const tgt = targetD();                                  // one-frame gap does not stutter tempo
    D += (tgt - D) * (tgt > D ? HEART.dUp : HEART.dDn);

    // The bed lowshelf dissolves D's baked heartbeat as danger rises, so the live heart can take
    // the low register over instead of flamming against it. Also lift the live heart's own level.
    const shelfDb = HEART.shelfDb * D;
    bedShelf.gain.setTargetAtTime(shelfDb, now, 1.5);
    if (now >= B.heart.until) {
      const hb = HEART.lvlBase + HEART.lvlSpan * D;
      B.heart.base = hb;
      B.heart.duck.gain.setTargetAtTime(hb, now, 0.25);
    }

    if (beating && unlocked) {
      if (nextBeat < now) nextBeat = now + 0.05;
      while (nextBeat < now + LOOKAHEAD) {
        scheduleBeat(nextBeat);                 // allocates its ~8 nodes HERE, not per frame
        const period = 60 / bpmFromD();         // period re-read per beat, at the boundary
        let nb = nextBeat + period;
        // THE PHASE-LOCK the §9 argument is actually about, and which used to be missing:
        // bedStartTime was written and never read, so the live heart free-ran against the 50 bpm
        // heartbeat Lane D baked into bed-esophagus and drifted into the smeared double-thump that
        // argument names — and the humanise below actively guaranteed the drift, in exactly the
        // calm state where the flam is audible. So while a sampled bed is playing AND we are at
        // rest, snap the next beat onto the bed's own grid instead of jittering off it.
        if (bedPlaying && D < HEART.calm) {
          const grid = 60 / HEART.bpmBase;
          const snapped = bedStartTime + Math.round((nb - bedStartTime) / grid) * grid;
          // The guard is what makes this loop terminate: never accept a snap that does not move
          // the beat clearly forward, or the while() above can re-schedule the same instant.
          if (snapped > nextBeat + period * 0.5) nb = snapped;
        } else if (D < HEART.calm) {
          // No bed to flam against: humanise. At high danger the beat should be mechanical.
          nb = nextBeat + period * (1 + (rnd() - 0.5) * HEART.humanise);
        }
        nextBeat = nb;
      }
    }
    pumpTimer = setTimeout(pump, TICK_MS);
  }

  // ═══ 10. UNLOCK ═════════════════════════════════════════════════════════════════════════════
  // The context starts suspended and browsers keep it that way until a real user gesture. Until
  // then cue() silently no-ops. Cues arriving before unlock are DROPPED, NOT QUEUED — queuing
  // produces a machine-gun burst of thirty stacked cues the instant the player clicks, which is
  // the worst possible first impression.
  const UNLOCK_EVENTS = ['pointerdown', 'keydown', 'touchend'];
  function onUnlock() {
    removeUnlock();
    if (disposed || unlocked) return;
    unlocked = true;
    try {
      ctx.resume();
      // iOS needs an actual source STARTED inside the gesture; resume() alone is not reliable.
      const s = ctx.createBufferSource();
      s.buffer = ctx.createBuffer(1, 1, SR);
      s.connect(ctx.destination); s.start(0);
    } catch (e) { void e; }
    if (!carrierStarted) {
      carrierStarted = true;
      const t = t0();
      carrierGain.gain.setValueAtTime(0, t);
      carrierGain.gain.linearRampToValueAtTime(CARRIER_LVL * carrierMul, t + 0.6);
      try { carrierA.start(t); carrierB.start(t); } catch (e) { void e; }
    }
    if (curBiome == null) setBiome('esophagus');   // sane default until player:state names one
    nextBeat = ctx.currentTime + 0.15;
    warmSamples();
  }
  function removeUnlock() {
    for (const ev of UNLOCK_EVENTS) {
      try { window.removeEventListener(ev, onUnlock, { capture: true }); } catch (e) { void e; }
    }
  }
  for (const ev of UNLOCK_EVENTS) {
    try { window.addEventListener(ev, onUnlock, { capture: true, passive: true }); } catch (e) { void e; }
  }
  function onVisible() {
    if (disposed) return;
    if (document.visibilityState === 'hidden') { try { ctx.suspend(); } catch (e) { void e; } return; }
    if (ctx.state === 'suspended' && unlocked) { try { ctx.resume(); } catch (e) { void e; } }
    // RE-ANCHOR. Without this the while-loop in pump() tries to schedule several thousand beats in
    // the past in a single tick and hangs the main thread. Write the re-anchor before the loop.
    nextBeat = ctx.currentTime + 0.1;
  }
  try { document.addEventListener('visibilitychange', onVisible); } catch (e) { void e; }

  // ═══ 11. BUS SUBSCRIPTIONS ══════════════════════════════════════════════════════════════════
  // Verified against the emitters: every field below was read out of the file that emits it.

  offs.push(bus.on('audio:cue', (e) => { if (e && e.name) cue(e.name); }));

  // player:state — 60 Hz. R4: scalars only, no allocation, no node work. Also the biome edge.
  offs.push(bus.on('player:state', (p) => {
    if (!p) return;
    const cm = p.coatMax, hm = p.hullMax;
    const r = cm ? p.coat / cm : 1;
    // 0.25 is comms.js's exact "coat low" threshold — the heart and Voss agree on the same edge.
    sig.coatT = r < 0.25 ? Math.max(0.55 * (1 - r), 0.55) : 0.55 * (1 - r);
    sig.hullT = hm ? Math.pow(1 - p.hull / hm, 1.5) : 0;
    sig.iframes = !!p.iframes;
    if (p.biome && p.biome !== curBiome && !fading) setBiome(p.biome);   // ONE string compare/frame
    else if (p.biome && p.biome !== curBiome) pendingBiome = p.biome;
  }));

  // combat:state — 60 Hz. `enemies` is the live enemy count (verified: combat/index.js:80).
  offs.push(bus.on('combat:state', (c) => {
    if (!c) return;
    sig.heat = c.heatMax ? c.heat / c.heatMax : 0;
    const wasOver = sig.overheated;
    sig.overheated = c.overheated ? 1 : 0;
    sig.foes = c.enemies || 0;
    // There is no `overheat_clear` cue, so the falling edge is tracked here: the lockout needs an
    // audible END or the player never learns they can shoot again. Rising tick = Law A.
    if (wasOver && !sig.overheated && unlocked) {
      const t = t0();
      // synth.js sustains the overheat steam until we call this — it cannot see combat:state, so
      // the falling edge is ours to detect and hand over. Without it the lockout never releases.
      if (trySynth('overheat_clear', t, 1, 0, null)) return;
      const o = ctx.createOscillator(); o.type = 'sine';
      o.frequency.setValueAtTime(1600, t); o.frequency.exponentialRampToValueAtTime(2200, t + 0.008);
      const v = adsr(t, 0.12, 0.002, 0.090);
      o.connect(v); v.connect(B.warn.in);
      o.onended = () => { try { o.disconnect(); } catch (e) { void e; } give(v, 'gain'); };
      try { o.start(t); o.stop(t + 0.15); } catch (e) { void e; }
    }
  }));

  // hazard:proximity — the rear channel's whole tension, in one signed number. distance > 0 means
  // you are AHEAD of the surge; catchRadius 3.0, proximityRange 220 (combat/balance.js:69-70).
  offs.push(bus.on('hazard:proximity', (h) => {
    if (!h) return;
    const d = h.distance;
    // NEGATIVE distance is handled explicitly rather than left to fall through the `<= 3` arm.
    // The emitter guards only |gap| < 220, so d in [-220, 3] reaches us: the surge is PAST you.
    // That is maximum danger and 1 is the right answer — but it must be stated, not inherited from
    // a comparison that happens to be true, or the next person to re-sign this number breaks it.
    const p = d < 0 ? 1 : d <= 3 ? 1 : 1 - Math.min((d - 3) / 217, 1);
    const stalled = !!h.stalled;
    sig.surge = stalled ? p * 0.35 : p;   // danger drops while an antacid stall holds it
    sig.surgeStamp = ctx.currentTime;

    // THE REAR CHANNEL. synth.js runs the chase loop from surge_start to surge_end but cannot see
    // the bus, so without this the loop sits at a flat base level for the whole chase and carries
    // no proximity information at all — the one continuous tension signal in the game, built and
    // unhooked. Throttled on CHANGE, not per frame: synth.js ramps with setTargetAtTime, so a 2%
    // step is inaudible, and this handler is a 60 Hz one (R4 — trySynth writes shared playOpts and
    // allocates nothing). RAW proximity, not the stalled-scaled danger: stalling has its own
    // gesture below, and colouring the loop by it twice thins it twice.
    if (chaseP < 0 || Math.abs(p - chaseP) > 0.02) {
      chaseP = p;
      trySynth('surge_chase', t0(), p, 0, null);
    }
    // The FALLING edge of `stalled` exists nowhere else on the bus — hazards.js emits surge_stall
    // but has no un-stall cue. Discarding it left the chase thinned to 0.35x behind a 6 kHz
    // highpass until surge_end, i.e. the mix insisting the acid was still neutralised while it was
    // actively chasing again. That is a lie the player acts on.
    if (stalled !== surgeStalled) {
      surgeStalled = stalled;
      if (!stalled) trySynth('surge_unstall', t0(), 1, 0, null);
    }
  }));

  // hazard:warn is emitted IMMEDIATELY BEFORE the warn_* audio:cue (hazards.js:108-109), so
  // latching eta here is what lets the cue schedule its 3-pulse accelerando. Without this the
  // warning would be a single beep and the acceleration — the actual information — would be lost.
  offs.push(bus.on('hazard:warn', (h) => {
    if (!h) return;
    sig.warnEta = h.eta || 2.5;
    sig.warn = 0.6;
  }));

  // combo {n} feeds the enemy_die chromatic ladder and the small HEAT term. {n:0} on window expiry.
  offs.push(bus.on('combo', (e) => { comboN = (e && e.n) || 0; }));

  // ── The three non-audio:cue subscriptions, each with a reason. Kept deliberately short. ──────
  // 1. player:damage — the ONLY source of the damage AMOUNT. wall_scrape's cue carries no
  //    magnitude, and the scrape is a continuous voice whose level must track how hard you are
  //    grinding. `kind` here is the damage SOURCE ('acid'/'gate'/'dart'/'contact'/'aura'/
  //    'squeeze'/wall kind) and NEVER 'hull_hit' — the coat-vs-hull truth lives on the sibling
  //    audio:cue at player.js:63, which is what this engine severity-grades off.
  //    Do NOT try to identify a scrape by matching `kind` against a guessed string: the wall path
  //    passes the COLLISION's own kind (player.js:221 `damage(..., hit.kind)`), whose vocabulary
  //    this lane cannot see. player.js:221-223 calls damage() and THEN emits the wall_scrape cue in
  //    the same frame, so latching the amount here unconditionally and letting the cue consume it
  //    is exact rather than a guess.
  offs.push(bus.on('player:damage', (e) => { if (e) lastDamageAmount = e.amount || 1; }));
  // 2. player:death — MUST be subscribed here rather than on audio:cue{death}: the hull-depletion
  //    path (player.js:64) emits player:death with NO audio cue, while kill() (player.js:78) emits
  //    BOTH. Listening only to the cue misses half of all deaths; listening to both without a latch
  //    fires lethal-hazard deaths twice. player:death + a 200 ms dedupe latch is the only correct
  //    subscription in the tree.
  offs.push(bus.on('player:death', () => {
    const t = ctx.currentTime;
    if (dying && t - lastDeathT < 0.2) return;
    lastDeathT = t; dying = true;
    death();
  }));
  // 3. player:spawn — the ONLY signal that a respawn completed. boot.js sets run.respawnT = 2.0
  //    today but THAT IS NOT A CONTRACT; the window is bracketed by death -> spawn, never a
  //    hardcoded 2.0. Also the only way back from the feed-drop, since player:state is silent
  //    throughout it.
  offs.push(bus.on('player:spawn', () => { reacquire(); }));
  // pickup {kind,s,score,sample} — the SOUND already arrives on audio:cue, so this listener makes
  // no noise of its own. It is here for `sample`, the authoritative biopsy count: counting cues
  // instead would over-count, because pickup_sample can be voice-stolen or rate-limited and the
  // third-of-three flourish must fire on the third SAMPLE, not the third audible cue.
  offs.push(bus.on('pickup', (e) => { if (e && e.sample) sampleCount += e.sample; }));

  offs.push(bus.on('level:complete', () => {
    // One final beat, then stop. The bed fades over 2 s and the pathology-report card is left
    // sitting in near-silence with only the carrier. That silence is the fiction landing.
    beating = false;
    const t = t0();
    pendingDucks.length = 0;
    // The feed-drop static outlives the death sequence by design — but not past the end of the
    // level. Left up, it hisses under the pathology card, which is the opposite of the near-silence
    // this handler exists to produce.
    stopStatic(t, 1.0);
    for (const slot of [bedSlotA, bedSlotB]) {
      if (!slot) continue;
      slot.vca.gain.cancelScheduledValues(t);
      slot.vca.gain.setValueAtTime(slot.vca.gain.value, t);
      slot.vca.gain.linearRampToValueAtTime(0, t + 2.0);
    }
    for (const g of droneGraphs.values()) {
      g.out.gain.cancelScheduledValues(t);
      g.out.gain.setValueAtTime(g.out.gain.value, t);
      g.out.gain.linearRampToValueAtTime(0, t + 2.0);
    }
  }));

  // ── DEATH: a global state change, not a cue. ─────────────────────────────────────────────────
  // Everybody's instinct is to FILL this. Don't. A feed that drops does not make a sound — that is
  // what dropping means. 180 ms of total silence is the loudest thing in the game, and the carrier
  // detuning afterwards says "instrument failing" diegetically, where a flatline tone would be a
  // hospital cliché reaching outside the fiction.
  // The feed-static loop has no natural end — it is a loop:true source with no stop() — so every
  // path that leaves the death state has to take it down. Factored out because there are three:
  // reacquire, level:complete, and a second death() before a spawn (the 200 ms latch does not
  // cover a hull-depletion death arriving minutes after a kill()), which used to overwrite the
  // reference at the bottom of death() and leave the previous source hissing forever.
  function stopStatic(t, fade = 0.300) {
    if (!staticVoice) return;
    const s = staticVoice; staticVoice = null;
    try {
      s.vca.gain.cancelScheduledValues(t);
      s.vca.gain.setValueAtTime(s.vca.gain.value, t);
      s.vca.gain.linearRampToValueAtTime(0, t + fade);
      s.src.stop(t + fade + 0.05);
      s.src.onended = () => { try { s.src.disconnect(); s.bp.disconnect(); s.vca.disconnect(); } catch (e) { void e; } };
    } catch (e) { void e; }
  }

  function death() {
    if (!unlocked) return;
    const t = t0();
    beating = false;
    stopStatic(t, 0.050);        // a second death before a spawn must not stack a second loop
    pendingDucks.length = 0;     // queued warn pulses belong to the run that just ended
    master.gain.cancelScheduledValues(t);
    master.gain.setValueAtTime(master.gain.value, t);
    master.gain.linearRampToValueAtTime(0, t + 0.040);
    for (const v of [...voices]) {
      try {
        v.vca.gain.cancelScheduledValues(t);
        v.vca.gain.setValueAtTime(Math.max(v.vca.gain.value, 1e-4), t);
        v.vca.gain.exponentialRampToValueAtTime(1e-4, t + 0.008);
        v.src.stop(t + 0.05);
      } catch (e) { void e; }
    }
    // 0.180 — the feed comes back carrying nothing but a detuned, gated carrier.
    const t2 = t + 0.180;
    master.gain.setValueAtTime(baseMaster(), t2);
    const f = carrierA.frequency;
    f.cancelScheduledValues(t2); f.setValueAtTime(CARRIER_HZ, t2);
    f.linearRampToValueAtTime(2100, t2 + 0.120);
    const cg = carrierGain.gain;
    cg.cancelScheduledValues(t2); cg.setValueAtTime(cg.value, t2);
    cg.linearRampToValueAtTime(CARRIER_DEAD * carrierMul, t2 + 0.05);
    // 0.350 — feed static, stepped from a FIXED table. Deterministic, house style, reproducible.
    const STATIC = [.30, .05, .22, .02, .35, .10, .04, .28, .06, .18, .03, .12];
    const st = t + 0.350;
    const n = ctx.createBufferSource(); n.buffer = NZ.white; n.loop = true;
    const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 3400; bp.Q.value = 2;
    const sv = ctx.createGain(); sv.gain.setValueAtTime(0, st);
    for (let i = 0; i < STATIC.length; i++) sv.gain.setValueAtTime(STATIC[i] * 0.22, st + i * 0.04);
    n.connect(bp); bp.connect(sv); sv.connect(preMaster);
    try { n.start(st, rnd()); } catch (e) { void e; }
    staticVoice = { src: n, vca: sv, bp };
    // 0.900 — the body's last thump, heard from far away (fully wet).
    const dt = t + 0.900;
    const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = 55;
    const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 200; lp.Q.value = 0.7;
    const dv = adsr(dt, 0.30, 0.040, 1.40);
    o.connect(lp); lp.connect(dv); dv.connect(cavitySend); dv.connect(B.drone.in);
    o.onended = () => { try { o.disconnect(); lp.disconnect(); } catch (e) { void e; } give(dv, 'gain'); };
    try { o.start(dt); o.stop(dt + 1.6); } catch (e) { void e; }
  }

  function reacquire() {
    dying = false;
    if (!unlocked) return;
    const t = t0();
    const f = carrierA.frequency;
    f.cancelScheduledValues(t); f.setValueAtTime(f.value, t);
    f.linearRampToValueAtTime(CARRIER_HZ, t + 0.400);
    const cg = carrierGain.gain;
    cg.cancelScheduledValues(t); cg.setValueAtTime(cg.value, t);
    cg.linearRampToValueAtTime(CARRIER_LVL * carrierMul, t + 0.400);
    stopStatic(t, 0.300);
    master.gain.cancelScheduledValues(t);
    master.gain.setValueAtTime(master.gain.value, t);
    master.gain.setTargetAtTime(baseMaster(), t, 0.25);
    for (const k of ['bed', 'drone', 'sfx', 'heart', 'warn']) {
      const s = B[k];
      s.floor = s.base; s.until = 0;
      s.duck.gain.cancelScheduledValues(t);
      s.duck.gain.setValueAtTime(s.duck.gain.value, t);
      s.duck.gain.setTargetAtTime(s.base, t, 0.25);
    }
    // D = 0 HARD, no smoothing — the heart restarts at exactly 50.000 bpm on the next boundary.
    D = 0; curBpm = 50;
    // Clear every THREAT term too. Leaving them latched would have the pump re-inflate D on its
    // very next tick from a signal that describes the run you just died in, not the new one.
    // sig.foes / sig.overheated are deliberately NOT cleared: combat:state re-states them next
    // frame, and a respawn into a live fight should sound like one.
    sig.surge = 0; sig.surgeStamp = -99; sig.warn = 0; sig.warnEta = 0; sig.hullT = 0; sig.coatT = 0;
    comboN = 0;
    // Same reasoning for the chase: forget the proximity and the stall we last handed the synth, so
    // the first hazard:proximity of the new life is treated as a change and actually gets sent.
    chaseP = -1; surgeStalled = false;
    pendingDucks.length = 0;
    beating = true;
    nextBeat = ctx.currentTime + 0.15;
    // The scanner locking back on: a filter sweep and three ticks.
    const n = ctx.createBufferSource(); n.buffer = NZ.pink; n.loop = true;
    const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(200, t + 0.1);
    lp.frequency.exponentialRampToValueAtTime(4000, t + 0.5); lp.Q.value = 0.8;
    const v = adsr(t + 0.1, 0.18, 0.020, 0.400);
    n.connect(lp); lp.connect(v); v.connect(B.sfx.in);
    n.onended = () => { try { n.disconnect(); lp.disconnect(); } catch (e) { void e; } give(v, 'gain'); };
    try { n.start(t + 0.1, rnd()); n.stop(t + 0.6); } catch (e) { void e; }
    for (let i = 0; i < 3; i++) {
      const tt = t + 0.1 + i * 0.09;
      const tn = ctx.createBufferSource(); tn.buffer = NZ.white; tn.loop = true;
      const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2400; bp.Q.value = 6;
      const tv = adsr(tt, 0.10, 0.001, 0.018);
      tn.connect(bp); bp.connect(tv); tv.connect(B.sfx.in);
      tn.onended = () => { try { tn.disconnect(); bp.disconnect(); } catch (e) { void e; } give(tv, 'gain'); };
      try { tn.start(tt, rnd()); tn.stop(tt + 0.05); } catch (e) { void e; }
    }
    // feed re-acquired
    const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = 1046.5;
    const ov = adsr(t + 0.5, 0.16, 0.006, 0.140);
    o.connect(ov); ov.connect(B.sfx.in);
    o.onended = () => { try { o.disconnect(); } catch (e) { void e; } give(ov, 'gain'); };
    try { o.start(t + 0.5); o.stop(t + 0.75); } catch (e) { void e; }
  }

  pumpTimer = setTimeout(pump, TICK_MS);

  // ═══ 12. PUBLIC HANDLE ══════════════════════════════════════════════════════════════════════
  return {
    // update(dt) exists for factory-signature parity and is INTENTIONALLY not the clock. The
    // heartbeat runs on the setTimeout pump against ctx.currentTime because player:state — the
    // only per-frame signal Lane E receives — STOPS EMITTING the instant you die and stays stopped
    // for the whole respawn window, which is exactly the interval the death/re-acquire sequence
    // has to sequence through. Nothing in boot.js calls this today; that is fine and expected.
    update() {},

    cue,                                   // for the ?fakebus=1 harness
    setVolume(v) {
      userVol = clamp01(v);
      const t = t0();
      master.gain.cancelScheduledValues(t);
      master.gain.setValueAtTime(master.gain.value, t);
      master.gain.linearRampToValueAtTime(baseMaster(), t + 0.05);   // R1: never a raw assignment
      persist();
    },
    setMuted(m) {
      userMuted = !!m;
      const t = t0();
      master.gain.cancelScheduledValues(t);
      master.gain.setValueAtTime(master.gain.value, t);
      master.gain.linearRampToValueAtTime(baseMaster(), t + 0.05);
      persist();
    },
    get volume() { return userVol; },
    get muted() { return userMuted; },
    get danger() { return D; },            // ?dbg=1 readout
    get bpm() { return curBpm; },

    dispose() {
      if (disposed) return;
      disposed = true;                     // FIRST, so an in-flight pump cannot reschedule
      for (const off of offs) off();
      if (pumpTimer) clearTimeout(pumpTimer);
      if (fadeTimer) clearTimeout(fadeTimer);
      if (irTimer) clearTimeout(irTimer);
      if (storeTimer) { clearTimeout(storeTimer); storeTimer = null; }
      removeUnlock();
      try { document.removeEventListener('visibilitychange', onVisible); } catch (e) { void e; }
      const t = ctx.currentTime;
      pendingDucks.length = 0;
      // 'heart' included: it is a fourth createSynth instance with its own permanent nodes and its
      // own timers array, and leaving it out left those timers running past teardown.
      for (const k of ['sfx', 'warn', 'comms', 'heart']) { try { synths[k] && synths[k].dispose(); } catch (e) { void e; } }
      // Ramp everything out over 8 ms rather than disconnecting: NEVER disconnect a sounding node.
      for (const v of [...voices]) {
        try {
          v.vca.gain.cancelScheduledValues(t);
          v.vca.gain.setValueAtTime(Math.max(v.vca.gain.value, 1e-4), t);
          v.vca.gain.exponentialRampToValueAtTime(1e-4, t + 0.008);
          v.src.stop(t + 0.01);
        } catch (e) { void e; }
      }
      voices.length = 0;
      try { master.gain.cancelScheduledValues(t); master.gain.setValueAtTime(master.gain.value, t); master.gain.linearRampToValueAtTime(0, t + 0.02); } catch (e) { void e; }
      if (scrapeSrc) { try { scrapeSrc.stop(t + 0.02); } catch (e) { void e; } }
      if (staticVoice) { try { staticVoice.src.stop(t + 0.02); } catch (e) { void e; } staticVoice = null; }
      if (carrierStarted) { try { carrierA.stop(t + 0.02); carrierB.stop(t + 0.02); } catch (e) { void e; } }
      for (const slot of [bedSlotA, bedSlotB]) if (slot) { try { slot.src.stop(t + 0.02); } catch (e) { void e; } }
      bedSlotA = bedSlotB = null;
      for (const g of droneGraphs.values()) {
        for (const n of g.nodes) { try { if (n.stop) n.stop(t + 0.02); } catch (e) { void e; } }
      }
      droneGraphs.clear();
      pool.gain.length = 0; pool.panner.length = 0;
      sampleCache.clear(); sampleLoading.clear();
      // close() ONLY after the fades land — closing immediately gives you a 20 ms burst of clipped
      // noise on every level transition.
      setTimeout(() => { try { ctx.close(); } catch (e) { void e; } }, 60);
    },
  };
}

// ── §BANNED — the audio equivalent of the red vignette ───────────────────────────────────────
// ART_BIBLE bans red screen-edges on damage. The exact audio equivalents are banned here by the
// same law, for the same reason, and nothing in this file does any of them:
//   1. No sustained health-linked alarm tone or rising siren. A persistent band of danger colour
//      pasted over the feed is MORE offensive in audio than in vision, because you cannot look away.
//   2. No lowpassing the mix as health drops — the "concussed" filter every shooter shipped in
//      2010. It is seductive, atmospheric, and it removes information at the precise moment the
//      player needs the most.
//   3. No health-linked rumble drone.
// Damage is communicated by ADDING, never by subtracting bandwidth from the whole mix: the heart,
// the valve click, the murmur, the carrier stutter, the static. All transient, all diegetic, all
// happening to the INSTRUMENT rather than to the mood. The only full-mix subtractions in here are
// the 40-70 ms master ducks on hull_hit/gate_hit (impacts, not states) and death.
