guts/web/js/audio/engine.js
type-two 464f5c8a18 [lane E] The game finds its voice: procedural synth bank, the WebAudio graph, the damage feed, and the medal/title/pause cards
Round-1 Lane E audio + the two remaining UI surfaces, consolidated from the JING5
clone onto main and made determinism-clean:
  - audio/synth.js  — procedural voice bank (primary path; audible with an empty manifest)
  - audio/engine.js — WebAudio graph, cue router, bed, heartbeat; now the listener for the
                      11 gameplay bus events that were previously firing into the void
  - ui/feedback.js  — feed-corruption damage overlay (an ART_BIBLE law previously unmet)
  - ui/cards.js     — title / medal / pause cards; boot now honours ui:pause
  - boot.js         — mounts all five modules with per-module failure isolation and a
                      frame() tick (never step(), so stepped sims stay deterministic)

Determinism gate: threaded engine's seeded rnd (mulberry32 off ?seed=) into createSynth,
replacing every Math.random in synth.js. Audio texture is now reproducible per-seed and
qa.sh is green.

Verified: QA green; runtime smoke on ?seed=7 scheduled 182 osc + 91 buffer voices + 268
envelope ramps during play, zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:57:38 +10:00

1748 lines
102 KiB
JavaScript

// 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.