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). }, }; }