guts/web/js/audio/synth.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

1820 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mulberry32 } from '../core/rng.js';
// audio/synth.js (Lane E) — THE PROCEDURAL VOICE BANK.
//
// Every sound in GUTS, synthesized. No files, no fetch, no decode. This module is the PRIMARY
// audio path (SOUND SPEC §1.6: "The synthesis path is primary; samples are layer overrides"),
// which is why `?localassets=0` is not a degraded mode — it is the mode this file was designed
// in. Every cue `names()` reports is audible with an empty manifest — none of them ever touch one.
//
// CONTRACT (fixed, shared with engine.js which is being written in parallel):
// createSynth(ctx, destination) -> { play(name, opts) -> bool, has(name), names(), dispose() }
// `destination` is the engine's sfx bus. We NEVER connect to ctx.destination — the engine
// owns the master chain, the volume, and the mute law. Nothing in here reads flags or the bus.
//
// THE STRUCTURE IS THE DELIVERABLE. Read RECIPES (below) to retune the game; read the six
// generic builders to change how a sound is made. There are deliberately NOT twenty bespoke
// synth functions — a cue is DATA, and the only cues with hand-written code are the ones that
// own real state (a sustained lockout, a chase loop, a heart, a continuous scrape).
//
// FIVE DSP LAWS, obeyed everywhere below. Each one is an audible bug if broken:
// 1. No instantaneous gain on a live node. `gain.value = x` mid-flight is a step
// discontinuity — a full-Nyquist click. Every gain goes through setValueAtTime +
// a ramp. Minimum attack 2 ms, minimum release 6 ms.
// 2. exponentialRampToValueAtTime may not touch zero. Floor is 1e-4, then setValueAtTime(0)
// to park. Ramping FROM an unscheduled zero is also illegal and silently no-ops in
// Chrome — every ramp gets an explicit setValueAtTime anchor first.
// 3. Every source is stop()ed at a known time. An Oscillator or BufferSource that is never
// stopped runs (and is retained) forever.
// 4. Noise buffers are built ONCE at construction and replayed via BufferSource. Allocating
// a buffer per shot is the classic hot-path bug — at 8 cannon shots/second it is ~1 MB/s
// of garbage.
// 5. The clock is ctx.currentTime. `now()` adds 5 ms of slack: scheduling in the past loses
// the attack ramp, and a lost attack ramp is a click.
//
// DELIBERATE DEVIATION FROM SPEC §1.3-R3 (node pooling): the spec asks for a checkout pool of
// GainNode/BiquadFilterNode. This file is fire-and-forget instead. Reason: a recycled node that
// carries one un-cancelled automation event jumps mid-ramp in its next life, and that bug is
// silent, intermittent, and nearly untestable — whereas the cost it avoids is ~6 short-lived
// nodes per cue, which every current WebAudio implementation reclaims cheaply off the audio
// thread. The allocation the spec was really protecting against (rule 4) is the noise buffer,
// and that one IS hoisted. Flagged to the integrator rather than hidden.
// ─────────────────────────────────────────────────────────────────────────────────────────
// RECIPE SCHEMA — read this once and the whole table below is legible.
// ─────────────────────────────────────────────────────────────────────────────────────────
// A cue is { cap, retrig, wet, dry, layers: [...] }.
// cap max simultaneous voices OF THIS CUE. Over cap we steal the oldest voice of the SAME
// name only — never across names, or a cannon burst eats your surge warning.
// retrig minimum seconds between voices; a request inside the window is dropped.
// wet send into the cavity (lowpass -> convolver -> highpass). "Biological" cues are wet,
// "instrument" cues are dry. That split IS the sound identity.
// dry direct-path multiplier, default 1. `dry: 0` = heard only through the cavity.
//
// A layer is one of five kinds. Shared fields:
// at offset in seconds from the cue's start time (default 0)
// peak linear peak gain into the bus (required)
// a d attack / decay seconds (required)
// sus s r sustain fraction / hold seconds / release seconds (optional; sus>0 enables)
// wet / dry per-layer overrides of the cue values
// pan -1..1, or 'alt' to alternate sides per voice
// am { f, depth } an LFO summed INTO the VCA's gain AudioParam. AudioParam-rate AM is
// the correct WebAudio idiom — it costs 2 nodes and zero JS per frame. `depth` is a
// FRACTION OF THIS LAYER'S PEAK, not an absolute gain: depth 0.3 = ±30 % of peak. It has
// to be relative or retuning `peak` silently changes the modulation index — and a depth
// that exceeds peak drives the gain param NEGATIVE, which is ring modulation with
// polarity inversion, not tremolo. (That bug shipped in `dart` until it was caught.)
// ctrl depth of smoothed ~14 Hz control noise summed into the VCA gain. ABSOLUTE, unlike `am`
// — it is used on hiss beds where 100 % flicker is the point, and control noise is
// symmetric about 0 so it grazes silence rather than inverting through it.
// aLin documentary only — the attack is always linear (see adsr); kept where a recipe wants
// to SAY "this attack is a linear swell, not a curve", e.g. surge_start's 1.2 s dread.
// shape true = through the tanh waveshaper
// lad 0 = exempt this layer from the semitone ladder (see §ladders)
// rep every repeat this layer `rep` times, `every` seconds apart
//
// k:'tone' wave ('sine'|'square'|'sawtooth'|'triangle'|'organ'), f, [f2, ft] sweep target
// + sweep seconds, sweep:'exp'|'lin', filt, vib { f, cents, decay }
// k:'noise' n ('white'|'pink'|'brown'), filt, filt2 (a second filter in series),
// split (stereo pair at ±split with the right channel delayed `splitDelay`)
// k:'bank' fs[] ds[] ps[] — N fixed sines with per-partial decay and peak. Inharmonic
// ratios + higher-partials-die-first is what reads as STRUCK STRUCTURE.
// k:'seq' wave, notes[], every | ats[], peak | peaks[], d | ds[]
//
// filt = [type, freq, Q] or [type, freq, Q, freqTarget, sweepSeconds].
// Q NEVER sweeps (high-Q biquad instability); it may STEP, which is inaudible on a filter.
// The comms panel holds a line open for exactly this long (comms.js `HOLD`). The channel-open
// bed below is timed to close WITH it — that mirror is the whole cue ("you hear it stop"), so it
// lives in one named constant instead of a hand-summed 2.52 buried in the table. We cannot import
// comms.js (audio never reaches into UI), so if that HOLD is retuned, retune this number.
const COMMS_HOLD = 3.0;
const T = {
// ── the instrument family: dry, pitched, centred ────────────────────────────────────────
cannon: {
// SPEC §3.1 "this sound must be SMALL". 70 ms, dry, no sub, ducks nothing. A gorgeous
// 400 ms wet cannon is right in isolation and catastrophic at 8 shots/second: the tail
// masks the surge warning and the ear fatigues inside thirty seconds. Payoff lives on the
// IMPACT (enemy_hit / enemy_die), never on the trigger.
cap: 4, retrig: 0.045, wet: 0.05,
layers: [
{ k: 'noise', n: 'white', filt: ['highpass', 2200, 0.5], peak: 0.30, a: 0.0008, d: 0.0022, pan: 'alt' },
{ k: 'tone', wave: 'square', f: 380, f2: 95, ft: 0.042, filt: ['lowpass', 3200, 0.9], peak: 0.32, a: 0.001, d: 0.062, pan: 'alt' },
{ k: 'noise', n: 'white', filt: ['bandpass', 1800, 1.2, 700, 0.050], peak: 0.13, a: 0.0005, d: 0.055, pan: 'alt' },
],
},
pickup: {
// Rising fifth. LAW A: pitch rises = you gained. Plus the coin ladder (§ladders).
cap: 4, retrig: 0.05, wet: 0,
layers: [
{ k: 'seq', wave: 'sine', notes: [1046.5, 1567.98], every: 0.055, peak: 0.22, a: 0.003, d: 0.170 },
{ k: 'noise', n: 'white', filt: ['bandpass', 5200, 3], peak: 0.09, a: 0.001, d: 0.025 },
],
},
pickup_sample: {
// THE collectible, 3 per level. Unmistakably longer and richer than `pickup` — if these two
// are ever confused the collectible stops feeling like one.
cap: 2, wet: 0,
layers: [
{ k: 'seq', wave: 'sine', notes: [659.25, 987.77, 1318.5], every: 0.070, peak: 0.18, a: 0.004, ds: [0.30, 0.34, 0.60] },
{ k: 'noise', n: 'white', filt: ['bandpass', 3000, 14], peak: 0.10, a: 0.002, d: 0.040 },
{ k: 'tone', wave: 'sine', f: 130.81, peak: 0.16, a: 0.015, d: 0.600, wet: 0.5 },
// "log entry": the instrument's own carrier gets a second partial for 200 ms, at -36 dB.
// Barely a tone; present as a feeling that the scanner wrote something down. It was -60 dB,
// which is not "subliminal" — it is silent, and a silent layer is two nodes of nothing.
{ k: 'tone', wave: 'sine', f: 3733.5, peak: 0.016, a: 0.010, d: 0.200 },
{ k: 'tone', wave: 'sine', f: 2093, at: 0.14, peak: 0.09, a: 0.050, d: 0.550, am: { f: 6, depth: 0.50 } },
],
},
checkpoint: {
// A data handshake, not a fanfare. Root/fifth/octave with NO THIRD — a third would make it
// a happy jingle, and this is a machine confirming it wrote a record.
cap: 1, wet: 0.35,
layers: [
{ k: 'seq', wave: 'triangle', notes: [587.33, 880.00, 1174.66], every: 0.09, peak: 0.24, a: 0.004, d: 0.260, wet: 0 },
{ k: 'noise', n: 'white', filt: ['bandpass', 2600, 8], peak: 0.08, a: 0.001, d: 0.012, rep: 4, every: 0.055 },
{ k: 'tone', wave: 'sine', f: 293.66, peak: 0.11, a: 0.030, d: 0.500 },
],
},
gate_pass: {
// Deliberately ~5 dB quieter than gate_hit. Passing is the EXPECTED state; hitting is the
// news. Rewarding the default loudly trains the player to stop listening to the channel.
cap: 2, wet: 0.40,
layers: [
{ k: 'seq', wave: 'triangle', notes: [587.33, 880], every: 0.075, peak: 0.16, a: 0.004, d: 0.140, wet: 0 },
{ k: 'noise', n: 'pink', filt: ['bandpass', 700, 1.6, 3200, 0.200], peak: 0.16, a: 0.015, d: 0.210 },
{ k: 'tone', wave: 'sine', f: 98, peak: 0.09, a: 0.020, d: 0.280 },
],
},
gate_hit: {
// The only semitone in the game (587.33 vs 622.25). LAW A allows this one exception because
// it is not "you lost" — it is the instrument saying NO.
cap: 2, wet: 0.60,
layers: [
{ k: 'noise', n: 'white', filt: ['highpass', 2000, 0.7], peak: 0.34, a: 0.001, d: 0.022 },
{ k: 'bank', fs: [320, 470, 705], ds: [0.70, 0.50, 0.34], ps: [0.30, 0.22, 0.14], a: 0.002 },
{ k: 'tone', wave: 'sine', f: 160, f2: 44, ft: 0.100, peak: 0.48, a: 0.004, d: 0.280 },
{ k: 'tone', wave: 'sine', f: 587.33, peak: 0.08, a: 0.008, d: 0.300, wet: 0 },
{ k: 'tone', wave: 'sine', f: 622.25, peak: 0.08, a: 0.008, d: 0.300, wet: 0 },
],
},
comms_open: {
// A mic keying up, NOT a beep. Fourteen trigger keys × a 3 s hold means a whole level of
// these; anything with a pitch in it is torture by minute four. The third layer is the
// channel standing OPEN at ~-48 dB for 3.0 s — you never hear it, you hear it stop.
cap: 1, retrig: 0.2, wet: 0,
layers: [
{ k: 'noise', n: 'white', filt: ['highpass', 3000, 0.7], peak: 0.20, a: 0.0008, d: 0.006 },
{ k: 'noise', n: 'pink', filt: ['bandpass', 1900, 3], peak: 0.09, a: 0.004, d: 0.028 },
// -36 dB, not -48: at -48 the bed is below audibility after the comms bus trim, and a bed
// you cannot hear cannot be heard to STOP. It must be subliminally present to work.
{ k: 'noise', n: 'pink', filt: ['highpass', 700, 0.7], filt2: ['lowpass', 3400, 0.7], peak: 0.016, a: 0.080, d: 0.002, sus: 1.0, s: COMMS_HOLD - 0.080 - 0.002 - 0.40, r: 0.40 },
],
},
// ── the tissue family: wet, unpitched, formant-filtered ─────────────────────────────────
dart: {
// LAW B, and the sharpest call in the spec: ENEMY FIRE ASCENDS, player fire descends. Both
// descending is the Star Fox laser, and if incoming and outgoing fire share a contour you
// cannot tell them apart in a firefight without looking. No noise layer at all — the
// ABSENCE of a transient is the message: something with muscle is pushing at you.
cap: 3, retrig: 0.06, wet: 0.35,
layers: [
{ k: 'tone', wave: 'sawtooth', f: 640, f2: 1180, ft: 0.090, filt: ['lowpass', 2600, 2], peak: 0.26, a: 0.002, d: 0.088, am: { f: 55, depth: 0.55 }, pan: 'alt', panAmt: 0.5 },
],
},
enemy_hit: {
// Squelch sweeps UPWARD (Law A: you gained). Consecutive hits walk the whole cue up a
// chromatic ladder — see §ladders. Cheapest feel win in the spec: one multiply turns
// sustained fire on a tanky enemy from a flat rattle into a six-step build.
cap: 4, retrig: 0.03, wet: 0.60,
layers: [
{ k: 'noise', n: 'white', filt: ['highpass', 3400, 0.5], peak: 0.28, a: 0.0008, d: 0.002, lad: 0 },
{ k: 'noise', n: 'brown', filt: ['bandpass', 900, 2.5, 2600, 0.060], peak: 0.30, a: 0.004, d: 0.090 },
{ k: 'tone', wave: 'sine', f: 420, f2: 560, ft: 0.060, peak: 0.14, a: 0.003, d: 0.080 },
],
},
enemy_die: {
// The whole design in miniature: a WET collapse with a DRY instrument ping on top. The body
// fails; the scanner notes it. The shard is the only thing here that isn't meat.
cap: 3, wet: 0.55,
layers: [
{ k: 'noise', n: 'white', filt: ['highpass', 2600, 0.5], peak: 0.42, a: 0.001, d: 0.003, lad: 0 },
{ k: 'noise', n: 'brown', filt: ['bandpass', 1200, 10, 90, 0.340], filt2: ['bandpass', 1200, 10, 90, 0.340], qStep: [2, 0.120], peak: 0.50, a: 0.006, d: 0.340 },
{ k: 'tone', wave: 'sine', f: 160, f2: 46, ft: 0.300, peak: 0.34, a: 0.004, d: 0.300 },
{ k: 'tone', wave: 'sine', f: 784, peak: 0.10, a: 0.008, d: 0.180, wet: 0, lad: 0 },
],
},
coat_hit: {
// Zero content above 1 kHz. Zero sub. ENTIRELY wet (dry: 0). The absence of dry signal and
// the absence of low end are the whole message, and they are what makes coat-vs-hull
// legible inside one frame of a firefight without a single extra dB.
cap: 2, retrig: 0.09, wet: 0.75, dry: 0,
layers: [
{ k: 'noise', n: 'pink', filt: ['bandpass', 700, 6, 380, 0.110], peak: 0.26, a: 0.005, d: 0.115 },
{ k: 'tone', wave: 'sine', f: 150, f2: 95, ft: 0.110, peak: 0.13, a: 0.005, d: 0.105 },
],
},
hull_hit: {
// It reached the INSTRUMENT. Dry 6 ms dropout + an inharmonic struck ring (1 : 1.74 : 2.72,
// higher partials dying first) + sub weight. The carrier stutter, the master duck and the
// 47 Hz ring-mod aberration flash are the ENGINE's half of this cue — they act on buses
// this module cannot see. See the report: engine.js owns them.
cap: 1, retrig: 0.12, wet: 0,
layers: [
{ k: 'noise', n: 'white', peak: 0.34, a: 0.0008, d: 0.005 },
{ k: 'bank', fs: [190, 331, 517], ds: [0.30, 0.22, 0.16], ps: [0.30, 0.20, 0.13], a: 0.002 },
{ k: 'tone', wave: 'sine', f: 120, f2: 38, ft: 0.130, peak: 0.40, a: 0.006, d: 0.330, wet: 0.6 },
],
},
boost: {
// Rising (Law A). The room swell and the carrier glide are the engine's; here we build the
// air, the lift and the settle. Note the bed is LIFTED under boost, not ducked — ducking
// the world under a player-empowerment cue is emotionally backwards. That is engine-side.
cap: 1, wet: 0.45,
layers: [
{ k: 'noise', n: 'white', filt: ['lowpass', 300, 2.6, 4200, 0.260], filtThen: [1400, 0.440], peak: 0.40, a: 0.020, d: 0.010, sus: 0.9, s: 0.24, r: 0.42 },
{ k: 'tone', wave: 'sine', f: 90, f2: 200, ft: 0.300, peak: 0.18, a: 0.012, d: 0.400 },
{ k: 'noise', n: 'pink', at: 0.30, filt: ['bandpass', 1200, 1.0, 400, 0.380], peak: 0.10, a: 0.015, d: 0.380 },
],
},
torpedo: {
cap: 2, wet: 0.50,
layers: [
{ k: 'noise', n: 'brown', filt: ['lowpass', 1800, 1.4, 400, 0.260], peak: 0.30, a: 0.020, d: 0.300 },
{ k: 'tone', wave: 'triangle', f: 220, f2: 60, ft: 0.200, peak: 0.32, a: 0.006, d: 0.280 },
{ k: 'tone', wave: 'sine', f: 90, f2: 45, ft: 0.070, peak: 0.26, a: 0.006, d: 0.120 },
// the fizz IS the chemistry — a 3 Hz tremolo on a 4.5 kHz highpass reads as reaction
{ k: 'noise', n: 'white', filt: ['highpass', 4500, 0.7], peak: 0.10, a: 0.008, d: 0.040, sus: 0.25, s: 0.20, r: 0.20, am: { f: 3, depth: 0.40 } },
],
},
torpedo_blast: {
// The biggest routine transient in the game. This voice alone peaks near 1.0, which is why
// the engine's limiter threshold is -8 and not -3. Two simultaneous blasts is a legitimate
// game state; three is never worth the sub headroom, hence cap 2.
cap: 2, wet: 0.55,
layers: [
{ k: 'noise', n: 'white', filt: ['highpass', 2500, 0.7], peak: 0.30, a: 0.001, d: 0.040 },
{ k: 'noise', n: 'white', shape: true, shapeFade: [0.120, 0.080], filt: ['lowpass', 5200, 1.0, 130, 0.800], peak: 0.62, a: 0.004, d: 0.820 },
{ k: 'tone', wave: 'sine', f: 88, f2: 28, ft: 0.460, peak: 0.58, a: 0.006, d: 0.560 },
// "identity": so the blast is not merely A NOISE. Every explosion in every game is a
// noise burst; the pitched triangle under it is what makes this one antacid.
{ k: 'tone', wave: 'triangle', f: 320, f2: 70, ft: 0.250, peak: 0.16, a: 0.004, d: 0.300 },
{ k: 'noise', n: 'white', at: 0.05, filt: ['highpass', 3000, 0.7, 9000, 0.800], peak: 0.12, a: 0.020, d: 0.820, split: 0.7, splitDelay: 0.011 },
],
},
surge_stall: {
// The antacid bites. The slow-linear-swell / fast-exponential-choke asymmetry IS what
// "freeze" sounds like; a symmetric envelope here just sounds like a wet fart.
cap: 1, wet: 0.30, surge: true,
layers: [
{ k: 'noise', n: 'white', filt: ['bandpass', 1200, 8], choke: [0.22, 0.06], peak: 0.40, a: 0.22, d: 0.06 },
{ k: 'tone', wave: 'sine', at: 0.22, f: 180, f2: 130, ft: 0.260, vib: { f: 6, cents: 30, decay: 0.300 }, peak: 0.36, a: 0.004, d: 0.310 },
{ k: 'seq', wave: 'sine', notes: [987.77, 1318.5], every: 0.060, peak: 0.07, a: 0.030, d: 0.400, wet: 0 },
],
},
surge_end: {
// The best moment in the game, and the only rising instrument figure that isn't a pickup.
cap: 1, wet: 0.45, surge: true,
layers: [
{ k: 'noise', n: 'brown', filt: ['lowpass', 600, 0.7, 3000, 0.900], peak: 0.26, a: 0.030, d: 0.900 },
{ k: 'tone', wave: 'sine', f: 55, f2: 110, ft: 0.700, peak: 0.24, a: 0.020, d: 0.900 },
{ k: 'tone', wave: 'sine', f: 392, f2: 587.33, ft: 0.500, peak: 0.14, a: 0.020, d: 0.520, wet: 0 },
],
},
surge_start: {
// The 1.2 s LINEAR attack on the dread sub is the whole cue. There is no transient. The
// ABSENCE of an attack is what makes this dread rather than impact — you notice it has
// already been happening. Two organ waves 0.62 Hz apart give a slow nauseating wobble for
// the price of zero LFOs.
cap: 1, wet: 0, surge: true,
layers: [
{ k: 'tone', wave: 'sine', f: 34, peak: 0.52, a: 1.20, aLin: true, d: 0.010, sus: 1.0, s: 0.6, r: 0.70 },
{ k: 'tone', wave: 'organ', f: 55.00, shape: true, filt: ['lowpass', 240, 3.0, 900, 2.0], peak: 0.26, a: 0.40, d: 0.010, sus: 1.0, s: 1.4, r: 0.70 },
{ k: 'tone', wave: 'organ', f: 55.62, shape: true, filt: ['lowpass', 240, 3.0, 900, 2.0], peak: 0.26, a: 0.40, d: 0.010, sus: 1.0, s: 1.4, r: 0.70 },
{ k: 'noise', n: 'brown', filt: ['lowpass', 180, 1.2, 900, 2.2], peak: 0.36, a: 0.350, d: 1.40, sus: 1.0, s: 0.4, r: 0.60 },
{ k: 'noise', n: 'white', filt: ['highpass', 4000, 0.7], peak: 0.03, a: 0.30, d: 0.010, sus: 1.0, s: 1.9, r: 0.40, ctrl: 0.06 },
],
},
};
// ── the three warnings. One grammar: the instrument BLIPS, the body ANSWERS underneath ──────
//
// SPEC §3.18-3.20, and the single most likely thing in this file to be got wrong. `hazard:warn`
// carries `eta` (2.5 s of lead by default). A lone beep 2.5 s early is not a warning, it is
// trivia — THE ACCELERATION IS THE INFORMATION. Three pulses at eta-1.9 / eta-1.0 / eta-0.35.
//
// Discrimination uses all three lenses at once, because any one alone fails in a firefight:
// RHYTHM (survives spectral masking, which timbre does not), PITCH CONTOUR, and TIMBRE FAMILY.
const WARN = {
warn_ring_gate: { // the thing AHEAD — a single sharp tick, high and clean
wet: 0.35, blipDur: 0.070,
blip: [
{ k: 'tone', wave: 'sine', f: 880, peak: 0.22, a: 0.004, d: 0.066, wet: 0 },
{ k: 'tone', wave: 'sine', f: 2640, peak: 0.22 / 6, a: 0.004, d: 0.066, wet: 0 },
{ k: 'noise', n: 'white', filt: ['highpass', 3000, 0.7], peak: 0.10, a: 0.0008, d: 0.005, wet: 0 },
],
body: [{ k: 'noise', n: 'white', filt: ['bandpass', 190, 8], peak: 0.14, a: 0.001, d: 0.900, sus: 0.70, s: 0.6, r: 0.70 }],
},
warn_aortic_squeeze: { // the WALLS — a double-tap, mid, with a 9 Hz flutter
wet: 0.40, blipDur: 0.045, taps: [0, 0.090],
blip: [
{ k: 'tone', wave: 'triangle', f: 698.46, filt: ['bandpass', 1000, 2], peak: 0.22, a: 0.004, d: 0.041, am: { f: 9, depth: 0.55 }, wet: 0 },
],
body: [
{ k: 'tone', wave: 'sine', f: 41, peak: 0.16, a: 0.20, d: 0.010, sus: 1.0, s: 1.2, r: 0.40, am: { f: 1.2, depth: 0.55 } },
{ k: 'noise', n: 'white', filt: ['bandpass', 130, 4], peak: 0.12, a: 0.20, d: 0.010, sus: 1.0, s: 1.2, r: 0.40 },
],
},
warn_reflux_surge: { // the thing BEHIND — long-long-LONG, and THE ONLY DESCENDING MOTIF
wet: 0, surge: true, blipDur: 0.110, durs: [0.110, 0.110, 0.170],
notes: [1046.5, 994, 944, 897], // ×0.95 each
blip: [{ k: 'tone', wave: 'sine', f: 1046.5, peak: 0.22, a: 0.006, d: 0.104, wet: 0 }],
body: [
{ k: 'tone', wave: 'sine', f: 33, peak: 0.18, a: 0.060, d: 1.60, sus: 0.4, s: 0.6, r: 0.50 },
{ k: 'noise', n: 'white', filt: ['highpass', 2500, 0.7], peak: 0.10, a: 0.10, d: 0.010, sus: 1.0, s: 1.3, r: 0.50, am: { f: 7, depth: 0.35 } },
],
},
};
// ── the heartbeat pulse. THE ENGINE OWNS THE TEMPO; THIS FILE OWNS THE SOUND. ───────────────
// play('heartbeat', { d, gap }) where d = the danger scalar 0..1 and gap = the S1->S2 spacing
// the engine computed from its own period. We do not schedule beats and we do not keep time.
const HEART = {
s1: { f: 62, fd: 9, f2: 38, ft: 0.090, peak: 0.55, a: 0.006, d: 0.150, slapPeak: 0.30, slapD: 0.075, floorPeak: 0.22, floorD: 0.200 },
s2: { f: 74, fd: 0, f2: 46, ft: 0.090, peak: 0.55, a: 0.006, d: 0.150, slapPeak: 0.30, slapD: 0.075, floorPeak: 0.22, floorD: 0.200, gainMul: 0.65, decayMul: 0.72 },
// The DANGER RESPONSE is the identity, so it is data too. It used to be four bare thresholds
// and three magic numbers inline in beat(), which meant "the heart should strain earlier"
// required reading the renderer. Every value here is "at danger d, ...".
dyn: {
gainBase: 0.55, gainD: 0.45, // body peak = peak * (gainBase + gainD*d)
valveF: 120, valveFD: 140, // valve-slap lowpass cutoff opens with danger — the anxiety
floorF: 34, // the sub floor under every beat
tDistort: 0.70, // above this, S1 goes through the shaper (working too hard)
tStrain: 0.45, strainPeak: 0.10, // the high tick: strain WITHOUT loudness
tMurmur: 0.60, murmurPeak: 0.18, // the low murmur
tSingle: 0.88, // above this the double-thump COLLAPSES to a single thump
tJitter: 0.30, jitter: 0.015, // humanise the S1->S2 gap only while calm
},
};
// Feed static on death: a FIXED table, not a live RNG. House style (hud.js hashes p.s,
// comms.js round-robins) — deterministic means a bug reproduces.
const STATIC_STEPS = [0.30, 0.05, 0.22, 0.02, 0.35, 0.10, 0.04, 0.28, 0.06, 0.18, 0.03, 0.12];
// Cannon detune: ±3 % from an 8-entry table, cycled. Fixed pitch at 8 shots/s produces a
// periodic comb the ear decodes as a buzz; ±20 % reads as TWO DIFFERENT WEAPONS and destroys
// event identity. ±3 % from a table splits the difference and stays reproducible.
const CANNON_DT = [1.000, 0.978, 1.022, 0.991, 1.012, 0.969, 1.004, 1.031];
const CANNON_GAIN = [1.00, 0.94, 0.98, 0.91];
const CEILING_DEFAULT = 24;
// The engine builds FOUR synths on one ctx (sfx / warn / comms / heart). Everything below is
// immutable, ctx-scoped, and identical in all four — ~10 s of Float32 noise (three O(n) passes
// each), a PeriodicWave and a 1024-point curve. Built per-instance that is ~7.7 MB and ~8M sample
// iterations SYNCHRONOUSLY on the unlock gesture, which is exactly the allocation LAW 4 exists to
// prevent, just moved to the integration seam. Keyed on ctx (WeakMap, so a discarded ctx frees
// its buffers). AudioBuffers are read-only once filled, so sharing them across instances is safe;
// nothing per-instance (the cavity, the surge chain, the scrape voice) is cached here, because
// those are wired to a `destination` that differs per instance.
const SHARED = new WeakMap();
export function createSynth(ctx, destination, { ceiling = CEILING_DEFAULT, rnd } = {}) {
// Hard bail if we were handed nothing usable. Never throw — a dead synth is a quiet game,
// a thrown synth is a dead game (ASSETS-OPTIONAL LAW, applied to our own inputs).
if (!ctx || !destination) return stub();
// Deterministic texture RNG (house determinism law: no wall-clock randomness — qa.sh greps
// for it). The engine hands us its ?seed=-derived stream so noise/grains/jitter reproduce on
// every machine; a direct caller falls back to a fixed seed and is still reproducible.
const rand = rnd || mulberry32(0x51ED7357);
let disposed = false;
const timers = [];
const live = []; // { name, vca, srcs, until }
const perm = []; // permanent sources (scrape, chase, static) — stopped in dispose
const nodes = []; // permanent graph nodes to disconnect in dispose
const now = () => ctx.currentTime + 0.005; // 5 ms of slack: scheduling in the past = click
const alive = () => !disposed && ctx.state !== 'closed';
/**
* A tracked setTimeout. EVERY timer this module creates goes through here so dispose() can
* clear it — an untracked timer that fires after teardown touches a graph that is already gone.
* It removes its own id on fire, so `timers` cannot grow across a session of lockouts/surges.
* Deliberately NOT guarded on `disposed`: these callbacks only ever disconnect dead nodes, and
* dispose() itself schedules some of them AFTER clearing the list.
*/
function later(fn, ms) {
const id = setTimeout(() => {
const i = timers.indexOf(id);
if (i >= 0) timers.splice(i, 1);
try { fn(); } catch { /* graph already gone */ }
}, ms);
timers.push(id);
return id;
}
/** Disconnect a set of now-silent nodes once their fades have landed. Never before. */
function dropLater(list, ms) {
later(() => { for (const n of list) { try { n.disconnect(); } catch { /* gone */ } } }, ms);
}
// ═══ built-once resources — once per CONTEXT, not once per instance (see SHARED) ══════════
let shared = SHARED.get(ctx);
if (!shared) {
try {
shared = {
NZ: {
white: noiseBuffer(2.0, 'white'),
pink: noiseBuffer(2.0, 'pink'),
brown: noiseBuffer(4.0, 'brown'),
ctrl: controlNoiseBuffer(2.0),
},
organ: buildOrganWave(),
curve: buildShaperCurve(),
};
SHARED.set(ctx, shared);
} catch {
// A closing ctx throws on createBuffer. Nothing below can work without these, and the
// ASSETS-OPTIONAL LAW applied to our own inputs says: degrade to silence, never throw.
return stub();
}
}
const NZ = shared.NZ;
const WAVE_ORGAN = shared.organ;
const SHAPER_CURVE = shared.curve;
function noiseBuffer(seconds, kind) {
const n = Math.max(1, (ctx.sampleRate * seconds) | 0);
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
const d = buf.getChannelData(0);
let b0 = 0, b1 = 0, b2 = 0, last = 0;
for (let i = 0; i < n; i++) {
const w = rand() * 2 - 1;
if (kind === 'white') d[i] = w;
else if (kind === 'pink') {
// Kellet 3-pole economy filter — pink enough for game audio at a third of the cost.
b0 = 0.99765 * b0 + w * 0.0990460;
b1 = 0.96300 * b1 + w * 0.2965164;
b2 = 0.57000 * b2 + w * 1.0526913;
d[i] = b0 + b1 + b2 + w * 0.1848;
} else {
// LEAKY integrator (0.996), not a pure one. A pure integrator random-walks into
// unbounded DC and will pin the limiter after a couple of seconds of a session-long
// looping bed. This is invisible in a unit test and ruins a long session.
last = 0.996 * last + w * 0.06;
d[i] = last;
}
}
// Mean subtraction + peak normalisation. Non-negotiable: any DC offset here is headroom
// stolen from every voice that uses the buffer, forever.
let mean = 0;
for (let i = 0; i < n; i++) mean += d[i];
mean /= n;
let peak = 1e-9;
for (let i = 0; i < n; i++) { d[i] -= mean; const a = d[i] < 0 ? -d[i] : d[i]; if (a > peak) peak = a; }
const g = 0.95 / peak;
for (let i = 0; i < n; i++) d[i] *= g;
return buf;
}
// Smoothed sub-audio noise, ~14 Hz, centred on 0.5 and scaled -0.5..0.5. Used as a CONTROL
// signal into an AudioParam: control-rate randomness for free, zero JS per frame.
function controlNoiseBuffer(seconds) {
const n = Math.max(2, (ctx.sampleRate * seconds) | 0);
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
const d = buf.getChannelData(0);
const stride = Math.max(1, (ctx.sampleRate / 14) | 0);
let a = rand(), b = rand();
for (let i = 0; i < n; i++) {
const k = i % stride;
if (k === 0) { a = b; b = rand(); }
const x = k / stride;
d[i] = (a + (b - a) * (x * x * (3 - 2 * x))) - 0.5; // smoothstep, so no stair-steps
}
return buf;
}
// One PeriodicWave, band-limited per octave by the API, so it never aliases at any f0.
function buildOrganWave() {
return ctx.createPeriodicWave(
new Float32Array([0, 0, 0, 0, 0, 0, 0, 0]),
new Float32Array([0, 1, 0.28, 0.44, 0.09, 0.19, 0.05, 0.11]),
{ disableNormalization: false },
);
}
// Odd-symmetric ON PURPOSE. An asymmetric curve generates even harmonics AND a DC term the
// highpass then has to clean up after the limiter has already lost the headroom to it.
function buildShaperCurve() {
const c = new Float32Array(1024);
for (let i = 0; i < 1024; i++) {
const x = (i / 1023) * 2 - 1;
c[i] = Math.tanh(x * 2.6) * 0.85;
}
return c;
}
// ═══ the cavity: one send, doubling as the reverb ════════════════════════════════════════
// "Biological" cues go through it; nothing from the instrument family does. That split is the
// sound identity, implemented as one chain instead of two reverbs.
//
// NOTE TO THE ENGINE: this send is INTERNAL and already mixed into our output. Do not put the
// synth's output through a second cavity/convolver — you will double the tails.
const cavityIn = ctx.createGain();
const cavityLP = biquad('lowpass', 1600, 0.5);
const cavityHP = biquad('highpass', 120, 0.7);
const cavityRet = ctx.createGain();
cavityRet.gain.value = 0.24;
cavityIn.connect(cavityLP);
cavityLP.connect(cavityHP); // bypass until the IR lands, so early wet isn't silent
cavityHP.connect(cavityRet);
cavityRet.connect(destination);
nodes.push(cavityIn, cavityLP, cavityHP, cavityRet);
let convolver = null;
// Deferred: ~12 ms of buffer generation must not land inside time-to-interactive.
timers.push(setTimeout(() => {
if (!alive()) return;
try {
// The IR is generated once per CONTEXT and shared, like the noise buffers — four instances
// generating four identical impulse responses is the same waste one level down. The
// ConvolverNode itself stays per-instance; only the buffer is shared.
let ir = shared.ir;
if (!ir) {
const dur = ctx.sampleRate > 48000 ? 0.5 : 0.7;
const n = (ctx.sampleRate * dur) | 0;
ir = ctx.createBuffer(1, n, ctx.sampleRate);
const d = ir.getChannelData(0);
for (let i = 0; i < n; i++) {
const t = i / ctx.sampleRate;
// The 6 ms of leading silence is what keeps early reflections off the attack
// transients. A naive IR with energy at t=0 is exactly why cannon shots turn to mush.
d[i] = t < 0.006 ? 0 : (rand() * 2 - 1) * Math.pow(1 - t / dur, 4.5);
}
shared.ir = ir;
}
convolver = ctx.createConvolver();
convolver.normalize = true;
convolver.buffer = ir;
cavityLP.disconnect();
cavityLP.connect(convolver);
convolver.connect(cavityHP);
nodes.push(convolver);
} catch { /* no convolution: the dry bypass above is already connected and audible */ }
}, 0));
// ═══ the rear channel (surgeChain) ═══════════════════════════════════════════════════════
// "Behind you" in stereo is mostly DULLER and WIDER. As the surge closes, the highshelf goes
// -6 -> 0 dB and the left delay 12 -> 2 ms: it BRIGHTENS AND NARROWS TO A POINT as it reaches
// you. That is the entire level's tension in two parameters, and it is the honest way to fake
// position without importing anything from flight/ or combat/ (House Law 1).
//
// BASS IS SUMMED TO CENTRE BEFORE THE HAAS SPLIT, and that is not a nicety. A 12 ms delay on one
// side is a comb filter when the two sides are summed — which is what happens on every laptop
// and phone speaker — with its first notch at 1/(2*0.012) = 41.7 Hz. surge_start's whole point
// is a 34 Hz dread sub and a 55/55.62 Hz organ pair; those sit right in the notch and would
// partially CANCEL on the most common playback device, and on headphones they would be a hole
// in the middle instead of weight. So: everything under 150 Hz goes straight down the middle,
// and only the band above it gets widened. The width cue lives in the top end anyway — that is
// how ears localise, and it is why every mastering chain in the world is mono below ~120 Hz.
const surgeIn = ctx.createGain();
const surgeSub = biquad('lowpass', 150, 0.7); // centre path — never delayed, never panned
const surgeHi = biquad('highpass', 150, 0.7); // wide path
const surgeShelf = ctx.createBiquadFilter();
surgeShelf.type = 'highshelf';
surgeShelf.frequency.value = 3500;
surgeShelf.gain.value = -6;
const surgeDelayL = ctx.createDelay(0.05);
surgeDelayL.delayTime.value = 0.012;
const surgePanL = panner(-0.85);
const surgePanR = panner(0.85);
surgeIn.connect(surgeSub);
surgeSub.connect(destination);
surgeIn.connect(surgeHi);
surgeHi.connect(surgeShelf);
surgeShelf.connect(surgeDelayL);
surgeDelayL.connect(surgePanL || destination);
surgeShelf.connect(surgePanR || destination);
if (surgePanL) surgePanL.connect(destination);
if (surgePanR) surgePanR.connect(destination);
// Every node on this path goes in `nodes` — the panners were previously connected to the
// destination and never disconnected, which is a graph leak on every level transition.
nodes.push(surgeIn, surgeSub, surgeHi, surgeShelf, surgeDelayL);
if (surgePanL) nodes.push(surgePanL);
if (surgePanR) nodes.push(surgePanR);
// ═══ generic builders ════════════════════════════════════════════════════════════════════
/**
* Anchor a param at its CURRENT value so a following ramp starts from where we actually are,
* clamped away from zero. Both halves matter: without the anchor the ramp starts from the last
* SCHEDULED value (not the audible one) and jumps; and an anchor of exactly 0 makes a following
* exponential ramp illegal — Chrome silently no-ops it and the gain stays stuck where it was.
* cancelAndHoldAtTime would do this in one call but does not exist in Firefox.
*/
function anchor(param, t) {
const v = Number.isFinite(param.value) ? param.value : 0;
param.cancelScheduledValues(t);
param.setValueAtTime(Math.max(v, 1e-4), t);
return param;
}
function biquad(type, f, q) {
const b = ctx.createBiquadFilter();
b.type = type;
b.frequency.value = f;
b.Q.value = q ?? 1;
return b;
}
function panner(v) {
if (!ctx.createStereoPanner) return null; // ancient Safari — mono is a fine degradation
const p = ctx.createStereoPanner();
p.pan.value = v;
return p;
}
/**
* THE envelope. Every cue in this file is expressed in it, which is why no cue can click.
* a/d/s/r in SECONDS; `sus` is a 0..1 fraction of peak (0 disables the sustain stage).
* Returns { node, end, park }:
* park the instant the gain is pinned at TRUE zero. NOTHING may still be summing into this
* gain param after `park` — an LFO or control-noise source that keeps running past it
* is added to an intrinsic value of 0, so the VCA RE-OPENS to ±depth after the note is
* supposed to be over, and then hard-cuts when the source stops. Every modulator gets
* faded out and stopped at `park`, never at `end`.
* end when the SOURCE may be stopped — deliberately later than `park`, so the source is
* still running (into a zero gain) while the park lands. Stopping a source is a step
* discontinuity in its own output; doing it behind a closed VCA is what makes it silent.
*/
function adsr(t, peak, a, d, sus = 0, s = 0, r = 0.02, aLin = false) {
const g = ctx.createGain();
const p = Math.max(peak, 1e-4);
a = Math.max(a, 0.002); // LAW 1: floor the attack
d = Math.max(d, 0.002);
g.gain.setValueAtTime(0, t); // LAW 2: explicit anchor, or the ramp no-ops
g.gain.linearRampToValueAtTime(p, t + a); // attack is ALWAYS linear (aLin is documentary)
let end;
if (sus > 0) {
const sv = Math.max(p * sus, 1e-4);
g.gain.exponentialRampToValueAtTime(sv, t + a + d);
g.gain.setValueAtTime(sv, t + a + d + s);
const rr = Math.max(r, 0.006); // LAW 1: floor the release
g.gain.exponentialRampToValueAtTime(1e-4, t + a + d + s + rr);
end = t + a + d + s + rr;
} else {
g.gain.exponentialRampToValueAtTime(1e-4, t + a + d); // LAW 2: never ramp TO zero
end = t + a + d;
}
const park = end + 0.001;
g.gain.setValueAtTime(0, park); // park at true zero once the ramp has landed
return { node: g, end: end + 0.02, park };
}
/**
* Schedule a modulator's depth so it has reached EXACTLY zero by the time the envelope it feeds
* parks. Anything summed into a gain param must die before the param does; see adsr's `park`.
* The 6 ms taper is LAW 1 applied to the modulation path itself — snapping a depth to 0 is the
* same step discontinuity as snapping a gain to 0, it is just one level of indirection away.
*/
function modDepth(param, depth, t, park) {
const off = Math.max(park - 0.006, t + 0.0005);
const to = Math.max(park, off + 0.0005);
param.setValueAtTime(depth, t);
param.setValueAtTime(depth, off);
param.linearRampToValueAtTime(0, to);
return to;
}
function oscOf(wave, f, t) {
const o = ctx.createOscillator();
if (wave === 'organ') o.setPeriodicWave(WAVE_ORGAN);
else o.type = wave || 'sine';
o.frequency.setValueAtTime(Math.max(f, 0.0001), t);
return o;
}
function noiseOf(kind, t) {
const src = ctx.createBufferSource();
const buf = NZ[kind] || NZ.white;
src.buffer = buf;
src.loop = true;
// Random START OFFSET, every time. Without it every squelch in the game begins on the
// identical sample sequence and the ear decodes it as A SAMPLE rather than as noise.
// (Loop-offset jitter: a texture, not a game value — so it draws from the seeded stream
// like everything else here, keeping the same ?seed reproducible on every machine.)
src.start(t, rand() * Math.max(0.01, buf.duration - 0.5));
return src;
}
function shaperNode() {
const w = ctx.createWaveShaper();
w.curve = SHAPER_CURVE;
w.oversample = '2x';
return w;
}
/** Apply a [type, f, Q, f2?, ft?] filter spec, with an optional second-stage sweep. */
function applyFilt(spec, t, mul, then) {
const b = biquad(spec[0], spec[1] * mul, spec[2]);
if (spec[3] != null) {
b.frequency.setValueAtTime(spec[1] * mul, t);
b.frequency.exponentialRampToValueAtTime(Math.max(spec[3] * mul, 1), t + (spec[4] ?? 0.1));
if (then) b.frequency.exponentialRampToValueAtTime(Math.max(then[0] * mul, 1), t + (spec[4] ?? 0.1) + then[1]);
}
return b;
}
// ═══ the renderer: DATA -> NODES ═════════════════════════════════════════════════════════
/**
* Render one cue's layers. `o` carries the per-play modulations:
* gain (master multiplier), detune (cents), step (semitone ladder), mul (peak multiplier),
* d (danger, heartbeat only), routeSurge (send through the rear channel instead of dry).
*/
function render(cue, layers, t0, o, sink) {
const srcs = [];
const vca = ctx.createGain(); // dry path
vca.gain.value = 1;
const wetSum = ctx.createGain(); // wet path, summed per voice
wetSum.gain.value = 1;
const dryG = ctx.createGain();
dryG.gain.value = cue.dry ?? 1;
vca.connect(dryG);
dryG.connect(sink);
wetSum.connect(cavityIn);
// Dry and wet are two nodes, not one, so that stealing a voice silences BOTH. Routing the
// wet send straight off the layer would leave a 0.7 s reverb tail of a voice we just killed.
const bus = { vca, wetSum };
let end = t0 + 0.05;
for (const L of layers) {
const reps = L.rep ?? 1;
for (let ri = 0; ri < reps; ri++) {
const at = t0 + (L.at ?? 0) + ri * (L.every ?? 0);
const e = buildLayer(L, at, o, bus, srcs, cue);
if (e > end) end = e;
}
}
return { vca, wetSum, srcs, end };
}
/** Route one layer's output: dry through the voice VCA, wet into the voice's cavity send. */
function sinkFor(L, cue, bus, gainOut) {
const wet = L.wet ?? cue.wet ?? 0;
gainOut.connect(bus.vca);
if (wet > 0) {
const w = ctx.createGain();
w.gain.value = wet;
gainOut.connect(w);
w.connect(bus.wetSum);
}
}
function buildLayer(L, t, o, bus, srcs, cue) {
const lad = L.lad === 0 ? 1 : Math.pow(2, (o.step ?? 0) / 12);
const mul = (o.mul ?? 1) * (o.gain ?? 1);
const peak = (L.peak ?? 0.2) * mul;
const dur = L.durOverride ?? L.d;
if (L.k === 'seq') {
// A sequence of fixed tones — used wherever the FIGURE is the sound (rising fifths,
// confirm triads, the descending reflux motif).
let end = t;
const notes = L.notes || [];
for (let i = 0; i < notes.length; i++) {
const at = t + (L.ats ? L.ats[i] : i * (L.every ?? 0.06));
const e = buildLayer(
{ ...L, k: 'tone', f: notes[i], at: 0, notes: undefined,
peak: (L.peaks ? L.peaks[i] : L.peak), d: (L.ds ? L.ds[i] : L.d) },
at, o, bus, srcs, cue,
);
if (e > end) end = e;
}
return end;
}
if (L.k === 'bank') {
// Inharmonic partials with per-partial decay. Higher partials die FIRST — that is the
// difference between "struck structure" and "organ chord", and it is entirely in the ds[].
let end = t;
for (let i = 0; i < L.fs.length; i++) {
const env = adsr(t, L.ps[i] * mul, L.a ?? 0.002, L.ds[i]);
const osc = oscOf('sine', L.fs[i] * lad, t);
if (o.detune) osc.detune.setValueAtTime(o.detune, t);
osc.connect(env.node);
sinkFor(L, cue, bus, env.node);
osc.start(t);
osc.stop(env.end);
srcs.push(osc);
if (env.end > end) end = env.end;
}
return end;
}
// ── tone / noise share the envelope + filter + routing tail ──
const env = adsr(t, peak, L.a ?? 0.005, dur ?? 0.05, L.sus ?? 0, L.s ?? 0, L.r ?? 0.02, L.aLin);
let head = null; // the node the source ultimately reaches the envelope through
let src = null;
if (L.k === 'tone') {
src = oscOf(L.wave, L.f * lad, t);
if (o.detune) src.detune.setValueAtTime(o.detune, t);
if (L.f2 != null) {
src.frequency.setValueAtTime(L.f * lad, t);
if (L.sweep === 'lin') src.frequency.linearRampToValueAtTime(L.f2 * lad, t + L.ft);
else src.frequency.exponentialRampToValueAtTime(Math.max(L.f2 * lad, 0.0001), t + L.ft);
}
if (L.vib) {
// Vibrato that DECAYS to zero — a constant vibrato reads as a synth patch, a decaying
// one reads as something losing its grip. That is the sputter in surge_stall.
const lfo = oscOf('sine', L.vib.f, t);
const amt = adsr(t, L.vib.cents, 0.004, L.vib.decay);
lfo.connect(amt.node);
amt.node.connect(src.detune);
lfo.start(t);
lfo.stop(env.end);
srcs.push(lfo);
}
// LAW 3, both halves: an oscillator must be start()ed (noiseOf does its own, because it
// needs the random buffer offset) and it must be stop()ed at the tail — and start MUST
// come first, or stop() throws InvalidStateError and the whole cue is silently lost.
src.start(t);
head = src;
} else {
src = noiseOf(L.n, t);
head = src;
if (L.split != null) {
// A stereo pair with the right channel delayed — decorrelation without a second source.
const pl = panner(-L.split), pr = panner(L.split);
const dl = ctx.createDelay(0.05);
dl.delayTime.value = L.splitDelay ?? 0.011;
if (pl && pr) {
const f1 = L.filt ? applyFilt(L.filt, t, lad, L.filtThen) : ctx.createGain();
src.connect(f1);
f1.connect(pl); pl.connect(env.node);
f1.connect(dl); dl.connect(pr); pr.connect(env.node);
sinkFor(L, cue, bus, env.node);
src.stop(env.end);
srcs.push(src);
return env.end;
}
}
}
let chain = head;
if (L.shape) {
const sh = shaperNode();
if (L.shapeFade) {
// Crossfade OUT of the shaper: distorted for the first N ms, clean after. The blast is
// saturated at the transient and clean in the tail, which is how real loud things decay.
const gS = ctx.createGain(), gC = ctx.createGain();
gS.gain.setValueAtTime(1, t);
gS.gain.setValueAtTime(1, t + L.shapeFade[0]);
gS.gain.linearRampToValueAtTime(0, t + L.shapeFade[0] + L.shapeFade[1]);
gC.gain.setValueAtTime(0, t);
gC.gain.setValueAtTime(0, t + L.shapeFade[0]);
gC.gain.linearRampToValueAtTime(1, t + L.shapeFade[0] + L.shapeFade[1]);
chain.connect(sh); sh.connect(gS);
chain.connect(gC);
const sum = ctx.createGain();
gS.connect(sum); gC.connect(sum);
chain = sum;
} else {
chain.connect(sh);
chain = sh;
}
}
if (L.filt) {
const f1 = applyFilt(L.filt, t, lad, L.filtThen);
chain.connect(f1);
chain = f1;
if (L.filt2) {
const f2 = applyFilt(L.filt2, t, lad, null);
// Q STEPS, it never sweeps — a swept high-Q biquad is unstable. A step on Q is inaudible.
if (L.qStep) f2.Q.setValueAtTime(L.qStep[0], t + L.qStep[1]);
chain.connect(f2);
chain = f2;
}
}
if (L.choke) {
// surge_stall: slow LINEAR swell, then a fast EXPONENTIAL choke. The asymmetry is the
// whole point — it is what "freeze" sounds like. Overwrites the adsr for this layer.
const g = env.node.gain;
g.cancelScheduledValues(t);
g.setValueAtTime(0, t);
g.linearRampToValueAtTime(peak, t + L.choke[0]);
g.exponentialRampToValueAtTime(1e-4, t + L.choke[0] + L.choke[1]);
g.setValueAtTime(0, t + L.choke[0] + L.choke[1] + 0.001);
}
chain.connect(env.node);
if (L.am) {
// AudioParam-rate AM: the LFO is SUMMED INTO the envelope's gain param. Must be start()ed
// and stop()ed with the voice or it leaks a running oscillator forever (LAW 3).
//
// depth is a FRACTION OF PEAK, not an absolute gain. Absolute depth has two failure modes
// and this file shipped both: retuning `peak` silently changed the modulation index, and a
// depth larger than peak (dart: 0.30 against a peak of 0.26) drove the gain param NEGATIVE
// for most of every LFO cycle — polarity-inverting ring modulation, which is buzzy and
// cheap, not the muscular push the recipe is describing. As a fraction it cannot invert.
const lfo = oscOf('sine', L.am.f, t);
const dep = ctx.createGain();
lfo.connect(dep);
dep.connect(env.node.gain);
// Faded to zero and stopped at the PARK, not at env.end: 19 ms of ±depth on a closed VCA
// is a re-opened note followed by a hard cut, on every dart in the game.
modDepth(dep.gain, L.am.depth * peak, t, env.park);
lfo.start(t);
lfo.stop(env.park);
srcs.push(lfo);
}
if (L.ctrl) {
// Control-rate noise into the gain param: free, smooth, zero JS per frame. Same park rule
// as `am` — this one is a looping buffer, so left running it would flicker a dead VCA.
const c = ctx.createBufferSource();
c.buffer = NZ.ctrl;
c.loop = true;
const cg = ctx.createGain();
c.connect(cg);
cg.connect(env.node.gain);
modDepth(cg.gain, L.ctrl * mul, t, env.park);
c.start(t, rand() * 1.5);
c.stop(env.park);
srcs.push(c);
}
if (L.pan != null) {
const amt = L.panAmt ?? 0.12;
const p = panner(L.pan === 'alt' ? (o.side ? amt : -amt) : L.pan);
if (p) {
env.node.connect(p);
sinkFor(L, cue, bus, p);
} else sinkFor(L, cue, bus, env.node);
} else {
sinkFor(L, cue, bus, env.node);
}
src.stop(env.end);
srcs.push(src);
return env.end;
}
// ═══ voice bookkeeping ═══════════════════════════════════════════════════════════════════
function prune() {
const t = ctx.currentTime;
for (let i = live.length - 1; i >= 0; i--) if (live[i].until < t) live.splice(i, 1);
}
/** Steal = ramp to silence and stop. NEVER disconnect() a sounding node — that is a click. */
function kill(v, t) {
try {
for (const n of [v.vca, v.wetSum]) {
if (!n) continue;
const g = n.gain;
g.cancelScheduledValues(t);
g.setValueAtTime(g.value, t);
g.linearRampToValueAtTime(1e-4, t + 0.008);
}
// stop() at a time earlier than a source's scheduled start simply means it never plays —
// which is exactly what we want for a stolen voice whose pulses were still in the future.
for (const s of v.srcs) { try { s.stop(t + 0.01); } catch { /* already stopped */ } }
} catch { /* node already gone */ }
}
const lastAt = new Map(); // name -> ctx time of the last accepted voice (retrigger gate)
function admit(name, cue, t) {
prune();
const rt = cue?.retrig ?? 0;
if (rt > 0 && t - (lastAt.get(name) ?? -1e9) < rt) return false;
const cap = cue?.cap ?? 4;
let n = 0, oldest = null;
for (const v of live) if (v.name === name) { n++; if (!oldest || v.start < oldest.start) oldest = v; }
if (n >= cap) {
if (!oldest) return false;
kill(oldest, t);
live.splice(live.indexOf(oldest), 1);
} else if (live.length >= ceiling) {
// Global ceiling with nothing of our own name to steal: DROP. Stealing across names is
// how a cannon burst eats your surge warning.
return false;
}
lastAt.set(name, t);
return true;
}
function emit(name, cue, layers, t, o, sink) {
// prune() lives HERE and not only in admit(), because the cues that bypass admit() are
// exactly the ones that fire forever: the heartbeat (1-3/s for the whole session) and the
// warnings (7 emits each). Pruned only from admit(), `live` grew ~2-6 entries/s during any
// stretch with no admitted cue, each retaining a GainNode and 3-5 AudioNodes that could then
// never be collected — five idle minutes was ~9000 retained nodes, and dispose() then had to
// kill() every one of them. Pruning on the way IN costs one pass over a list that is, by
// construction, at most `ceiling` long.
prune();
const r = render(cue, layers, t, o, sink || destination);
live.push({ name, vca: r.vca, wetSum: r.wetSum, srcs: r.srcs, until: r.end + 0.2, start: t });
return r;
}
// ═══ stateful voices ═════════════════════════════════════════════════════════════════════
// ── wall_scrape: ONE continuous voice, built on first use, then never stopped ─────────────
// This is a DSP hazard, not a taste call. The cue fires many times per second while you grind
// a wall; a voice per cue is a machine-gun stutter that burns the voice budget and is the
// single biggest source of mix mud in this genre. Instead: a permanently running noise source
// whose VCA is re-excited per cue and decays asymptotically. ~0.1 % of a core, forever.
//
// LAZY, because the engine builds four synths and routes wall_scrape to exactly ONE of them.
// Built eagerly it was four always-running voices — two looping BufferSources and four biquads
// each — three of which could never be excited by anything. "Forever" is a promise you only get
// to make about a voice something can actually play.
let scrapeVCA = null, scrapeBP = null, scrapeBuilt = false;
function buildScrape() {
scrapeBuilt = true;
try {
const src = ctx.createBufferSource();
src.buffer = NZ.pink;
src.loop = true;
scrapeBP = biquad('bandpass', 1100, 3.5);
const lp = biquad('lowpass', 500, 1.0); // parallel "meat" path
scrapeVCA = ctx.createGain();
scrapeVCA.gain.value = 0;
src.connect(scrapeBP); scrapeBP.connect(scrapeVCA);
src.connect(lp); lp.connect(scrapeVCA);
scrapeVCA.connect(destination);
const w = ctx.createGain();
w.gain.value = 0.70;
scrapeVCA.connect(w); w.connect(cavityIn);
// jitter: a second looping noise source driving the bandpass = the texture of DRAGGING
const j = ctx.createBufferSource();
j.buffer = NZ.ctrl;
j.loop = true;
const jg = ctx.createGain();
jg.gain.value = 400;
j.connect(jg); jg.connect(scrapeBP.frequency);
src.start(0, rand() * 1.5);
j.start(0, rand() * 1.5);
perm.push(src, j);
nodes.push(scrapeBP, lp, scrapeVCA, w, jg);
} catch { scrapeVCA = null; }
}
function scrape(amount, t) {
if (!scrapeBuilt) buildScrape();
if (!scrapeVCA) return false;
// `6` is a normalisation floor for `amount`, chosen because combat/balance.js cannot be
// imported (House Law 1). Retune here if wall damage is rebalanced.
const peak = 0.12 + 0.20 * Math.min((amount ?? 3) / 6, 1);
const g = scrapeVCA.gain;
g.cancelScheduledValues(t);
g.setValueAtTime(g.value, t); // anchor at the CURRENT value, or the ramp starts
g.linearRampToValueAtTime(peak, t + 0.012); // from a stale SCHEDULED value and jumps
g.setTargetAtTime(0, t + 0.012, 0.11); // asymptotic: below -80 dB in ~0.9 s
const f = scrapeBP.frequency;
f.cancelScheduledValues(t);
f.setValueAtTime(f.value, t);
f.linearRampToValueAtTime(900 + rand() * 700, t + 0.05);
return true;
}
// ── overheat: a LOCKOUT STATE, not a beep ────────────────────────────────────────────────
// A one-shot here is a design bug: the player mashes fire into silence and concludes the game
// is broken. The steam SUSTAINS until the engine calls play('overheat_clear') on the falling
// edge of combat:state.overheated (there is no overheat_clear cue in the bus — §9 asks C for
// a `weapon:heat_clear` edge; until then the engine tracks it).
let heatVoice = null;
function overheat(t, o) {
overheatClear(t, true);
const srcs = [];
const vca = ctx.createGain();
vca.gain.value = 1;
vca.connect(destination);
// steam: sustains indefinitely (r is applied later, by overheatClear)
const steam = ctx.createGain();
const hold = Math.max(0.10 * (o.gain ?? 1), 1e-4);
steam.gain.setValueAtTime(0, t);
steam.gain.linearRampToValueAtTime(0.34 * (o.gain ?? 1), t + 0.010);
steam.gain.exponentialRampToValueAtTime(hold, t + 0.130);
// SAFETY NET. This layer is designed to sustain until the engine calls overheat_clear on the
// falling edge of combat:state.overheated — but if that call never arrives (a routing bug, a
// missed edge, a level torn down mid-lockout) the steam would hiss forever, and "the audio is
// permanently broken" is a far worse failure than "the lockout tone ended early". So the
// release is ALSO pre-scheduled on the audio clock at +12 s, which is far longer than any
// real lockout. overheat_clear cancels it, so in the normal path this never fires.
const CAP = 12;
steam.gain.setValueAtTime(hold, t + CAP);
steam.gain.exponentialRampToValueAtTime(1e-4, t + CAP + 0.4);
steam.gain.setValueAtTime(0, t + CAP + 0.41);
const sf = biquad('highpass', 2400, 0.7);
sf.frequency.setValueAtTime(2400, t);
sf.frequency.exponentialRampToValueAtTime(5200, t + 0.700);
const sn = noiseOf('white', t);
sn.connect(sf); sf.connect(steam); steam.connect(vca);
// 3.2 Hz tremolo on the held steam — a STATE that breathes, so it never becomes wallpaper.
// Its depth is a fraction of the SUSTAINED level, and it must be ramped out with the steam:
// left at full depth through the release, the steam does not fade to silence, it fades to a
// 78 ms tremolo ~8 dB down and then clicks off. The gain and its modulator die together or
// neither of them does. overheatClear() ramps this; the +12 s safety net below pre-schedules
// the same ramp so the un-cleared path releases just as cleanly.
const trem = oscOf('sine', 3.2, t);
const td = ctx.createGain();
const tdDepth = hold * 0.40;
td.gain.setValueAtTime(tdDepth, t);
td.gain.setValueAtTime(tdDepth, t + CAP);
td.gain.linearRampToValueAtTime(0, t + CAP + 0.4);
trem.connect(td); td.connect(steam.gain);
trem.start(t);
srcs.push(sn, trem);
// fault tone: two pitches beating at ~128 Hz. A FAULT, not a siren (§8 bans sirens).
const ftPeak = 0.28 * (o.gain ?? 1);
const ft = adsr(t, ftPeak, 0.006, 0.010, 1.0, 0.60, 0.12);
const fb = biquad('bandpass', 1000, 3);
const sq = oscOf('square', 1046.5, t);
const si = oscOf('sine', 1174.7, t);
const sig = ctx.createGain();
sig.gain.value = 0.5;
sq.connect(fb);
si.connect(sig); sig.connect(fb);
fb.connect(ft.node); ft.node.connect(vca);
// The 11 Hz gate. THREE things were wrong with it and all three were audible:
// - it was a SQUARE, i.e. an instantaneous jump on a live gain param eleven times a second.
// That is LAW 1 broken by proxy — the discontinuity is in the modulator, but the click
// comes out of the VCA all the same. A triangle gates just as hard to the ear and has no
// step in it; the fault reads as a relay chattering, not as a fizz of clicks.
// - its depth was an absolute 0.5 against a tone sustaining at 0.28, so it inverted polarity
// every cycle and ran the fault tone at ~1.8x its designed peak. Now it is a fraction.
// - it ran 19 ms past the envelope's park, re-opening a dead VCA at ±0.5 and then hard-
// stopping: the loudest click in the file. It now fades and stops at ft.park.
const gate = oscOf('triangle', 11, t);
const gd = ctx.createGain();
gate.connect(gd); gd.connect(ft.node.gain);
modDepth(gd.gain, 0.45 * ftPeak, t, ft.park);
sq.start(t); si.start(t); gate.start(t);
sq.stop(ft.end); si.stop(ft.end); gate.stop(ft.park);
srcs.push(sq, si, gate);
// seize: the mechanism stopping
const sz = adsr(t, 0.24 * (o.gain ?? 1), 0.004, 0.200);
const so = oscOf('sine', 150, t);
so.frequency.setValueAtTime(150, t);
so.frequency.exponentialRampToValueAtTime(60, t + 0.080);
so.connect(sz.node); sz.node.connect(vca);
so.start(t); so.stop(sz.end);
srcs.push(so);
// A later stop() supersedes an earlier one, so overheat_clear's 0.30 s stop wins over this.
sn.stop(t + CAP + 0.5);
trem.stop(t + CAP + 0.5);
heatVoice = { vca, srcs, steam, td, sustained: [sn, trem] };
return true;
}
function overheatClear(t, silent = false) {
if (!heatVoice) return false;
const v = heatVoice;
heatVoice = null;
try {
const g = anchor(v.steam.gain, t);
g.exponentialRampToValueAtTime(1e-4, t + 0.220); // 220 ms release
g.setValueAtTime(0, t + 0.222);
// The tremolo depth releases WITH the steam, over the same 220 ms — see overheat(). Held
// flat it would be all that is left after the gain parks, and a release that ends in a
// tremolo is not a release.
if (v.td) {
const depth = Number.isFinite(v.td.gain.value) ? v.td.gain.value : 0;
v.td.gain.cancelScheduledValues(t);
modDepth(v.td.gain, depth, t, t + 0.220);
}
for (const s of v.sustained) { try { s.stop(t + 0.30); } catch { /* stopped */ } }
for (const s of v.srcs) { try { s.stop(t + 0.30); } catch { /* stopped */ } }
// The lockout's own VCA is not in `nodes` (a session can have many lockouts and `nodes` is
// for the permanent graph), so it is disconnected here once the release has landed.
dropLater([v.vca], 400);
if (!silent) {
// the clear tick RISES (Law A: you got something back)
const e = adsr(t, 0.12, 0.002, 0.090);
const o = oscOf('sine', 1600, t);
o.frequency.setValueAtTime(1600, t);
o.frequency.exponentialRampToValueAtTime(2200, t + 0.008);
o.connect(e.node); e.node.connect(destination);
o.start(t); o.stop(e.end);
}
} catch { /* context gone */ }
return true;
}
// ── the surge chase loop ─────────────────────────────────────────────────────────────────
// Starts with surge_start, runs until surge_end. Its gain follows proximity — but ALWAYS via
// setTargetAtTime, never setValueAtTime per frame, which zippers audibly.
let chase = null;
const BASE = 0.10; // the loop's nominal gain
const WOB = 0.30; // 7 Hz wobble depth, AS A FRACTION OF THE GAIN — see startChase
function startChase(t) {
if (chase) return;
try {
const src = noiseOf('white', t);
const hp = biquad('highpass', 2500, 0.7);
const vca = ctx.createGain();
vca.gain.setValueAtTime(1e-4, t);
vca.gain.linearRampToValueAtTime(BASE, t + 1.5);
// The 7 Hz wobble's depth is a FRACTION of the chase gain and it is ramped with it, always.
// Fixed at 0.030 against a base of 0.10 it broke every transition this loop has: the fade-in
// popped in at ±0.030 instead of rising from silence, the 1.2 s fade-out bottomed out at
// -10.5 dB of throbbing instead of reaching zero, and `stall` (base*0.35 = 0.035) came out
// as 86 % modulation — a violent throb, not "thin and quiet". A modulator that cannot be
// ramped is a gain that cannot be ramped.
const lfo = oscOf('sine', 7, t);
const ld = ctx.createGain();
ld.gain.setValueAtTime(1e-6, t);
ld.gain.linearRampToValueAtTime(BASE * WOB, t + 1.5);
lfo.connect(ld); ld.connect(vca.gain);
lfo.start(t);
src.connect(hp); hp.connect(vca); vca.connect(surgeIn);
// a continuous 33 Hz floor under the fizz — the mass of the thing, not its surface
const sub = oscOf('sine', 33, t);
const sg = ctx.createGain();
sg.gain.setValueAtTime(1e-4, t);
sg.gain.linearRampToValueAtTime(0.06, t + 1.5);
sub.connect(sg); sg.connect(surgeIn);
sub.start(t);
chase = { src, hp, vca, lfo, ld, sub, sg, base: BASE, wob: WOB, stallT: 0 };
perm.push(src, lfo, sub);
} catch { chase = null; }
}
/** proximity 0..1 (1 = on top of you). Drives the chase gain AND the rear channel's colour. */
function chaseTo(p, t) {
p = p > 1 ? 1 : p < 0 ? 0 : p;
if (chase && !chase.stallT) {
// Not while stalled: proximity keeps arriving during a stall (hazard:proximity fires either
// way) and would fight the thinning ramp for control of the same param.
const g = Math.max(chase.base * (0.5 + 1.6 * p), 1e-4);
chase.vca.gain.setTargetAtTime(g, t, 0.08);
chase.ld.gain.setTargetAtTime(Math.max(g * chase.wob, 1e-6), t, 0.08);
chase.sg.gain.setTargetAtTime(Math.max(0.06 * (0.5 + 1.4 * p), 1e-4), t, 0.08);
}
// brighter and narrower as it closes: the whole level's tension in two params
surgeShelf.gain.setTargetAtTime(-6 + 6 * p, t, 0.4);
surgeDelayL.delayTime.setTargetAtTime(0.012 - 0.010 * p, t, 0.4);
return true;
}
// A neutralised surge is parked for balance.js's `neutralizeDuration` (10 s at time of writing).
// SELF-HEALING, for the same reason overheat() pre-schedules its own release: nothing in the
// repo emits `surge_unstall`, so a stall that waited for a caller would leave the rear channel
// thin and quiet FOR THE REST OF THE LEVEL after a single antacid torpedo — an audible
// regression, not a missing feature. The recovery is therefore scheduled on the audio clock at
// +13 s (comfortably past any real stall). If `surge_unstall` ever does arrive it simply
// cancels this and recovers early, which is the correct precedence.
const STALL_CAP = 13;
function stall(t, on) {
if (!chase) return;
const c = chase;
const f = c.hp.frequency, g = c.vca.gain, ld = c.ld.gain;
const thin = Math.max(c.base * 0.35, 1e-4);
// All three params are re-anchored together; `ld` moves with `g` everywhere in this file.
f.cancelScheduledValues(t); f.setValueAtTime(f.value, t);
g.cancelScheduledValues(t); g.setValueAtTime(Math.max(g.value, 1e-4), t);
ld.cancelScheduledValues(t); ld.setValueAtTime(Math.max(ld.value, 1e-6), t);
if (on) {
// thin and quiet: the acid is still there, it just cannot reach you for a moment
c.stallT = t;
f.exponentialRampToValueAtTime(6000, t + 0.250);
g.linearRampToValueAtTime(thin, t + 0.250);
ld.linearRampToValueAtTime(Math.max(thin * c.wob, 1e-6), t + 0.250);
// ...and the recovery, pre-scheduled on the audio clock right now. See STALL_CAP.
const r = t + STALL_CAP;
f.exponentialRampToValueAtTime(6000, r);
f.exponentialRampToValueAtTime(2500, r + 0.800);
g.setValueAtTime(thin, r);
g.linearRampToValueAtTime(c.base, r + 0.800);
ld.setValueAtTime(Math.max(thin * c.wob, 1e-6), r);
ld.linearRampToValueAtTime(Math.max(c.base * c.wob, 1e-6), r + 0.800);
// the JS-side flag, so chaseTo() takes the params back once that ramp has landed
later(() => { if (chase === c) c.stallT = 0; }, (STALL_CAP + 0.8) * 1000);
} else {
c.stallT = 0;
f.exponentialRampToValueAtTime(2500, t + 0.800);
g.linearRampToValueAtTime(c.base, t + 0.800);
ld.linearRampToValueAtTime(Math.max(c.base * c.wob, 1e-6), t + 0.800);
}
}
function endChase(t) {
if (!chase) return;
const c = chase;
chase = null;
try {
anchor(c.vca.gain, t).exponentialRampToValueAtTime(1e-4, t + 1.2);
anchor(c.sg.gain, t).exponentialRampToValueAtTime(1e-4, t + 1.2);
// The wobble depth fades WITH the gain. Left at full depth the "fade" bottoms out about
// 10 dB below the chase, throbbing at 7 Hz, until the source is cut dead — which is the
// one transition in the level the player is guaranteed to be listening to.
c.ld.gain.cancelScheduledValues(t);
c.ld.gain.setValueAtTime(Math.max(c.ld.gain.value, 1e-6), t);
c.ld.gain.linearRampToValueAtTime(1e-6, t + 1.15);
for (const s of [c.src, c.lfo, c.sub]) {
try { s.stop(t + 1.25); } catch { /* stopped */ }
const i = perm.indexOf(s);
if (i >= 0) perm.splice(i, 1);
}
dropLater([c.hp, c.vca, c.ld, c.sg], 1400); // not in `nodes`: one per surge, not permanent
} catch { /* context gone */ }
surgeShelf.gain.setTargetAtTime(-6, t, 0.4);
surgeDelayL.delayTime.setTargetAtTime(0.012, t, 0.4);
}
// ── death: a global state change, not a cue ──────────────────────────────────────────────
// WE DO NOT FILL THE SILENCE. Everybody's instinct is to put a tone in the gap; don't. A feed
// that drops does not make a sound — that is what dropping MEANS. The 180 ms of nothing is the
// loudest thing in the game, and it is the engine's master duck that produces it. What this
// module owns is what comes back AFTER: broken static, and one distant body thump.
//
// The 1 kHz flatline tone was explicitly rejected — it is a hospital cliché that reaches
// outside the fiction, and the engine's carrier detune does the same job diegetically.
let staticVoice = null;
function death(t, o) {
if (staticVoice) return true; // idempotent: two death paths can both fire
try {
const src = noiseOf('white', t);
const bp = biquad('bandpass', 3400, 2);
const g = ctx.createGain();
const st = t + 0.350;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(STATIC_STEPS[0] * 0.22, st + 0.002);
// Stepped, NOT ramped — this is the one place a discontinuity is the point: it is meant
// to sound like a broken decoder. Only the first and last edges are ramped, so the burst
// itself doesn't click against the bus. 12 s of table, held until respawn.
let k = 0;
for (let rep = 0; rep < 25; rep++) {
for (let i = 0; i < STATIC_STEPS.length; i++, k++) {
g.gain.setValueAtTime(STATIC_STEPS[i] * 0.22 * (o.gain ?? 1), st + k * 0.040);
}
}
// THE LAST EDGE, which the comment above has always promised and the code did not deliver:
// the table used to end holding at whatever step it landed on and the source was simply
// stopped, which is a step to zero — the exact click LAW 1 exists to prevent, in the game's
// most exposed moment of silence. 120 ms of release, then park, then stop behind it.
const off = st + k * 0.040;
g.gain.linearRampToValueAtTime(1e-4, off + 0.120);
g.gain.setValueAtTime(0, off + 0.122);
src.connect(bp); bp.connect(g); g.connect(destination);
const stopAt = off + 0.2;
src.stop(stopAt);
staticVoice = { src, g, stopAt };
perm.push(src);
// SELF-CLEARING, and it has to be: nothing in the repo emits `respawn`, and death() is
// idempotent on `staticVoice`. Latched non-null forever, the SECOND death in a session
// produced no audio at all. The handle is released when the static has actually finished,
// so a later death re-arms. (respawn() clears it early; this is only the floor.)
later(() => {
if (staticVoice && staticVoice.src === src) {
staticVoice = null;
const i = perm.indexOf(src);
if (i >= 0) perm.splice(i, 1);
}
}, Math.max((stopAt - ctx.currentTime) * 1000 + 50, 50));
// The body's last thump: 55 Hz through a 200 Hz lowpass, mostly dry. It is NOT "distance" —
// the wet copy is largely eaten by the cavity's 120 Hz highpass, so what you actually hear
// is a close, dull thud. That is the honest description, and it is the right sound: the
// body is not far away, it is right here and it has stopped.
const e = adsr(t + 0.900, 0.30 * (o.gain ?? 1), 0.040, 1.40);
const lp = biquad('lowpass', 200, 0.7);
const osc = oscOf('sine', 55, t + 0.900);
osc.connect(lp); lp.connect(e.node);
const w = ctx.createGain();
w.gain.value = 0.9;
e.node.connect(w); w.connect(cavityIn);
e.node.connect(destination);
osc.start(t + 0.900);
osc.stop(e.end);
// Tracked as a voice, so dispose() can FADE it. Untracked it was reachable from nothing —
// a 55 Hz thump that kept playing into the engine's bus for up to 1.44 s after teardown.
live.push({ name: 'death_thump', vca: e.node, srcs: [osc], until: e.end + 0.2, start: t + 0.900 });
} catch { /* context gone */ }
return true;
}
function respawn(t, o) {
// the scanner locking back on
if (staticVoice) {
const v = staticVoice;
staticVoice = null;
try {
anchor(v.g.gain, t).exponentialRampToValueAtTime(1e-4, t + 0.300);
v.src.stop(t + 0.32);
const i = perm.indexOf(v.src);
if (i >= 0) perm.splice(i, 1);
} catch { /* stopped */ }
}
emit('respawn', { wet: 0.2 }, [
{ k: 'noise', n: 'pink', at: 0.10, filt: ['lowpass', 200, 0.8, 4000, 0.400], peak: 0.18, a: 0.020, d: 0.400 },
{ k: 'noise', n: 'white', at: 0.10, filt: ['bandpass', 2400, 6], peak: 0.10, a: 0.001, d: 0.018, rep: 3, every: 0.090 },
{ k: 'tone', wave: 'sine', at: 0.50, f: 1046.5, peak: 0.16, a: 0.006, d: 0.140, wet: 0 },
], t, o);
return true;
}
// ── the warnings ─────────────────────────────────────────────────────────────────────────
//
// THE ACCELERANDO IS THE INFORMATION, so the ratio between the two gaps has to be big enough to
// hear as acceleration and not merely as "three beeps". At eta 2.5 the offsets below give
// inter-onsets of 1.10 s then 0.60 s — a ratio of 1.83, comfortably inside the 1.6-2.0 that
// reads as "closing". The previous [-1.9, -1.0, -0.35] gave 0.90 / 0.65 = 1.38, which is
// detectable but leaves nearly all the "NOW" to the lift and the +3 semitones on pulse 3.
// ⚠ engine.js schedules its warning ducks off a private copy of these offsets — if you retune
// WARN_OFFS, retune that copy too or the ducks drift off the beeps.
const WARN_OFFS = [-2.00, -0.90, -0.30];
const WARN_TIGHT = [0, 0.180, 0.360]; // eta too short to accelerate into: just fire three
// Pulse 3's lift, as a RATIO. It was written `0.34 / 0.22`, where 0.22 was silently the blip
// `peak` from the WARN table — retune a blip peak and the lift became an arbitrary number.
const WARN_LAST_LIFT = 1.55;
function warn(name, t, o) {
const W = WARN[name];
if (!W) return false;
// Warnings bypass admit(), so a warning can never be DROPPED because a firefight filled the
// voice list — that is the exact failure the mix is built to avoid. (They still push to
// `live` and so still occupy ceiling slots against later cues; "exempt" means they cannot be
// refused, not that they are free.) They get a retrigger guard instead, so a re-armed hazard
// cannot stack two accelerandos.
if (t - (lastAt.get(name) ?? -1e9) < 1.0) return false;
lastAt.set(name, t);
const cue = { wet: W.wet };
const sink = W.surge ? surgeIn : destination;
// ETA scheduling. `eta` comes straight off hazard:warn (seconds of lead, default 2.5).
const eta = Number.isFinite(o.eta) ? o.eta : 2.5;
const rel = eta >= 1.2 ? WARN_OFFS.map((x) => eta + x) : WARN_TIGHT;
for (let i = 0; i < 3; i++) {
const at = Math.max(t + rel[i], t + 0.02);
const last = i === 2;
// Pulse 3 is louder AND +3 semitones — the accelerando plus a lift is what turns three
// beeps into "it is arriving NOW".
const g = last ? WARN_LAST_LIFT : 1.0;
// EXCEPTION, and it is deliberate: reflux does NOT pitch up on pulse 3. It is the only
// descending motif in the game (Law A: pitch falls = you lost) and raising the last note
// would break the one contour that tells you the thing is BEHIND you.
const step = last && name !== 'warn_reflux_surge' ? 3 : 0;
const dur = W.durs ? W.durs[i] : W.blipDur;
const taps = W.taps || [0];
for (const tap of taps) {
const layers = W.blip.map((L) => {
// blipDur/durs set the length of the PITCHED blip only. Applied to every layer it
// stretched warn_ring_gate's deliberate 5 ms transient click (L.d 0.005) to ~66 ms,
// turning "a single sharp tick, high and clean" into a noise wash. A transient layer
// keeps its own decay; it is a transient because it is short.
const c = L.k === 'tone'
? { ...L, durOverride: Math.max(dur - (L.a ?? 0.004), 0.004) }
: { ...L };
// reflux walks down its own note table across the three pulses
if (W.notes && c.k === 'tone') c.f = W.notes[Math.min(i, W.notes.length - 1)];
return c;
});
emit(name, cue, layers, at + tap, { ...o, mul: (o.mul ?? 1) * g, step }, sink);
}
}
// The fourth reflux note, 150 ms after the last pulse: the motif completing as it lands.
if (W.notes) {
const at = Math.max(t + rel[2], t + 0.02) + 0.150;
emit(name, cue, [{ ...W.blip[0], f: W.notes[3], durOverride: 0.170 }], at, { ...o, mul: (o.mul ?? 1) * WARN_LAST_LIFT }, sink);
}
// The body: one sustained answer underneath the WHOLE warning, wet. Its sustain is stretched
// to cover the last pulse, because the pulses are placed relative to `eta` and the body is
// not: at the table's nominal ~2.2 s, any eta above ~2.6 fired pulse 3 (and the reflux fourth
// note) after the body had already died, and "the instrument BLIPS, the body ANSWERS
// underneath" is the entire grammar of the three warnings. It is one max(), and without it
// the grammar silently breaks on exactly the long-lead warnings that need it most.
const bodyFor = Math.max(rel[2], 0.02) + 0.35;
const body = W.body.map((L) => {
if (!(L.sus > 0)) return L;
const head = (L.a ?? 0.005) + (L.d ?? 0.05);
return { ...L, s: Math.max(L.s ?? 0, bodyFor - head) };
});
emit(name, cue, body, t, o, sink);
return true;
}
// ── the heartbeat ────────────────────────────────────────────────────────────────────────
// The engine calls this on its own ctx.currentTime schedule. `opts.d` is the danger scalar,
// `opts.gap` the S1->S2 spacing it computed. Danger colours TIMBRE, not just rate — that is
// what keeps it a body rather than a metronome.
function beat(t, o, which) {
prune(); // beat() pushes to `live` directly; without this the list never shrinks
const d = Math.min(Math.max(o.d ?? 0, 0), 1);
const gain = o.gain ?? 1;
const D = HEART.dyn;
const one = (P, at, isLub) => {
const srcs = [];
const vca = ctx.createGain();
vca.gain.value = 1;
vca.connect(destination);
const gm = (P.gainMul ?? 1) * gain;
const dm = P.decayMul ?? 1;
// body — the pitch RISES with danger (62 -> 71 Hz) as well as the tempo
const body = adsr(at, P.peak * (D.gainBase + D.gainD * d) * gm, P.a, P.d * dm);
const bo = oscOf('sine', P.f + P.fd * d, at);
bo.frequency.setValueAtTime(P.f + P.fd * d, at);
bo.frequency.exponentialRampToValueAtTime(P.f2, at + P.ft);
if (isLub && d > D.tDistort) {
// Harmonic distortion, NOT volume, is how a heart sounds like it is working too hard.
const sh = shaperNode();
bo.connect(sh); sh.connect(body.node);
} else bo.connect(body.node);
body.node.connect(vca);
bo.start(at); bo.stop(body.end);
srcs.push(bo);
// valve slap — the cutoff OPENING with danger is the anxiety. Same note, tenser body.
const slap = adsr(at, P.slapPeak * gm, 0.004, P.slapD * dm);
const lp = biquad('lowpass', D.valveF + D.valveFD * d, 1.4);
const sn = noiseOf('brown', at);
sn.connect(lp); lp.connect(slap.node); slap.node.connect(vca);
sn.stop(slap.end);
srcs.push(sn);
// floor
const fl = adsr(at, P.floorPeak * gm, 0.008, P.floorD * dm);
const fo = oscOf('sine', D.floorF, at);
fo.connect(fl.node); fl.node.connect(vca);
fo.start(at); fo.stop(fl.end);
srcs.push(fo);
if (isLub && d > D.tStrain) {
// strain WITHOUT loudness
const c = adsr(at, D.strainPeak * ((d - D.tStrain) / (1 - D.tStrain)) * gm, 0.001, 0.008);
const cb = biquad('bandpass', 900, 6);
const cn = noiseOf('white', at);
cn.connect(cb); cb.connect(c.node); c.node.connect(vca);
cn.stop(c.end);
srcs.push(cn);
}
if (isLub && d > D.tMurmur) {
const m = adsr(at, D.murmurPeak * ((d - D.tMurmur) / (1 - D.tMurmur)) * gm, 0.002, 0.040);
const mb = biquad('bandpass', 240, 5);
const mn = noiseOf('white', at);
mn.connect(mb); mb.connect(m.node); m.node.connect(vca);
mn.stop(m.end);
srcs.push(mn);
}
live.push({ name: 'heartbeat', vca, srcs, until: at + 0.6, start: at });
};
if (which === 's2') { one(HEART.s2, t, false); return true; }
one(HEART.s1, t, true);
if (which === 's1') return true;
// Above HEART.dyn.tSingle: THE DOUBLE-THUMP COLLAPSES TO A SINGLE THUMP. At that rate the ventricle cannot
// fill. It is anatomically correct, and it is the most frightening thing in this file —
// because the player hears the RHYTHM break, not just the tempo.
if (d <= D.tSingle) {
// Humanise only while calm. At high danger the beat should be mechanical.
const jitter = d < D.tJitter ? (rand() * 2 - 1) * D.jitter : 0;
const gap = (o.gap ?? 0.36) * (1 + jitter);
one(HEART.s2, t + gap, false);
}
return true;
}
// ═══ per-cue play hooks (the small amount of state a cue may own) ═════════════════════════
let cannonI = 0, dartSide = 0, cannonSide = 0;
let hitN = 0, hitAt = -1e9; // enemy_hit consecutive ladder
let coinN = 0, coinAt = -1e9; // pickup coin ladder
let sampleN = 0; // biopsy samples this level
const SPECIAL = {
cannon(t, o) {
const cue = T.cannon;
if (!admit('cannon', cue, t)) return false;
// NO VOICE-SUM TAPER HERE. There used to be one (an obfuscated `1 - 0.375*min(n/3,1)`,
// written as `taper/0.32`) and the engine applies exactly the same guard before it calls
// us, forwarding the result in `gain`. Applied twice, sustained fire pushed the synth layer
// to 0.625 x 0.625 = -8 dB while the sampled layer (cannon is a SAMPLE_IS_LAYER cue) only
// took the single 0.625 — so the synthesized click and body, the parts that must never be
// delegated to a sample, collapsed underneath it exactly when you are firing hardest, and
// the sampled and synth-only builds ended up with different cannon dynamics. One owner:
// the engine. If that guard is ever removed there, reinstate it here.
const i = cannonI++;
const dt = CANNON_DT[i & 7];
const heat = Math.min(Math.max(o.heat ?? 0, 0), 1);
cannonSide ^= 1;
const layers = T.cannon.layers.map((L) => {
const c = { ...L };
if (c.k === 'tone') { c.f = L.f * dt; c.f2 = L.f2 * dt; }
// The gun audibly SOURS before it locks out, so `overheat` is never a surprise. Free
// telegraphy: one detune term and one Q term, no new UI, no new event.
if (c.k === 'noise' && c.filt && c.filt[0] === 'bandpass') {
c.filt = [c.filt[0], c.filt[1], 1.2 + 2.8 * heat, c.filt[3], c.filt[4]];
}
return c;
});
emit('cannon', cue, layers, t, {
...o,
detune: (o.detune ?? 0) - 200 * heat,
mul: (o.mul ?? 1) * CANNON_GAIN[i & 3],
side: cannonSide,
});
return true;
},
dart(t, o) {
if (!admit('dart', T.dart, t)) return false;
dartSide ^= 1;
// Alternating pan is a FAKE and it is the weakest thing in this file. It needs an azimuth
// from Lane C (see the report) — until then, at least consecutive darts separate.
emit('dart', T.dart, T.dart.layers, t, { ...o, side: dartSide });
return true;
},
enemy_hit(t, o) {
if (!admit('enemy_hit', T.enemy_hit, t)) return false;
hitN = t - hitAt < 0.9 ? Math.min(hitN + 1, 5) : 0;
hitAt = t;
emit('enemy_hit', T.enemy_hit, T.enemy_hit.layers, t, { ...o, step: o.step ?? hitN });
return true;
},
enemy_die(t, o) {
if (!admit('enemy_die', T.enemy_die, t)) return false;
hitN = 0;
// Chained kills walk a chromatic scale. THIS is where combo lives — the heart belongs to
// the patient, not to your kill streak. Danger raises the pulse; skill sharpens the readout.
const step = Math.min(o.combo ?? 0, 7);
emit('enemy_die', T.enemy_die, T.enemy_die.layers, t, { ...o, step });
return true;
},
pickup(t, o) {
if (!admit('pickup', T.pickup, t)) return false;
coinN = t - coinAt < 1.5 ? Math.min(coinN + 2, 8) : 0;
coinAt = t;
emit('pickup', T.pickup, T.pickup.layers, t, { ...o, step: o.step ?? coinN });
return true;
},
pickup_sample(t, o) {
if (!admit('pickup_sample', T.pickup_sample, t)) return false;
sampleN++;
const layers = T.pickup_sample.layers.slice();
if ((o.third ?? sampleN) >= 3) {
// the set is complete — one low note that was not there for samples 1 and 2
layers.push({ k: 'tone', wave: 'sine', f: 261.63, peak: 0.14, a: 0.040, d: 0.900, wet: 0.3 });
}
emit('pickup_sample', T.pickup_sample, layers, t, o);
return true;
},
wall_scrape(t, o) { return scrape(o.amount, t); },
overheat(t, o) { return overheat(t, o); },
overheat_clear(t) { return overheatClear(t); },
surge_start(t, o) {
if (!admit('surge_start', T.surge_start, t)) return false;
emit('surge_start', T.surge_start, T.surge_start.layers, t, o, surgeIn);
startChase(t + 1.5); // the 2.6 s cue hands off to the loop as it decays
return true;
},
// `p`, NOT `gain`. `gain` is the master multiplier in the contract (see play()'s doc block and
// `mul` in buildLayer), so reading proximity out of it meant that the moment anyone wired this
// cue through the generic router — which always passes gain 1 — the chase would pin to
// maximum permanently and never move again. Two meanings for one key is a trap, not an API.
surge_chase(t, o) { return chaseTo(o.p ?? 0, t); },
surge_stall(t, o) {
if (!admit('surge_stall', T.surge_stall, t)) return false;
emit('surge_stall', T.surge_stall, T.surge_stall.layers, t, o, surgeIn);
stall(t, true);
return true;
},
surge_unstall(t) { stall(t, false); return true; },
surge_end(t, o) {
if (!admit('surge_end', T.surge_end, t)) return false;
emit('surge_end', T.surge_end, T.surge_end.layers, t, o, surgeIn);
endChase(t);
return true;
},
death(t, o) { return death(t, o); },
// Note: sampleN deliberately survives a death — biopsy samples persist across respawns within
// a level, so the "third of the set" note must not re-fire. It is only a FALLBACK: the engine
// currently always sends `third`, so this counter is normally shadowed by it.
respawn(t, o) { return respawn(t, o); },
heartbeat(t, o) { return beat(t, o, 'both'); },
heart_s1(t, o) { return beat(t, o, 's1'); },
heart_s2(t, o) { return beat(t, o, 's2'); },
warn_ring_gate(t, o) { return warn('warn_ring_gate', t, o); },
warn_aortic_squeeze(t, o) { return warn('warn_aortic_squeeze', t, o); },
warn_reflux_surge(t, o) { return warn('warn_reflux_surge', t, o); },
};
const ALL = [...new Set([...Object.keys(T), ...Object.keys(SPECIAL), ...Object.keys(WARN)])].sort();
// ═══ the public surface ══════════════════════════════════════════════════════════════════
function stub() {
return { play: () => false, has: () => false, names: () => [], dispose() {} };
}
return {
/**
* play(name, opts) -> true if it produced a voice, false if unknown / gated / not alive.
* opts: { gain?, when?, detune? } per the contract, plus these cue-specific extras, all
* optional and all safely absent:
* eta (warn_*) seconds of lead, straight off hazard:warn
* d, gap (heartbeat) danger scalar 0..1 and the S1->S2 spacing the engine computed
* heat (cannon) combat:state.heat / heatMax — sours the gun before lockout
* combo (enemy_die) combo {n} — the chromatic kill ladder
* amount (wall_scrape) player:damage.amount — how hard you are grinding
* third (pickup_sample) sample index; we count our own when it is absent
* p (surge_chase) 0..1 proximity, 1 = on top of you. NOT `gain` — that is the
* master multiplier and means something else on every other cue.
*/
play(name, opts = {}) {
if (!alive()) return false;
try {
const t = Math.max(opts.when ?? 0, now());
const fn = SPECIAL[name];
if (fn) return fn(t, opts) !== false;
const cue = T[name];
if (!cue) return false;
if (!admit(name, cue, t)) return false;
emit(name, cue, cue.layers, t, opts, cue.surge ? surgeIn : destination);
return true;
} catch {
// A suspended or closing context throws on node creation. A missing sound is a bug;
// a thrown sound is a crashed frame. Never throw. (ASSETS-OPTIONAL LAW, generalised.)
return false;
}
},
has: (name) => ALL.includes(name),
names: () => ALL.slice(),
dispose() {
if (disposed) return;
disposed = true; // FIRST, so nothing in flight can reschedule
const t = ctx.state === 'closed' ? 0 : ctx.currentTime;
for (const id of timers) clearTimeout(id);
timers.length = 0;
try {
// Fade every sounding voice out over 8 ms rather than disconnecting it. Disconnecting a
// node that is producing signal is a step to zero — a click on every level transition.
for (const v of live) kill(v, t);
live.length = 0;
if (heatVoice) overheatClear(t, true);
if (chase) endChase(t);
// Everything with a VCA gets the same 8 ms fade before its source is stopped — the death
// static included. It used to be stopped bare via `perm`, so disposing during a death
// (i.e. a level teardown at the worst possible moment) clicked.
for (const g of [scrapeVCA && scrapeVCA.gain, staticVoice && staticVoice.g.gain]) {
if (!g) continue;
g.cancelScheduledValues(t);
g.setValueAtTime(Math.max(g.value, 1e-4), t);
g.linearRampToValueAtTime(1e-4, t + 0.008);
}
staticVoice = null;
for (const s of perm) { try { s.stop(t + 0.02); } catch { /* already stopped */ } }
perm.length = 0;
// Disconnect the permanent graph only AFTER the fades have landed, or we clip them.
// Through later(), so this timer is tracked like every other one in the file. (It is
// pushed after the clearTimeout sweep above, so it survives to do its job.)
later(() => {
for (const n of nodes) { try { n.disconnect(); } catch { /* gone */ } }
nodes.length = 0;
}, 60);
} catch { /* context already closed — nothing to tear down */ }
// We never close the ctx: the ENGINE owns it (and may outlive us).
},
};
}