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>
1820 lines
92 KiB
JavaScript
1820 lines
92 KiB
JavaScript
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).
|
||
},
|
||
};
|
||
}
|