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>
1748 lines
102 KiB
JavaScript
1748 lines
102 KiB
JavaScript
// audio/engine.js (Lane E) — the WebAudio graph, the cue router, the bed, the heartbeat.
|
|
//
|
|
// THE FICTION: a cheap surgical scanner failing inside a living animal. A hairline 2.5 kHz
|
|
// carrier runs under everything (the instrument's own electronics); sounds are either DRY,
|
|
// pitched and centred (the instrument) or WET, unpitched and wide (the tissue). Under both, a
|
|
// heart beats at 50 bpm and speeds up when you are about to die. Damage is the feed CORRUPTING
|
|
// — never an alarm tone, never a health-linked filter sweep. See §BANNED at the bottom.
|
|
//
|
|
// DIVISION OF LABOUR: this file owns the GRAPH, the ROUTING, the BED and the heartbeat CLOCK.
|
|
// audio/synth.js owns the SOUND of individual cues. The contract is:
|
|
// createSynth(ctx, destination) -> { play(name, {gain, when, detune}) -> bool, has, names, dispose }
|
|
//
|
|
// HOUSE LAWS OBSERVED
|
|
// 1. BUS-ONLY: no import from flight/** or combat/**. Every number arrives as an event.
|
|
// 2. ASSETS-OPTIONAL: every one of the 24 cues, the bed and the heartbeat are audible with an
|
|
// empty manifest (?localassets=0). Samples are LAYER OVERRIDES on a synthesis path that is
|
|
// primary — never the reverse. That is the state this engine is designed in.
|
|
// 3. FLAGS: flags.mute makes no sound at all; flags.shots trims the frame budget.
|
|
// 5. 60FPS: the per-frame handlers (player:state, combat:state) copy scalars into one
|
|
// preallocated object and do ZERO node work. All node work is on discrete cues + the pump.
|
|
// 6. DISPOSE-CLEAN: see dispose() at the bottom — every sub, timer, listener and node.
|
|
//
|
|
// FIVE DSP RULES that override every aesthetic decision in here:
|
|
// R1 No instantaneous gain change, anywhere. `gain.value = x` on a live node is a step
|
|
// discontinuity = a full-Nyquist click. Min attack 2 ms, min release 6 ms, mute included.
|
|
// R2 exponentialRamp may not touch or cross zero (floor 1e-4, then park with setValueAtTime),
|
|
// and may not start from an UNSCHEDULED value — every ramp needs a setValueAtTime anchor
|
|
// first or Chrome silently does nothing.
|
|
// R3 Oscillator/BufferSource are one-shot by spec and CANNOT be pooled. Allocate per voice,
|
|
// release on ended. Never disconnect() a sounding node — ramp it out and stop() it.
|
|
// R4 Zero allocation in a per-frame handler.
|
|
// R5 The clock is ctx.currentTime. setTimeout appears exactly ONCE, as a pump asking "what
|
|
// falls in the next 200 ms of audio time?" — never as the beat source. This is the only
|
|
// pattern that survives a throttled background tab.
|
|
|
|
import { mulberry32 } from '../core/rng.js';
|
|
|
|
const CTOR = typeof window !== 'undefined' ? (window.AudioContext || window.webkitAudioContext) : null;
|
|
|
|
// DETERMINISM LAW (tools/qa.sh greps for it): the global RNG is banned everywhere outside
|
|
// core/rng.js. Audio texture is the one place it feels harmless to reach for it — noise buffers,
|
|
// grain scatter, sample-offset jitter — and that instinct is exactly what the law exists to stop:
|
|
// a mix bug you cannot reproduce is a mix bug you cannot fix. One seeded stream, so the same
|
|
// ?seed= produces the same noise, the same grains and the same jitter on every machine forever.
|
|
// Its own stream constant keeps it from perturbing world/enemy sequences.
|
|
const AUDIO_STREAM = 0x61756469; // 'audi'
|
|
|
|
|
|
// ── Bus levels. Target: master ~-20 LUFS at rest, peaks under -6 dBFS. ────────────────────────
|
|
const LEVEL = {
|
|
bed: 0.34, // sampled beds only
|
|
drone: 0.30, // synthesized bed + the surge chase loop
|
|
heart: 0.42, // heartbeat only (its duck node rides 0.35 -> 0.85 linear in D)
|
|
sfx: 0.55, // all 19 non-warning, non-comms cues
|
|
warn: 0.80, // warn_*, overheat, surge_start/stall/end
|
|
comms: 0.30, // comms_open. Exempt from every duck — it is the thing doing the ducking.
|
|
cavity: 0.24, // the wet return
|
|
master: 0.70, // user volume, persisted
|
|
};
|
|
|
|
// Beds are loudnorm'd to -16 LUFS, sfx peak-normalised to ~-3.5 dBFS, my synthesis peaks near
|
|
// -11. Without this trim the default build and ?localassets=0 are two DIFFERENT MIXES and every
|
|
// duck depth below is tuned for only one of them.
|
|
const SAMPLE_TRIM = 0.55;
|
|
|
|
const VOICE_CEILING = 24; // global; drops to 12 under ?shots
|
|
const CARRIER_HZ = 2489; // the identity layer. Its partner sits 4 Hz up to beat slowly.
|
|
// Carrier amplitude into preMaster, BEFORE master (0.70) and user volume (0.70). At the original
|
|
// 0.002 that landed near -60 dBFS: not "subliminal" but genuinely under the noise floor of laptop
|
|
// speakers, so the carrier's whole job — stuttering on hull_hit/gate_hit, detuning on death, so
|
|
// the player FEELS the instrument fail — was being done at a level nobody could hear. 0.0045 is
|
|
// still ~-47 dBFS at the output: you stop noticing it in twenty seconds, which is the point, but
|
|
// its absence and its gestures register. CARRIER_DEAD is the post-feed-drop level.
|
|
const CARRIER_LVL = 0.0045;
|
|
const CARRIER_DEAD = 0.0020;
|
|
|
|
// ── CUE -> BUS. Which bus a cue lands on decides what ducks it and what it ducks. ─────────────
|
|
const WARN_CUES = new Set([
|
|
'warn_reflux_surge', 'warn_aortic_squeeze', 'warn_ring_gate',
|
|
'overheat', 'surge_start', 'surge_stall', 'surge_end',
|
|
]);
|
|
|
|
// ── CUE -> MANIFEST KEY. This is a DECISION, not a lookup. ────────────────────────────────────
|
|
// Lane D ships exactly four sfx keys — pellet, hit_squelch, boost, pickup — and NONE of them is
|
|
// spelled like a cue name, so a naive audioUrl('sfx', cue.name) finds a sample for `boost` and
|
|
// `pickup` only and silently misses the other two files. Hence this table.
|
|
//
|
|
// The four keys are deliberately NOT spread across every plausible cue. `dart`, `coat_hit`,
|
|
// `hull_hit` and `wall_scrape` are the obvious extra customers for hit_squelch/pellet, and they
|
|
// are refused: sharing one sample across enemy fire, player fire and BOTH damage layers destroys
|
|
// the exact discriminations the mix is built on (you must be able to tell your cannon from their
|
|
// dart, and coat-absorbed from hull-breached, in one frame inside a firefight). Four shipped
|
|
// keys, five cue sites, clean. Everything else synthesizes — which is the primary path anyway.
|
|
const SAMPLE_KEY = {
|
|
cannon: 'pellet', // body+air; the CLICK always synthesizes on top (see below)
|
|
boost: 'boost', // air layer only; lift + carrier glide stay synth
|
|
pickup: 'pickup',
|
|
pickup_sample: 'pickup', // note 1 only — the 3-note rise must survive
|
|
enemy_hit: 'hit_squelch', // the squelch layer; click + body sine stay synth
|
|
};
|
|
// Cues where the sample REPLACES only part of the voice, so the synth still plays underneath.
|
|
// cannon: a decoded sample's attack is at the mercy of its own file pre-roll, and sample-accurate
|
|
// punch cannot be guaranteed from an asset you did not cut. The click is never delegated.
|
|
const SAMPLE_IS_LAYER = new Set(['cannon', 'boost', 'pickup_sample', 'enemy_hit']);
|
|
|
|
// Per-cue voice caps and minimum retrigger (seconds). The retrigger doubles as the same-frame
|
|
// de-dupe the brief asks for: two cannon emits in one frame are 0 ms apart, far under 45 ms.
|
|
const CUE_CAP = {
|
|
cannon: 4, dart: 3, enemy_hit: 4, enemy_die: 3, coat_hit: 2, hull_hit: 1,
|
|
boost: 1, torpedo: 2, torpedo_blast: 2, overheat: 1, gate_pass: 2, gate_hit: 2,
|
|
checkpoint: 1, pickup: 4, pickup_sample: 2, comms_open: 1,
|
|
warn_reflux_surge: 1, warn_aortic_squeeze: 1, warn_ring_gate: 1,
|
|
surge_start: 1, surge_stall: 1, surge_end: 1, death: 1, wall_scrape: 1,
|
|
};
|
|
const CUE_MIN_GAP = {
|
|
cannon: 0.045, dart: 0.060, enemy_hit: 0.030, coat_hit: 0.090, hull_hit: 0.120,
|
|
pickup: 0.050, comms_open: 0.200, wall_scrape: 0.030,
|
|
};
|
|
|
|
// ── THE DUCK MATRIX. dB, then attack/hold/release in seconds. ─────────────────────────────────
|
|
// THE LOAD-BEARING ROW is `warn_*` ducking SFX. That is the whole answer to "a firefight must not
|
|
// mask the surge warning". Making the warning LOUDER does not work — the limiter erases the
|
|
// difference. Pulling the MASKER down 5-9 dB for 600 ms cuts a hole the warning sits in, at zero
|
|
// cost to peak level. Second: comms_open ducks sfx for 2.4 s because the cue is 140 ms but the
|
|
// LINE it announces is ~3 s and carries gameplay information — 2.90 s, matching comms.js's own
|
|
// HOLD = 3.0 (comms.js:84). At the original 2.40 the last ~600 ms of EVERY line played against a
|
|
// restored mix, which is the part of a line you are still reading. Third: `cannon` ducks NOTHING — at
|
|
// 8 shots/s any duck becomes a tremolo pumping the entire mix.
|
|
const DUCKS = {
|
|
warn_ring_gate: { bed: -9, drone: -5, sfx: -5, a: 0.008, h: 0.09, r: 0.12 },
|
|
warn_aortic_squeeze:{ bed: -9, drone: -5, sfx: -6, a: 0.008, h: 0.09, r: 0.12 },
|
|
warn_reflux_surge: { bed: -12, drone: -6, sfx: -9, a: 0.008, h: 0.09, r: 0.12 },
|
|
surge_start: { bed: -5, drone: -6, sfx: -4, heart: -3, a: 0.30, h: 1.60, r: 0.90 },
|
|
torpedo_blast: { bed: -6, drone: -4, sfx: -4, heart: -6, a: 0.02, h: 0.40, r: 0.50 },
|
|
torpedo: { bed: -3, a: 0.02, h: 0.20, r: 0.30 },
|
|
enemy_die: { bed: -2, a: 0.01, h: 0.06, r: 0.12 },
|
|
checkpoint: { bed: -3, a: 0.03, h: 0.40, r: 0.50 },
|
|
pickup_sample: { bed: -4, a: 0.03, h: 0.40, r: 0.40 },
|
|
comms_open: { bed: -3, sfx: -4, heart: -1, a: 0.15, h: 2.90, r: 0.70 },
|
|
overheat: { bed: -4, sfx: -3, a: 0.10, h: 0.60, r: 0.30 },
|
|
};
|
|
// hull_hit and gate_hit duck the MASTER instead — they are impacts, not states, and the HOLE is
|
|
// the impact. 40-70 ms, the only sanctioned full-mix subtractions besides overheat and death.
|
|
const MASTER_DUCK = { hull_hit: [0.55, 0.004, 0.040, 0.120], gate_hit: [0.50, 0.004, 0.030, 0.090] };
|
|
|
|
// ── THE SYNTHESIZED BED, per biome. ───────────────────────────────────────────────────────────
|
|
// NOTE FOR THE NEXT READER: the sound spec's table named a `colon` biome. There is no such id.
|
|
// web/js/world/biomes.js defines oral / esophagus / stomach / small_intestine / large_intestine /
|
|
// appendix, plus a `neutral` fallback that world/biomes.js substitutes for any unknown id — so
|
|
// `neutral` DOES reach player:state.biome and needs a row or the bed goes silent on it.
|
|
// lp: L1 cavity lowpass. res: three L2 wall resonances, deliberately NON-HARMONIC so the layer
|
|
// reads as a SPACE and never as a chord. per: L4 peristalsis rate. grain: L5 density multiplier.
|
|
// car: carrier multiplier — stomach and large_intestine pull it down, because you are deeper in
|
|
// and the signal is worse. That is the whole biome story in one number.
|
|
const BIOME_BED = {
|
|
oral: { lp: 620, res: [210, 340, 545], per: 0.06, grain: 0.5, car: 1.00 },
|
|
esophagus: { lp: 390, res: [84, 137, 219], per: 0.11, grain: 1.0, car: 1.00 },
|
|
stomach: { lp: 260, res: [58, 96, 151], per: 0.07, grain: 1.8, car: 0.85 },
|
|
small_intestine: { lp: 470, res: [118, 193, 308], per: 0.19, grain: 2.4, car: 1.00 },
|
|
large_intestine: { lp: 210, res: [46, 74, 121], per: 0.05, grain: 3.0, car: 0.70 },
|
|
appendix: { lp: 300, res: [66, 108, 172], per: 0.04, grain: 1.4, car: 0.80 },
|
|
neutral: { lp: 400, res: [90, 148, 236], per: 0.09, grain: 1.0, car: 1.00 },
|
|
};
|
|
|
|
// ── THE DANGER SCALAR AND THE HEARTBEAT — the numbers, in one place. ──────────────────────────
|
|
// The headline feature was the only system in this file with no table: its constants were spread
|
|
// across targetD, bpmFromD, the pump, thump and maybeGrain, so a mix pass meant a hunt through
|
|
// logic. Every WHY is still at §9 where the mapping is documented; only the numbers moved.
|
|
const WARN_PEAK = 0.6; // tWarn's height on hazard:warn, decaying to 0 over eta+1 s
|
|
const HEART = {
|
|
bpmBase: 50, // EXACTLY 50 — bed-esophagus is 24.000 s with 50 bpm baked in
|
|
bpmSpan: 88, // ceiling 138: faster reads as a drum machine, not as a body
|
|
bpmExp: 1.30, // keeps the low third near rest, so movement MEANS something
|
|
bpmUp: 8, bpmDn: 4, // bpm slew per second, asymmetric
|
|
dUp: 0.10, dDn: 0.020, // D slew per tick: ~0.25 s up, ~1.3 s down
|
|
surgeDecay: 0.86, // per tick once hazard:proximity falls silent
|
|
threatW: 0.80, heatW: 0.28, // sum >1 deliberately: maxed threat AND a hot fight should pin
|
|
lvlBase: 0.35, lvlSpan: 0.50, // the heart bus's own D-linked level
|
|
shelfDb: -14, // bed lowshelf at D=1, dissolving D's baked heartbeat
|
|
calm: 0.30, // below this D the heart is "at rest"
|
|
humanise: 0.03, // ±1.5% period jitter — calm only, and only with no bed to flam
|
|
grainBase: 0.14, grainSpan: 0.41, // L5 grain probability floor and D-linked span
|
|
// The FALLBACK thump's valve-slap band. It used to open 120 -> 260 Hz, which is under the
|
|
// passband of every laptop and phone speaker there is: at rest the headline feature had no
|
|
// audible content at all on the most common playback device, and the 62 -> 38 Hz sine underneath
|
|
// it is no help. Opening from 300 Hz keeps the slap a slap while putting its body where small
|
|
// speakers can actually reproduce it. (synth.js owns this when it is present.)
|
|
slapLo: 300, slapSpan: 420,
|
|
};
|
|
|
|
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
const dbToLin = (db) => Math.pow(10, db / 20);
|
|
|
|
export function createAudio({ bus, flags = {}, assets = null, level = null, world = null, mount = null } = {}) {
|
|
void level; void world; void mount; // accepted for factory-signature parity; unused in round 1.
|
|
// world.flowPulse (grain phase-alignment) is a round-2 ask
|
|
// — House Law 1 forbids reaching for it, F must hand it in.
|
|
|
|
// The noop MUST have the same SHAPE as the real handle, getters included. A settings slider bound
|
|
// to `audio.volume` under ?mute (or on a browser with no AudioContext) would otherwise read
|
|
// undefined and render NaN, and `audio.setMuted(true)` would throw — a silent build is a feature,
|
|
// a crashing one is not.
|
|
const noop = {
|
|
update() {}, setVolume() {}, setMuted() {}, cue() {}, dispose() {},
|
|
get volume() { return 0.7; }, get muted() { return !!flags.mute; },
|
|
get danger() { return 0; }, get bpm() { return 50; },
|
|
};
|
|
if (!bus) return noop;
|
|
|
|
// ?mute => never construct an AudioContext at all. A suspended-but-existing context still shows
|
|
// Chrome's tab-audio indicator, still costs a render thread, and leaks if close() is missed.
|
|
// This also satisfies "skip scheduling work so muted runs stay cheap" — there is nothing to skip
|
|
// because nothing is built. ?mute OVERRIDES but does NOT WRITE localStorage: a flag must never
|
|
// corrupt a stored preference.
|
|
if (flags.mute || !CTOR) return noop;
|
|
|
|
let ctx;
|
|
try { ctx = new CTOR({ latencyHint: 'interactive' }); } catch (e) {
|
|
console.info('[audio] no AudioContext —', e.message);
|
|
return noop;
|
|
}
|
|
|
|
// Seeded off ?seed= when the run is pinned, else a fixed constant — never a wall-clock value,
|
|
// which would silently reintroduce the irreproducibility the law forbids.
|
|
const rnd = mulberry32(((flags.seed != null ? flags.seed : 0x9E3779B9) ^ AUDIO_STREAM) >>> 0);
|
|
|
|
let disposed = false;
|
|
let unlocked = false;
|
|
const offs = [];
|
|
const shots = !!flags.shots;
|
|
// ?shots constructs audio NORMALLY (a shots run may be a video capture, and screenshots are
|
|
// silent regardless) but disables the bed's grain scheduler and halves the voice ceiling to keep
|
|
// the frame budget clean for the capture. hud.js hides-after-building and comms.js skips-entirely;
|
|
// this is a third route and it is deliberate. Flagged to F for ratification.
|
|
const ceiling = shots ? 12 : VOICE_CEILING;
|
|
|
|
const t0 = () => ctx.currentTime + 0.005; // scheduling in the past loses the attack => click
|
|
|
|
// ═══ 1. THE GRAPH ═══════════════════════════════════════════════════════════════════════════
|
|
// sources ─ voiceVCA ─┬─ [busGain ─ busDuck] ─┬─ preMaster ─ hp1 ─ hp2 ─ limiter ─ master ─ out
|
|
// └─ cavitySend ─ LP1600 ─ convolver ─ HP120 ─ cavityRet ┘
|
|
//
|
|
// Every bus is busGain -> busDuck -> preMaster. Ducks write busDuck ONLY; the user volume writes
|
|
// master ONLY. They never touch the same node, which removes a whole class of bug: a duck that
|
|
// wrote the bus's own gain would fight the volume setting and leave buses stuck quiet.
|
|
const master = ctx.createGain();
|
|
const limiter = ctx.createDynamicsCompressor();
|
|
limiter.threshold.value = -8; limiter.knee.value = 0; limiter.ratio.value = 20;
|
|
limiter.attack.value = 0.003; limiter.release.value = 0.25;
|
|
// Cascaded 24 Hz highpasses = 12 dB/oct. NOT optional: three cues sweep sines under 30 Hz and
|
|
// every lowpassed brown-noise source has a nonzero mean. Sub-25 Hz energy is inaudible but
|
|
// steals headroom, moves real cones, and pumps the limiter on content nobody can hear.
|
|
const hp1 = ctx.createBiquadFilter(); hp1.type = 'highpass'; hp1.frequency.value = 24; hp1.Q.value = 0.707;
|
|
const hp2 = ctx.createBiquadFilter(); hp2.type = 'highpass'; hp2.frequency.value = 24; hp2.Q.value = 0.707;
|
|
const preMaster = ctx.createGain(); preMaster.gain.value = 1;
|
|
|
|
preMaster.connect(hp1); hp1.connect(hp2); hp2.connect(limiter);
|
|
limiter.connect(master); master.connect(ctx.destination);
|
|
|
|
// Volume/mute persistence. Safari private mode throws on ACCESS, not just write.
|
|
let userVol = 0.7, userMuted = false;
|
|
try {
|
|
const raw = localStorage.getItem('guts.audio');
|
|
if (raw) {
|
|
const s = JSON.parse(raw);
|
|
if (typeof s.master === 'number' && isFinite(s.master)) userVol = clamp01(s.master);
|
|
userMuted = !!s.muted;
|
|
}
|
|
} catch (e) { void e; }
|
|
const baseMaster = () => (userMuted ? 0 : userVol * LEVEL.master);
|
|
master.gain.value = baseMaster();
|
|
|
|
let storeTimer = null;
|
|
function persist() { // debounced 400 ms — volume sliders emit fast
|
|
if (storeTimer) clearTimeout(storeTimer);
|
|
storeTimer = setTimeout(() => {
|
|
storeTimer = null;
|
|
try { localStorage.setItem('guts.audio', JSON.stringify({ master: userVol, muted: userMuted })); }
|
|
catch (e) { void e; }
|
|
}, 400);
|
|
}
|
|
|
|
function mkBus(level) {
|
|
const g = ctx.createGain(); g.gain.value = level;
|
|
const d = ctx.createGain(); d.gain.value = 1;
|
|
g.connect(d); d.connect(preMaster);
|
|
return { in: g, duck: d, base: 1, floor: 1, until: 0 };
|
|
}
|
|
const B = {
|
|
bed: mkBus(LEVEL.bed), drone: mkBus(LEVEL.drone), heart: mkBus(LEVEL.heart),
|
|
sfx: mkBus(LEVEL.sfx), warn: mkBus(LEVEL.warn), comms: mkBus(LEVEL.comms),
|
|
};
|
|
// The heartbeat's duck node is not a duck — it is the heart's own D-linked level (0.35 -> 0.85).
|
|
B.heart.base = HEART.lvlBase; B.heart.duck.gain.value = HEART.lvlBase;
|
|
|
|
// A lowshelf on the BED, not on the mix. As danger rises this dissolves the body of the
|
|
// heartbeat Lane D baked into bed-esophagus, so the LIVE heart can take the low register over
|
|
// without the two of them flamming. It is subtractive but it is aimed at one layer for one
|
|
// reason — it is not the banned health-linked lowpass on the mix. See §BANNED.
|
|
const bedShelf = ctx.createBiquadFilter();
|
|
bedShelf.type = 'lowshelf'; bedShelf.frequency.value = 250; bedShelf.gain.value = 0;
|
|
B.bed.in.disconnect(); B.bed.in.connect(bedShelf); bedShelf.connect(B.bed.duck);
|
|
|
|
// Cavity send: ONE chain that is both the "heard through meat" filter and the reverb. Everything
|
|
// biological goes through it; nothing from the instrument family does. Send-only, never an insert.
|
|
const cavitySend = ctx.createGain(); cavitySend.gain.value = 1;
|
|
const cavLP = ctx.createBiquadFilter(); cavLP.type = 'lowpass'; cavLP.frequency.value = 1600; cavLP.Q.value = 0.5;
|
|
const convolver = ctx.createConvolver();
|
|
const cavHP = ctx.createBiquadFilter(); cavHP.type = 'highpass'; cavHP.frequency.value = 120;
|
|
const cavityRet = ctx.createGain(); cavityRet.gain.value = LEVEL.cavity;
|
|
cavitySend.connect(cavLP); cavLP.connect(convolver); convolver.connect(cavHP);
|
|
cavHP.connect(cavityRet); cavityRet.connect(preMaster);
|
|
|
|
// ── Built-once resources ────────────────────────────────────────────────────────────────────
|
|
const SR = ctx.sampleRate;
|
|
function finishBuffer(d) {
|
|
// Mean subtraction + peak normalise. Non-negotiable and invisible until it ruins a long
|
|
// session: a noise buffer with DC offset pins the limiter on content nobody can hear.
|
|
let sum = 0; for (let i = 0; i < d.length; i++) sum += d[i];
|
|
const mean = sum / d.length;
|
|
let peak = 0; for (let i = 0; i < d.length; i++) { d[i] -= mean; const a = Math.abs(d[i]); if (a > peak) peak = a; }
|
|
if (peak > 0) { const k = 0.95 / peak; for (let i = 0; i < d.length; i++) d[i] *= k; }
|
|
}
|
|
function whiteBuf(sec) {
|
|
const b = ctx.createBuffer(1, (SR * sec) | 0, SR), d = b.getChannelData(0);
|
|
for (let i = 0; i < d.length; i++) d[i] = rnd() * 2 - 1;
|
|
finishBuffer(d); return b;
|
|
}
|
|
function pinkBuf(sec) { // Kellet 3-pole
|
|
const b = ctx.createBuffer(1, (SR * sec) | 0, SR), d = b.getChannelData(0);
|
|
let b0 = 0, b1 = 0, b2 = 0;
|
|
for (let i = 0; i < d.length; i++) {
|
|
const w = rnd() * 2 - 1;
|
|
b0 = 0.99765 * b0 + w * 0.0990460;
|
|
b1 = 0.96300 * b1 + w * 0.2965164;
|
|
b2 = 0.57000 * b2 + w * 1.0526913;
|
|
d[i] = b0 + b1 + b2 + w * 0.1848;
|
|
}
|
|
finishBuffer(d); return b;
|
|
}
|
|
function brownBuf(sec) {
|
|
const b = ctx.createBuffer(1, (SR * sec) | 0, SR), d = b.getChannelData(0);
|
|
let last = 0;
|
|
// LEAKY integrator (0.996), not a pure one. A pure integrator random-walks into unbounded DC
|
|
// and will pin the limiter after a couple of seconds of a session-long looping bed.
|
|
for (let i = 0; i < d.length; i++) { last = 0.996 * last + (rnd() * 2 - 1) * 0.06; d[i] = last; }
|
|
finishBuffer(d); return b;
|
|
}
|
|
const NZ = { white: whiteBuf(2), pink: pinkBuf(2), brown: brownBuf(4) };
|
|
|
|
// Equal-power crossfade curves, built once, reused by every crossfade forever.
|
|
const FADE_N = 32;
|
|
const fadeOut = new Float32Array(FADE_N), fadeIn = new Float32Array(FADE_N);
|
|
for (let i = 0; i < FADE_N; i++) {
|
|
const x = i / (FADE_N - 1);
|
|
fadeOut[i] = Math.cos(x * Math.PI / 2);
|
|
fadeIn[i] = Math.sin(x * Math.PI / 2);
|
|
}
|
|
|
|
// The impulse response is generated AFTER first paint — ~12 ms of buffer generation must not
|
|
// land in time-to-interactive. The 6 ms of leading zero is what keeps early reflections off the
|
|
// attack transients; a naive IR is exactly why cannon shots turn to mush.
|
|
let irTimer = setTimeout(() => {
|
|
irTimer = null;
|
|
if (disposed) return;
|
|
try {
|
|
const dur = SR > 48000 ? 0.5 : 0.7;
|
|
const n = (SR * dur) | 0, ir = ctx.createBuffer(1, n, SR), d = ir.getChannelData(0);
|
|
for (let i = 0; i < n; i++) {
|
|
const t = i / SR;
|
|
d[i] = t < 0.006 ? 0 : (rnd() * 2 - 1) * Math.pow(1 - t / dur, 4.5);
|
|
}
|
|
convolver.buffer = ir;
|
|
} catch (e) { void e; } // no reverb is a mix flavour, not a failure
|
|
}, 0);
|
|
|
|
// ── THE CARRIER — the identity layer. ───────────────────────────────────────────────────────
|
|
// Two sines 4 Hz apart at -54 dB, direct to preMaster, dry, never ducked by any rule. You stop
|
|
// consciously hearing it in twenty seconds, which IS the point: when it stutters (hull_hit,
|
|
// gate_hit) or detunes (death, boost) the player feels the instrument fail without being told.
|
|
// Cheapest identity in the whole design: two oscillators, forever.
|
|
const carrierGain = ctx.createGain(); carrierGain.gain.value = 0;
|
|
carrierGain.connect(preMaster);
|
|
const carrierA = ctx.createOscillator(), carrierB = ctx.createOscillator();
|
|
carrierA.type = carrierB.type = 'sine';
|
|
carrierA.frequency.value = CARRIER_HZ; carrierB.frequency.value = CARRIER_HZ + 4;
|
|
carrierA.connect(carrierGain); carrierB.connect(carrierGain);
|
|
let carrierStarted = false;
|
|
let carrierMul = 1; // biome multiplier (deeper in = worse signal)
|
|
|
|
// ═══ 2. POOLS, VOICES, HELPERS ══════════════════════════════════════════════════════════════
|
|
// R3: only reusable node types are pooled. Oscillators and BufferSources are one-shot by spec.
|
|
// Filters are deliberately NOT pooled: every filter this engine builds is configured once and
|
|
// lives as long as its voice, so a pool would only add a bucket nobody drains.
|
|
const pool = { gain: [], panner: [] };
|
|
function takeGain() {
|
|
const g = pool.gain.pop() || ctx.createGain();
|
|
g.gain.cancelScheduledValues(0); // mandatory: a recycled node carrying stale
|
|
return g; // automation will jump mid-ramp in its next life
|
|
}
|
|
function takePanner(pan) {
|
|
if (!ctx.createStereoPanner) return null;
|
|
const p = pool.panner.pop() || ctx.createStereoPanner();
|
|
p.pan.cancelScheduledValues(0); p.pan.value = pan;
|
|
return p;
|
|
}
|
|
function give(node, bucket) {
|
|
try { node.disconnect(); } catch (e) { void e; }
|
|
if (pool[bucket].length < 32) pool[bucket].push(node);
|
|
}
|
|
|
|
const voices = []; // { name, src, vca, extra[], at }
|
|
|
|
function releaseVoice(v) {
|
|
const i = voices.indexOf(v);
|
|
if (i >= 0) voices.splice(i, 1);
|
|
try { v.src.disconnect(); } catch (e) { void e; }
|
|
give(v.vca, 'gain');
|
|
for (const n of v.extra) {
|
|
if (n.__k) give(n, n.__k); else { try { n.disconnect(); } catch (e) { void e; } }
|
|
}
|
|
v.extra.length = 0;
|
|
}
|
|
// Steal the oldest voice OF THE SAME CUE NAME ONLY, and return whether we found one. There is no
|
|
// across-names fallback: a cannon burst that steals your surge warning is the one failure this
|
|
// mix cannot survive, so at the global ceiling with nothing of our own name to take we DROP the
|
|
// new voice instead. synth.js:862 makes exactly the same choice; the two files must agree or the
|
|
// sampled and synthesized builds diverge under load.
|
|
function steal(name) {
|
|
let oldest = null;
|
|
for (const v of voices) if (v.name === name && (!oldest || v.at < oldest.at)) oldest = v;
|
|
if (!oldest) return false;
|
|
const t = ctx.currentTime;
|
|
try {
|
|
oldest.vca.gain.cancelScheduledValues(t);
|
|
oldest.vca.gain.setValueAtTime(Math.max(oldest.vca.gain.value, 1e-4), t);
|
|
oldest.vca.gain.exponentialRampToValueAtTime(1e-4, t + 0.008); // ramp out, never disconnect
|
|
oldest.src.stop(t + 0.01);
|
|
} catch (e) { void e; }
|
|
return true;
|
|
}
|
|
function countOf(name) { let n = 0; for (const v of voices) if (v.name === name) n++; return n; }
|
|
|
|
// ── The envelope helper. Every internally-synthesized voice goes through it, so none can click.
|
|
function adsr(t, peak, a, d, sustain = 0, s = 0, r = 0.02) {
|
|
const g = takeGain();
|
|
g.gain.setValueAtTime(0, t); // R2 anchor
|
|
g.gain.linearRampToValueAtTime(peak, t + Math.max(a, 0.002)); // R1 floor
|
|
const ta = t + Math.max(a, 0.002);
|
|
if (sustain > 0) {
|
|
const sv = Math.max(peak * sustain, 1e-4);
|
|
g.gain.exponentialRampToValueAtTime(sv, ta + d);
|
|
g.gain.setValueAtTime(sv, ta + d + s);
|
|
g.gain.exponentialRampToValueAtTime(1e-4, ta + d + s + r);
|
|
g.gain.setValueAtTime(0, ta + d + s + r + 0.001);
|
|
} else {
|
|
g.gain.exponentialRampToValueAtTime(1e-4, ta + d);
|
|
g.gain.setValueAtTime(0, ta + d + 0.001);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
// Fire a one-shot buffer voice (noise or decoded sample).
|
|
function bufVoice(name, buffer, dest, vca, life, { rate = 1, loop = false, offset = null, pan = 0 } = {}) {
|
|
// Dropped at the ceiling: hand the caller's VCA back to the pool rather than orphaning it.
|
|
if (voices.length >= ceiling && !steal(name)) { give(vca, 'gain'); return null; }
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = buffer; src.loop = loop;
|
|
src.playbackRate.value = rate;
|
|
const extra = [];
|
|
const p = pan ? takePanner(pan) : null;
|
|
if (p) { p.__k = 'panner'; vca.connect(p); p.connect(dest); extra.push(p); }
|
|
else vca.connect(dest);
|
|
src.connect(vca);
|
|
const v = { name, src, vca, extra, at: ctx.currentTime };
|
|
voices.push(v);
|
|
src.onended = () => releaseVoice(v);
|
|
const t = life.t;
|
|
// Noise voices ALWAYS start at a random offset. Without it every squelch in the game begins on
|
|
// the identical sample sequence and the ear decodes it as a SAMPLE, not as noise.
|
|
const off = offset != null ? offset : (loop || buffer.duration > 1 ? rnd() * Math.max(0, buffer.duration - 0.5) : 0);
|
|
// If start() throws (closed/closing context, bad time) `onended` will NEVER fire, so the voice
|
|
// would sit in `voices` forever holding a slot. 24 of those and every later cue is permanently
|
|
// in steal(). Reclaim the slot here rather than trusting a callback that cannot arrive.
|
|
try { src.start(t, off); } catch (e) { void e; releaseVoice(v); return null; }
|
|
if (life.stop) { try { src.stop(life.stop); } catch (e) { void e; } }
|
|
return v;
|
|
}
|
|
|
|
// ── Ducking. One function, parameterised. Not a DynamicsCompressor sidechain — WebAudio has no
|
|
// sidechain input and faking one is more nodes, more latency and less predictable.
|
|
function duck(slot, depthDb, attack, hold, release, t) {
|
|
let to = slot.base * dbToLin(depthDb);
|
|
// ARBITRATION: ducks do not stack and do not multiply. A new duck applies only if it is DEEPER
|
|
// or LATER than the one in flight. Two torpedo_blasts 100 ms apart must not take the bed to
|
|
// -12 dB and hold it there.
|
|
const endAt = t + attack + hold + release;
|
|
const active = t < slot.until;
|
|
// Shallower AND no longer than what is in flight: it has nothing to add. Skip it entirely.
|
|
if (active && to > slot.floor && endAt <= slot.until) return;
|
|
// Shallower but LONGER is the case that used to break the load-bearing row: a torpedo (bed -3,
|
|
// 0.52 s) landing 50 ms into a warn_reflux_surge (bed -12, 0.218 s) passed the guard and then
|
|
// ramped the bed UP from -12 to -3, filling in the hole the warning was sitting in. Extending
|
|
// in time must never raise the floor — clamp the target to the depth already in flight, which
|
|
// also keeps slot.floor and the actual param agreeing from here on.
|
|
if (active && to > slot.floor) to = slot.floor;
|
|
slot.floor = active ? Math.min(slot.floor, to) : to;
|
|
slot.until = Math.max(slot.until, endAt);
|
|
const g = slot.duck.gain;
|
|
g.cancelScheduledValues(t);
|
|
g.setValueAtTime(g.value, t); // MANDATORY anchor. cancelAndHoldAtTime does not exist in
|
|
g.linearRampToValueAtTime(to, t + attack); // Firefox; without this the ramp starts from the
|
|
g.setValueAtTime(to, t + attack + hold); // last SCHEDULED value, not the current one, and
|
|
g.setTargetAtTime(slot.base, t + attack + hold, Math.max(release, 0.01) / 3); // ducks stack
|
|
} // into silence
|
|
// The warn accelerando's 2nd and 3rd ducks, waiting for the audio clock to reach them.
|
|
// WHY A QUEUE AND NOT THREE SCHEDULED CALLS: duck() anchors with setValueAtTime(g.value, t), and
|
|
// g.value is the param's value RIGHT NOW. Anchoring a time two seconds in the future to the
|
|
// value the param happens to hold in this tick asserts a step discontinuity at that future
|
|
// instant — the exact full-Nyquist click R1 exists to ban — and it erases any duck (surge_start
|
|
// holds the bed for 1.6 s) that is in flight when the future anchor lands. The pump drains this
|
|
// instead, so each pulse's duck is computed against the mix as it actually is at that moment.
|
|
const pendingDucks = []; // { name, at }
|
|
function applyDucks(name, t) {
|
|
const d = DUCKS[name];
|
|
if (d) {
|
|
if (d.bed != null) duck(B.bed, d.bed, d.a, d.h, d.r, t);
|
|
if (d.drone != null) duck(B.drone, d.drone, d.a, d.h, d.r, t);
|
|
if (d.sfx != null) duck(B.sfx, d.sfx, d.a, d.h, d.r, t);
|
|
if (d.heart != null) duck(B.heart, d.heart, d.a, d.h, d.r, t);
|
|
}
|
|
const m = MASTER_DUCK[name];
|
|
if (m) {
|
|
const g = master.gain, base = baseMaster();
|
|
g.cancelScheduledValues(t); g.setValueAtTime(g.value, t);
|
|
g.linearRampToValueAtTime(base * m[0], t + m[1]);
|
|
g.setValueAtTime(base * m[0], t + m[1] + m[2]);
|
|
g.setTargetAtTime(base, t + m[1] + m[2], m[3] / 3);
|
|
}
|
|
// boost LIFTS the bed rather than ducking it. Ducking the world under a player-empowerment cue
|
|
// is emotionally backwards — the tube should open around you. It is doppler you don't compute.
|
|
if (name === 'boost') {
|
|
const g = B.bed.duck.gain;
|
|
g.cancelScheduledValues(t); g.setValueAtTime(g.value, t);
|
|
g.linearRampToValueAtTime(B.bed.base * dbToLin(1.5), t + 0.04);
|
|
g.setTargetAtTime(B.bed.base, t + 0.24, 0.5 / 3);
|
|
carrierGlide(t);
|
|
}
|
|
}
|
|
|
|
// Carrier gestures — the instrument failing, diegetically.
|
|
function carrierStutter(t, gaps) {
|
|
const g = carrierGain.gain, lvl = CARRIER_LVL * carrierMul;
|
|
g.cancelScheduledValues(t); g.setValueAtTime(g.value, t);
|
|
for (const [off, on] of gaps) {
|
|
g.setValueAtTime(0, t + off);
|
|
g.linearRampToValueAtTime(lvl, t + on);
|
|
}
|
|
}
|
|
function carrierGlide(t) {
|
|
const f = carrierA.frequency;
|
|
f.cancelScheduledValues(t); f.setValueAtTime(f.value, t);
|
|
f.linearRampToValueAtTime(CARRIER_HZ + 71, t + 0.2);
|
|
f.linearRampToValueAtTime(CARRIER_HZ, t + 0.9);
|
|
}
|
|
|
|
// ═══ 3. THE SYNTH (audio/synth.js) ══════════════════════════════════════════════════════════
|
|
// Loaded with a DYNAMIC import, deliberately. synth.js is written in parallel by another
|
|
// engineer; a static import would make this module fail to parse if that file is late or broken,
|
|
// and boot.js loads all of Lane E in one Promise.all — so a bad synth.js would take the HUD and
|
|
// the comms crew down with it. Dynamic + catch degrades to samples-plus-internal-fallbacks
|
|
// instead. It resolves within a frame from the module cache, and no cue is audible before the
|
|
// user gesture that unlocks the context anyway, so in practice it is always ready in time.
|
|
//
|
|
// The contract is createSynth(ctx, destination) — ONE destination. Cues need four different
|
|
// buses (sfx / warn / comms / heart), so we instantiate one synth per bus. Each is a closure
|
|
// that allocates per play(); the cost of the extra instances is effectively zero, and it is the
|
|
// only way to honour both the one-destination contract and the ducking matrix.
|
|
const synths = { sfx: null, warn: null, comms: null, heart: null };
|
|
let synthReady = false;
|
|
import('./synth.js').then((m) => {
|
|
if (disposed || typeof m.createSynth !== 'function') return;
|
|
synths.sfx = m.createSynth(ctx, B.sfx.in, { rnd });
|
|
synths.warn = m.createSynth(ctx, B.warn.in, { rnd });
|
|
synths.comms = m.createSynth(ctx, B.comms.in, { rnd });
|
|
synths.heart = m.createSynth(ctx, B.heart.in, { rnd });
|
|
synthReady = true;
|
|
}).catch((e) => {
|
|
console.info('[audio] synth.js unavailable, running on samples + internal fallbacks —', e.message);
|
|
});
|
|
|
|
// Names that are NOT bus cues but must reach the WARN instance, because that is where their
|
|
// state lives. Each synth is a separate closure: `overheat` plays on warn, so warn holds the
|
|
// heatVoice that `overheat_clear` has to cancel, and `surge_start` plays on warn, so warn holds
|
|
// the chase loop that surge_chase/surge_unstall modulate. Routing any of them to the sfx
|
|
// instance addresses a null — the call returns false, the state it was supposed to end never
|
|
// ends, and (for overheat_clear) the engine ALSO plays its fallback tick, a double signal on
|
|
// every cooldown while the warn instance keeps hissing until synth.js's 12 s safety net.
|
|
const WARN_ROUTED = new Set(['overheat_clear', 'surge_chase', 'surge_unstall']);
|
|
function synthFor(name) {
|
|
if (!synthReady) return null;
|
|
if (name === 'heartbeat') return synths.heart;
|
|
if (name === 'comms_open') return synths.comms;
|
|
return (WARN_CUES.has(name) || WARN_ROUTED.has(name)) ? synths.warn : synths.sfx;
|
|
}
|
|
const synthHas = (name) => { const s = synthFor(name); return !!(s && s.has(name)); };
|
|
|
|
// ═══ 4. SAMPLE CACHE ════════════════════════════════════════════════════════════════════════
|
|
// fetch + decodeAudioData ONCE per key; cheap BufferSourceNodes thereafter. A decode failure
|
|
// caches `null`, which routes that cue back to the synth forever — it must never throw and must
|
|
// never retry in a loop.
|
|
// Keyed by "cat/key", never by key alone. Bed keys are biome ids and sfx keys are pellet/boost/
|
|
// pickup/hit_squelch, so nothing collides TODAY — but a manifest that later ships an sfx named
|
|
// after a biome would cross-wire a 24-second looping bed into a cue, which is the kind of bug
|
|
// that gets diagnosed as "the audio engine is broken" rather than as a one-character cache key.
|
|
const sampleCache = new Map(); // "cat/key" -> AudioBuffer | null
|
|
const sampleLoading = new Map(); // "cat/key" -> Promise
|
|
// Precomputed so the cue path never builds a string (R4): the cannon hits emit() ~8x/second.
|
|
const SAMPLE_CK = {};
|
|
for (const n in SAMPLE_KEY) SAMPLE_CK[n] = 'sfx/' + SAMPLE_KEY[n];
|
|
const bedCK = (id) => 'beds/' + id; // biome path only — rare, a string here is free
|
|
|
|
function entryOf(cat, key) {
|
|
try { return assets && typeof assets.audio === 'function' ? assets.audio(cat, key) : null; }
|
|
catch (e) { void e; return null; }
|
|
}
|
|
function urlOf(cat, key) {
|
|
// audioUrl() already probes canPlayType and returns ogg-or-m4a for THIS browser. Never
|
|
// hardcode an extension. It builds a fresh <audio> probe on EVERY call, so it is only ever
|
|
// called once per key here, on the load path — never per cue, never per frame.
|
|
try { return assets && typeof assets.audioUrl === 'function' ? assets.audioUrl(cat, key) : null; }
|
|
catch (e) { void e; return null; }
|
|
}
|
|
function loadSample(cat, key) {
|
|
const ck = cat + '/' + key;
|
|
if (sampleCache.has(ck)) return Promise.resolve(sampleCache.get(ck));
|
|
if (sampleLoading.has(ck)) return sampleLoading.get(ck);
|
|
const url = urlOf(cat, key);
|
|
if (!url) { sampleCache.set(ck, null); return Promise.resolve(null); }
|
|
const p = fetch(url)
|
|
.then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(new Error('HTTP ' + r.status))))
|
|
.then((ab) => new Promise((res, rej) => {
|
|
// Callback form: Safari's decodeAudioData does not reliably return a Promise.
|
|
try { ctx.decodeAudioData(ab, res, rej); } catch (e) { rej(e); }
|
|
}))
|
|
.then((buf) => { sampleCache.set(ck, buf); sampleLoading.delete(ck); return buf; })
|
|
.catch((e) => {
|
|
console.info(`[audio] sample "${ck}" unavailable, synthesizing —`, e.message);
|
|
sampleCache.set(ck, null); sampleLoading.delete(ck); return null;
|
|
});
|
|
sampleLoading.set(ck, p);
|
|
return p;
|
|
}
|
|
// Warm the five sfx keys we actually map, once, right after construction. Not on first use:
|
|
// decoding a 200 ms file the first time the cannon fires would miss that shot.
|
|
function warmSamples() {
|
|
const want = new Set(Object.values(SAMPLE_KEY));
|
|
for (const k of want) if (entryOf('sfx', k)) loadSample('sfx', k);
|
|
}
|
|
|
|
function playSample(name, key, t, { rate = 1, gainMul = 1, pan = 0 } = {}) {
|
|
const buf = sampleCache.get(SAMPLE_CK[name]);
|
|
if (!buf) return false;
|
|
const e = entryOf('sfx', key) || {};
|
|
// Respect the per-entry gain. D loudness-normalised the beds and peak-normalised the sfx, so
|
|
// raw playback has sfx MUCH hotter than the bed by design — without entry.gain the mix is
|
|
// simply wrong, and without SAMPLE_TRIM the sampled and synthesized builds are different mixes.
|
|
const peak = (e.gain != null ? e.gain : 0.5) * SAMPLE_TRIM * gainMul;
|
|
const vca = takeGain();
|
|
vca.gain.setValueAtTime(0, t);
|
|
vca.gain.linearRampToValueAtTime(peak, t + 0.002); // R1: never a raw .value on a live node
|
|
const dur = (e.seconds || buf.duration) / Math.max(rate, 0.01);
|
|
vca.gain.setValueAtTime(peak, t + Math.max(dur - 0.006, 0.004));
|
|
vca.gain.linearRampToValueAtTime(0, t + Math.max(dur, 0.01));
|
|
// The return matters: at the voice ceiling with nothing of our own name to steal, bufVoice
|
|
// DROPS. Claiming `true` there would tell emit() a sample played and suppress the synth layer
|
|
// as well, turning a dropped voice into a silent cue.
|
|
return !!bufVoice(name, buf, B.sfx.in, vca, { t, stop: t + dur + 0.02 }, { rate, offset: 0, pan });
|
|
}
|
|
|
|
// ═══ 5. INTERNAL FALLBACK VOICES ════════════════════════════════════════════════════════════
|
|
// If synth.js is missing AND no sample maps, the game must still be audible — that is the
|
|
// assets-optional law taken one step further than the brief requires (it also covers a missing
|
|
// TEAMMATE, not just a missing asset). These are deliberately crude: a tone shaped by the two
|
|
// readability laws, so even the degraded build stays legible.
|
|
// LAW A — pitch RISES = you gained; pitch FALLS = you lost.
|
|
// LAW B — player fire DESCENDS, enemy fire ASCENDS. If incoming and outgoing fire share a
|
|
// contour you cannot tell them apart in a firefight without looking.
|
|
const FALLBACK = {
|
|
cannon: ['square', 380, 95, 0.07, 0.24],
|
|
dart: ['sawtooth', 640, 1180, 0.10, 0.20], // ascends — Law B
|
|
enemy_hit: ['sine', 420, 560, 0.12, 0.16],
|
|
enemy_die: ['sine', 160, 46, 0.34, 0.30],
|
|
coat_hit: ['sine', 150, 95, 0.13, 0.16],
|
|
hull_hit: ['sine', 190, 38, 0.30, 0.34],
|
|
wall_scrape: ['sawtooth', 300, 260, 0.10, 0.10],
|
|
boost: ['sine', 90, 200, 0.35, 0.20], // rises — Law A
|
|
torpedo: ['triangle', 220, 60, 0.28, 0.28],
|
|
torpedo_blast: ['sine', 88, 28, 0.50, 0.50],
|
|
overheat: ['square', 1046, 1046, 0.50, 0.20],
|
|
gate_pass: ['triangle', 587, 880, 0.22, 0.16],
|
|
gate_hit: ['sine', 320, 300, 0.40, 0.30], // the one sanctioned semitone: "no"
|
|
checkpoint: ['triangle', 587, 1174, 0.30, 0.22],
|
|
pickup: ['sine', 1046, 1568, 0.20, 0.20],
|
|
pickup_sample: ['sine', 659, 1318, 0.45, 0.18],
|
|
comms_open: ['sine', 1900, 1900, 0.03, 0.05],
|
|
death: ['sine', 120, 40, 0.90, 0.28],
|
|
surge_start: ['sine', 34, 55, 2.00, 0.40],
|
|
surge_stall: ['sine', 180, 130, 0.30, 0.30],
|
|
surge_end: ['sine', 55, 110, 0.90, 0.22],
|
|
warn_reflux_surge: ['sine', 1046, 897, 0.30, 0.24], // descends — the thing BEHIND you
|
|
warn_aortic_squeeze: ['triangle', 698, 698, 0.20, 0.22],
|
|
warn_ring_gate: ['sine', 880, 880, 0.12, 0.22],
|
|
};
|
|
function fallbackVoice(name, dest, t, gainMul, detuneCents) {
|
|
const f = FALLBACK[name];
|
|
if (!f) return false;
|
|
const [type, f1, f2, dur, peak] = f;
|
|
if (voices.length >= ceiling && !steal(name)) return false;
|
|
const k = Math.pow(2, (detuneCents || 0) / 1200);
|
|
const osc = ctx.createOscillator();
|
|
osc.type = type;
|
|
osc.frequency.setValueAtTime(f1 * k, t);
|
|
if (f2 !== f1) osc.frequency.exponentialRampToValueAtTime(Math.max(f2 * k, 1e-4), t + dur);
|
|
const vca = adsr(t, peak * gainMul, 0.004, dur);
|
|
osc.connect(vca); vca.connect(dest);
|
|
const v = { name, src: osc, vca, extra: [], at: ctx.currentTime };
|
|
voices.push(v);
|
|
osc.onended = () => releaseVoice(v);
|
|
// See bufVoice: a throw here means onended never fires, so the slot must be reclaimed by hand.
|
|
try { osc.start(t); osc.stop(t + dur + 0.05); } catch (e) { void e; releaseVoice(v); return false; }
|
|
return true;
|
|
}
|
|
|
|
// ═══ 6. THE CUE ROUTER ══════════════════════════════════════════════════════════════════════
|
|
const lastCueT = new Map();
|
|
// Fixed 8-entry detune table, cycled by an index — not the RNG. Fixed pitch at 8 shots/s
|
|
// produces a periodic comb the ear reads as a buzz, but +/-20% random reads as two different
|
|
// weapons and destroys event identity. +/-3% from a table satisfies both, matches the house
|
|
// no-RNG convention (hud.js hashes p.s, comms.js round-robins) and makes bugs reproducible.
|
|
// Noise BUFFER offsets still use the seeded stream — different problem, different answer.
|
|
const DETUNE = [0, -38, 38, -16, 21, -55, 7, 53];
|
|
let cannonIx = 0, dartIx = 0;
|
|
// Preallocated: the cue path must not allocate (R4) — the cannon runs it ~8x/second.
|
|
const extras = { heat: 0, combo: 0, eta: 0, amount: 0, third: 0 };
|
|
|
|
let hitChain = 0, hitChainT = 0; // consecutive-hit ladder (enemy_hit)
|
|
let pickChain = 0, pickChainT = 0; // coin ladder (pickup)
|
|
let comboN = 0;
|
|
let sampleCount = 0; // biopsy samples this level, for the third-of-three flourish
|
|
let lastDamageAmount = 1; // latched off player:damage, consumed by the wall_scrape cue
|
|
let staticVoice = null; // the death feed-static loop, released on re-acquire
|
|
let chaseP = -1; // last proximity handed to synth.js's chase loop (-1 = none)
|
|
let surgeStalled = false; // last hazard:proximity.stalled, for its FALLING edge
|
|
|
|
// No `opts = {}` default: a defaulted object literal ALLOCATES on every call, and the cannon
|
|
// runs this ~8x/second — which is the R4 claim this file makes two comments below.
|
|
function cue(name, opts) {
|
|
if (disposed || !unlocked || !name) return;
|
|
const t = t0();
|
|
|
|
// i-frames suppress hull_hit ENTIRELY, carrier included — so the check has to be here, above
|
|
// applyDucks. Down in the per-cue branch it was already too late: MASTER_DUCK.hull_hit had
|
|
// scheduled a silent 45% dip of the WHOLE MIX for 164 ms on a hit that makes no sound.
|
|
if (name === 'hull_hit' && sig.iframes) return;
|
|
|
|
// De-dupe / rate-limit. Two emits in the same frame are 0 ms apart, far under any gap here.
|
|
const gap = CUE_MIN_GAP[name];
|
|
if (gap != null) {
|
|
const last = lastCueT.get(name);
|
|
if (last != null && t - last < gap) return;
|
|
}
|
|
lastCueT.set(name, t);
|
|
|
|
const cap = CUE_CAP[name] || 4;
|
|
if (countOf(name) >= cap) steal(name);
|
|
|
|
// warn_* ducks once PER PULSE further down, so it must not also take an unconditional duck
|
|
// here — that would be a fourth, earlier, un-aligned hole in the masker.
|
|
if (!name.startsWith('warn_')) applyDucks(name, t);
|
|
|
|
let gainMul = opts && opts.gain != null ? opts.gain : 1;
|
|
const detune = opts && opts.detune ? opts.detune : 0;
|
|
let pan = 0;
|
|
|
|
// ── SIGNALS, not shaping. ──────────────────────────────────────────────────────────────────
|
|
// DIVISION OF LABOUR, and getting this wrong is the loudest bug available: synth.js owns the
|
|
// cannon detune table, the enemy_hit consecutive ladder, the pickup coin ladder, the
|
|
// enemy_die combo ladder, the pickup_sample third-specimen flourish and the warn_* eta
|
|
// accelerando. This engine owns the BUS SIGNALS those effects are computed FROM, because the
|
|
// synth is bus-blind by contract. So we forward raw numbers and apply NOTHING ourselves —
|
|
// doing both would double every ladder and turn one 3-pulse warning into nine pulses.
|
|
// The equivalent ladders further down are the FALLBACK path only, for a missing synth.
|
|
const ext = extras;
|
|
ext.heat = 0; ext.combo = 0; ext.eta = 0; ext.amount = 0; ext.third = 0;
|
|
if (name === 'cannon') {
|
|
// Heat coupling is free telegraphy: the gun audibly sours before it locks out, so `overheat`
|
|
// is never a surprise. Only this engine can see combat:state, so only it can supply it.
|
|
ext.heat = clamp01(sig.heat);
|
|
// Voice-sum guard stays HERE — it is a mix concern, not a sound-design one. A held trigger
|
|
// must stop summing before it becomes a wall, and only the engine counts live voices.
|
|
gainMul *= 1 - 0.375 * clamp01(countOf('cannon') / 4);
|
|
} else if (name === 'dart') {
|
|
// Alternating pan is a FAKE and it is the weakest thing in this engine. It needs an azimuth
|
|
// from Lane C — see the notes at the bottom.
|
|
pan = (dartIx++ & 1) ? 0.5 : -0.5;
|
|
} else if (name === 'enemy_die') {
|
|
ext.combo = comboN;
|
|
} else if (name === 'pickup_sample') {
|
|
ext.third = sampleCount;
|
|
} else if (name === 'wall_scrape') {
|
|
// A CONTINUOUS voice, never a one-shot: this cue can fire many times per second while you
|
|
// grind a wall, and a voice per cue is a machine-gun stutter that burns the voice budget.
|
|
// The synth keeps its own permanent scrape voice, so delegate the excitation to it and only
|
|
// run ours if it cannot. Either way this returns early — no per-cue voice, no duck.
|
|
if (!(synthHas('wall_scrape') && trySynth(name, t, gainMul, detune, { amount: lastDamageAmount })))
|
|
scrape(lastDamageAmount);
|
|
return;
|
|
} else if (name === 'surge_end') {
|
|
// THE AUTHORITATIVE end-of-surge signal. hazard:proximity has no clear event, so without
|
|
// this the danger term would only bleed off via the pump's 250 ms half-life and the heart
|
|
// would keep racing for a second after the threat is provably gone — turning the best
|
|
// moment in the game (the relief) into a lag. Forced to zero, not decayed.
|
|
sig.surge = 0; sig.surgeStamp = -99; sig.warn = 0;
|
|
// The chase loop is gone with it (synth.js endChase), so forget its state or the next surge
|
|
// opens against a stale proximity and a stall that is no longer true.
|
|
chaseP = -1; surgeStalled = false;
|
|
// Queued warn pulses belong to the surge that just ended: firing their ducks now would cut
|
|
// holes in the mix for a threat the player has provably outrun.
|
|
pendingDucks.length = 0;
|
|
} else if (name === 'hull_hit') {
|
|
// (the i-frame suppression is at the top of cue(), above applyDucks)
|
|
// The carrier stutters: contamination of one signal by another, which is the literal audio
|
|
// implementation of ART_BIBLE's "damage is feed corruption" law.
|
|
carrierStutter(t, [[0, 0.030], [0.048, 0.066]]);
|
|
} else if (name === 'gate_hit') {
|
|
carrierStutter(t, [[0, 0.024]]);
|
|
} else if (name.startsWith('warn_')) {
|
|
// The warning is a 3-pulse ACCELERANDO, not a beep: a lone tone 2.5 s early is not a warning,
|
|
// it is trivia — THE ACCELERATION IS THE INFORMATION. synth.js schedules the three pulses
|
|
// itself from `eta`, so we forward eta and must NOT schedule them too (that is the nine-pulse
|
|
// bug). What stays ours is the DUCK, because the synth cannot see the buses: one duck per
|
|
// pulse, at the same offsets, so the hole in the masker tracks the accelerando instead of
|
|
// being one long smear the player stops noticing.
|
|
ext.eta = sig.warnEta > 0 ? sig.warnEta : 2.5;
|
|
const offs3 = ext.eta >= 1.2 ? [ext.eta - 1.9, ext.eta - 1.0, ext.eta - 0.35] : [0, 0.18, 0.36];
|
|
// Queued, not scheduled — see pendingDucks above. Anything already inside the pump's
|
|
// lookahead is applied now; the rest waits for the clock.
|
|
for (let i = 0; i < 3; i++) {
|
|
const at = Math.max(t + 0.02, t + offs3[i]);
|
|
if (at <= t + 0.05) applyDucks(name, at);
|
|
else pendingDucks.push({ name, at });
|
|
}
|
|
}
|
|
|
|
const dest = name === 'comms_open' ? B.comms.in : (WARN_CUES.has(name) ? B.warn.in : B.sfx.in);
|
|
emit(name, dest, t, gainMul, detune, pan, ext);
|
|
}
|
|
|
|
// One cue -> one or two voices. Sample first where one is mapped, synth otherwise; where the
|
|
// sample is a LAYER the synth also plays, so the parts the asset cannot carry survive.
|
|
// Preallocated opts (R4: no allocation on the cue path, which the cannon hits ~8x/second).
|
|
// `when` is ctx.currentTime-domain ABSOLUTE — verified against synth.js, which does
|
|
// `Math.max(opts.when ?? 0, now())`. Delay-vs-absolute is exactly the ambiguity that ships broken.
|
|
const playOpts = { gain: 1, when: 0, detune: 0, heat: 0, combo: 0, eta: 0, amount: 0, third: 0 };
|
|
function trySynth(name, t, gainMul, detune, ext) {
|
|
const s = synthFor(name);
|
|
if (!s) return false;
|
|
playOpts.gain = gainMul; playOpts.when = t; playOpts.detune = detune;
|
|
playOpts.heat = (ext && ext.heat) || 0; playOpts.combo = (ext && ext.combo) || 0;
|
|
playOpts.eta = (ext && ext.eta) || 0; playOpts.amount = (ext && ext.amount) || 0;
|
|
playOpts.third = (ext && ext.third) || 0;
|
|
try { return !!s.play(name, playOpts); } catch (e) { void e; return false; }
|
|
}
|
|
|
|
function emit(name, dest, t, gainMul, detune, pan, ext) {
|
|
const key = SAMPLE_KEY[name];
|
|
let played = false;
|
|
if (key && sampleCache.get(SAMPLE_CK[name])) {
|
|
const rate = Math.pow(2, detune / 1200);
|
|
played = playSample(name, key, t, { rate, gainMul, pan });
|
|
}
|
|
// Where a sample is only a LAYER, the synth still plays underneath at reduced gain so the
|
|
// parts the asset cannot carry (the cannon's click, boost's lift, the 3-note sample rise)
|
|
// survive. Where the sample is the whole voice, the synth is skipped.
|
|
if (!played || SAMPLE_IS_LAYER.has(name)) {
|
|
const ok = trySynth(name, t, gainMul * (played ? 0.7 : 1), detune, ext);
|
|
played = played || ok;
|
|
}
|
|
if (!played) fallbackVoice(name, dest, t, gainMul, detune + fallbackLadder(name, t));
|
|
}
|
|
|
|
// The ladders, FALLBACK PATH ONLY. synth.js owns these when it is present (it keeps its own
|
|
// hitN/coinN/cannon-detune counters); running them in both places would double every step. They
|
|
// live here so that a build with no synth still gets the rising hit ladder and the coin ladder,
|
|
// which are the cheapest feel wins available and should not vanish with the synth.
|
|
function fallbackLadder(name, t) {
|
|
if (name === 'cannon') return DETUNE[cannonIx++ & 7] - 200 * clamp01(sig.heat);
|
|
if (name === 'enemy_hit') {
|
|
if (t - hitChainT > 0.9) hitChain = 0;
|
|
hitChainT = t;
|
|
return 100 * Math.min(hitChain++, 5);
|
|
}
|
|
if (name === 'pickup') {
|
|
if (t - pickChainT > 1.5) pickChain = 0;
|
|
pickChainT = t;
|
|
return 200 * Math.min(pickChain++, 4);
|
|
}
|
|
if (name === 'enemy_die') return 100 * Math.min(comboN, 7);
|
|
return 0;
|
|
}
|
|
|
|
// ═══ 7. WALL SCRAPE — a CONTINUOUS voice, not a one-shot ════════════════════════════════════
|
|
// This is a DSP hazard, not a taste call. `wall_scrape` can fire many times per second while you
|
|
// grind a wall; a voice per cue is a machine-gun stutter that burns the voice budget and is the
|
|
// single largest source of mix mud in this genre. One looping source, allocated once, excited by
|
|
// each cue and left to decay asymptotically.
|
|
let scrapeSrc = null, scrapeBP = null, scrapeVCA = null;
|
|
function buildScrape() {
|
|
scrapeSrc = ctx.createBufferSource();
|
|
scrapeSrc.buffer = NZ.pink; scrapeSrc.loop = true;
|
|
scrapeBP = ctx.createBiquadFilter(); scrapeBP.type = 'bandpass';
|
|
scrapeBP.frequency.value = 1100; scrapeBP.Q.value = 3.5;
|
|
const scrapeLP = ctx.createBiquadFilter(); scrapeLP.type = 'lowpass';
|
|
scrapeLP.frequency.value = 500; scrapeLP.Q.value = 1.0; // the parallel "meat" path
|
|
scrapeVCA = ctx.createGain(); scrapeVCA.gain.value = 0;
|
|
scrapeSrc.connect(scrapeBP); scrapeBP.connect(scrapeVCA);
|
|
scrapeSrc.connect(scrapeLP); scrapeLP.connect(scrapeVCA);
|
|
scrapeVCA.connect(B.sfx.in);
|
|
scrapeVCA.connect(cavitySend);
|
|
try { scrapeSrc.start(0, rnd() * 1.5); } catch (e) { void e; }
|
|
}
|
|
function scrape(amount) {
|
|
// Built on demand, not at unlock: when synth.js is present it keeps its own permanent scrape
|
|
// voice and this one would be a second looping source running forever for nothing.
|
|
if (!scrapeSrc) { try { buildScrape(); } catch (e) { void e; return; } }
|
|
if (!scrapeVCA) return;
|
|
const t = t0();
|
|
// 6 is a normalisation floor for `amount` — balance constants cannot be imported (House Law 1).
|
|
const peak = 0.12 + 0.20 * Math.min((amount || 1) / 6, 1);
|
|
const g = scrapeVCA.gain;
|
|
g.cancelScheduledValues(t);
|
|
g.setValueAtTime(g.value, t); // anchor at the CURRENT value, or the ramp starts from a
|
|
g.linearRampToValueAtTime(peak, t + 0.012); // stale scheduled value and jumps
|
|
g.setTargetAtTime(0, t + 0.012, 0.11); // asymptotic: below -80 dB in ~0.9 s
|
|
const f = scrapeBP.frequency;
|
|
f.cancelScheduledValues(t); f.setValueAtTime(f.value, t);
|
|
f.linearRampToValueAtTime(900 + rnd() * 700, t + 0.05);
|
|
}
|
|
|
|
// ═══ 8. THE BED ═════════════════════════════════════════════════════════════════════════════
|
|
// Two halves that coexist: D's SAMPLED bed where one ships for this biome, and a SYNTHESIZED
|
|
// drone that never stops. When a sampled bed is playing the drone ducks to 0.25x and loses its
|
|
// grain layer; it is the floor of the world. The consequence is the good one: a missing bed for
|
|
// `stomach` is inaudible AS A FAILURE — it just sounds like a different, drier room.
|
|
const droneGraphs = new Map(); // biome -> { out, nodes[], grainP }
|
|
let curBiome = null, pendingBiome = null, fading = false, fadeTimer = null;
|
|
let bedSlotA = null, bedSlotB = null, bedUsingA = true;
|
|
let bedStartTime = 0; // the exact ctx time the sampled bed started — the heart phase-locks to it
|
|
let bedPlaying = false;
|
|
|
|
function buildDrone(id) {
|
|
const cfg = BIOME_BED[id] || BIOME_BED.neutral;
|
|
const nodes = [];
|
|
const out = ctx.createGain(); out.gain.value = 0;
|
|
out.connect(B.drone.in);
|
|
out.connect(cavitySend);
|
|
|
|
// L1 cavity — the outside world heard through meat.
|
|
const src = ctx.createBufferSource(); src.buffer = NZ.pink; src.loop = true;
|
|
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 45;
|
|
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cfg.lp; lp.Q.value = 0.7;
|
|
const cav = ctx.createGain(); cav.gain.value = 0.30;
|
|
src.connect(hp); hp.connect(lp); lp.connect(cav); cav.connect(out);
|
|
nodes.push(src, hp, lp, cav);
|
|
|
|
// L2 wall resonances + L3 breathing. The breathing LFOs drive the band VCAs' gain AudioParams
|
|
// directly (AudioParam-to-AudioParam AM) — the correct WebAudio idiom, and ZERO per-frame JS.
|
|
// The three rates are mutually irrational-ish so the composite never audibly repeats.
|
|
const bandGains = [0.16, 0.11, 0.08], breathHz = [0.070, 0.113, 0.181];
|
|
for (let i = 0; i < 3; i++) {
|
|
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass';
|
|
bp.frequency.value = cfg.res[i]; bp.Q.value = [7, 9, 6][i];
|
|
const vca = ctx.createGain(); vca.gain.value = bandGains[i] * 0.65;
|
|
const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = breathHz[i];
|
|
const depth = ctx.createGain(); depth.gain.value = bandGains[i] * 0.35;
|
|
lfo.connect(depth); depth.connect(vca.gain);
|
|
hp.connect(bp); bp.connect(vca); vca.connect(out);
|
|
try { lfo.start(); } catch (e) { void e; }
|
|
nodes.push(bp, vca, lfo, depth);
|
|
}
|
|
|
|
// L4 peristalsis — the tube swallowing. LFO offsets the L1 cutoff.
|
|
const per = ctx.createOscillator(); per.type = 'sine'; per.frequency.value = cfg.per;
|
|
const perDepth = ctx.createGain(); perDepth.gain.value = 130;
|
|
per.connect(perDepth); perDepth.connect(lp.frequency);
|
|
try { per.start(); } catch (e) { void e; }
|
|
nodes.push(per, perDepth);
|
|
|
|
try { src.start(0, rnd() * 1.0); } catch (e) { void e; }
|
|
return { out, nodes, grainP: cfg.grain, car: cfg.car };
|
|
}
|
|
function droneFor(id) {
|
|
const key = BIOME_BED[id] ? id : 'neutral';
|
|
if (!droneGraphs.has(key)) droneGraphs.set(key, buildDrone(key));
|
|
return droneGraphs.get(key);
|
|
}
|
|
|
|
// setValueCurveAtTime COPIES the array it is given, so one module-level scratch buffer is safe
|
|
// and removes the only allocation on the biome path.
|
|
const fadeScratch = new Float32Array(FADE_N);
|
|
function xfade(param, from, to, dur, t) {
|
|
// The rising leg is sin-shaped and the falling leg is cos-shaped: that is what makes the PAIR
|
|
// equal-power. Using the same shape for both (or interpolating linearly) puts a ~3 dB hole in
|
|
// the middle of every biome transition, which the player hears as the world briefly vanishing.
|
|
if (to >= from) for (let i = 0; i < FADE_N; i++) fadeScratch[i] = from + (to - from) * fadeIn[i];
|
|
else for (let i = 0; i < FADE_N; i++) fadeScratch[i] = to + (from - to) * fadeOut[i];
|
|
// setValueCurveAtTime throws NotSupportedError on OVERLAPPING automation windows, so cancel
|
|
// first — and see setBiome(), which refuses to start a fade while one is in flight.
|
|
param.cancelScheduledValues(t);
|
|
try { param.setValueCurveAtTime(fadeScratch, t, dur); }
|
|
catch (e) { void e; param.setValueAtTime(param.value, t); param.linearRampToValueAtTime(to, t + dur); }
|
|
}
|
|
|
|
// Fade and STOP both bed slots. This has to exist as its own function because startSampledBed
|
|
// only ever stops the old slot on its SUCCESS path: when the new biome ships no bed (the manifest
|
|
// ships exactly one, `esophagus`, and it is the OPENING biome) the old loop:true source was never
|
|
// touched, so the esophagus bed played on under stomach, small_intestine and everything after it
|
|
// for the rest of the run — and `bedPlaying`, never once assigned false, pinned the drone at
|
|
// 0.25x and suppressed the L5 grain layer for the whole level.
|
|
function stopSampledBeds(t, fade = 1.5) {
|
|
for (const slot of [bedSlotA, bedSlotB]) {
|
|
if (!slot) continue;
|
|
slot.vca.gain.cancelScheduledValues(t);
|
|
slot.vca.gain.setValueAtTime(slot.vca.gain.value, t);
|
|
slot.vca.gain.linearRampToValueAtTime(0, t + fade);
|
|
try { slot.src.stop(t + fade + 0.1); } catch (e) { void e; }
|
|
}
|
|
bedSlotA = bedSlotB = null;
|
|
bedUsingA = true;
|
|
bedPlaying = false;
|
|
}
|
|
|
|
// Bring the current biome's drone to its correct level for whether a sampled bed is playing.
|
|
// Called from the DEFERRED bed load too: setBiome computes the drone target from this function's
|
|
// synchronous result, and beds are not warmed, so the first setBiome ALWAYS missed and left the
|
|
// drone at 1.0 — the shipped build then opened with D's bed plus a full-level synth drone, the
|
|
// "two bodies" the bed section argues against, while ?localassets=0 sounded cleaner. A plain
|
|
// anchored ramp, not xfade(): this can land mid-biome-fade, and setValueCurveAtTime throws
|
|
// NotSupportedError on overlapping windows.
|
|
function droneToBedLevel(t, dur = 1.5) {
|
|
const g = droneGraphs.get(BIOME_BED[curBiome] ? curBiome : 'neutral');
|
|
if (!g) return;
|
|
const to = bedPlaying ? 0.25 : 1;
|
|
const p = g.out.gain;
|
|
if (Math.abs(p.value - to) < 0.01) return;
|
|
p.cancelScheduledValues(t);
|
|
p.setValueAtTime(p.value, t);
|
|
p.linearRampToValueAtTime(to, t + dur);
|
|
}
|
|
|
|
function startSampledBed(id, t) {
|
|
const e = entryOf('beds', id);
|
|
if (!e) return false;
|
|
const buf = sampleCache.get(bedCK(id));
|
|
if (!buf) {
|
|
loadSample('beds', id).then((b) => {
|
|
if (!b || disposed || curBiome !== id) return;
|
|
if (startSampledBed(id, t0())) droneToBedLevel(t0()); // re-duck: see droneToBedLevel
|
|
});
|
|
return false;
|
|
}
|
|
const vca = ctx.createGain(); vca.gain.value = 0;
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = buf; src.loop = true;
|
|
src.loopStart = 0; src.loopEnd = e.seconds || buf.duration;
|
|
// NO gap logic and NO seam fade. Lane D crossfaded the tail into the head (seam 0.32); adding
|
|
// fade logic here would CREATE a seam where there is none.
|
|
src.connect(vca); vca.connect(B.bed.in);
|
|
try { src.start(t); } catch (ex) { void ex; return false; }
|
|
const peak = (e.gain != null ? e.gain : 0.5) * SAMPLE_TRIM;
|
|
vca.gain.setValueAtTime(0, t);
|
|
vca.gain.linearRampToValueAtTime(peak, t + 1.5);
|
|
const slot = { src, vca, peak };
|
|
const old = bedUsingA ? bedSlotA : bedSlotB;
|
|
if (bedUsingA) bedSlotB = slot; else bedSlotA = slot;
|
|
bedUsingA = !bedUsingA;
|
|
if (old) {
|
|
old.vca.gain.cancelScheduledValues(t);
|
|
old.vca.gain.setValueAtTime(old.vca.gain.value, t);
|
|
old.vca.gain.linearRampToValueAtTime(0, t + 1.5);
|
|
try { old.src.stop(t + 1.6); } catch (ex) { void ex; }
|
|
}
|
|
bedStartTime = t; // heart phase anchor
|
|
bedPlaying = true;
|
|
return true;
|
|
}
|
|
|
|
function setBiome(id) {
|
|
if (id === curBiome) return;
|
|
// Flapping across a biome boundary is a real scenario and it WILL crash the engine via
|
|
// overlapping setValueCurveAtTime windows. Queue the target instead of starting a second fade.
|
|
if (fading) { pendingBiome = id; return; }
|
|
const prev = curBiome;
|
|
curBiome = id;
|
|
const t = t0();
|
|
fading = true;
|
|
|
|
const next = droneFor(id);
|
|
const prevG = prev != null ? droneGraphs.get(BIOME_BED[prev] ? prev : 'neutral') : null;
|
|
|
|
// If the new biome's bed is not playable RIGHT NOW — no manifest entry, or an entry whose
|
|
// buffer has not decoded (or 404'd and cached null) — silence the bed that is playing. It
|
|
// belongs to a biome you have left. Only when both buffers are in hand does startSampledBed's
|
|
// own crossfade run; that is the one case where letting the old bed ring on is correct.
|
|
const bedReady = !!(entryOf('beds', id) && sampleCache.get(bedCK(id)));
|
|
if (!bedReady) stopSampledBeds(t);
|
|
// Start the sampled bed FIRST so the drone's fade target is known before we schedule it. Two
|
|
// setValueCurveAtTime calls on one param in the same tick is the overlapping-automation
|
|
// NotSupportedError this whole function is built to avoid — so the target is computed once.
|
|
startSampledBed(id, t);
|
|
// Equal-power, never linear: two uncorrelated drones crossfaded linearly dip ~3 dB in the
|
|
// middle and the player hears a hole exactly at the transition you wanted to feel smooth.
|
|
// The synth drone NEVER stops — it ducks to 0.25x under a sampled bed and is the floor of the
|
|
// world. That is why a missing bed for a biome is inaudible AS A FAILURE.
|
|
xfade(next.out.gain, next.out.gain.value, bedPlaying ? 0.25 : 1, 1.5, t);
|
|
if (prevG && prevG !== next) xfade(prevG.out.gain, prevG.out.gain.value, 0, 1.5, t);
|
|
|
|
carrierMul = next.car;
|
|
const cg = carrierGain.gain;
|
|
cg.cancelScheduledValues(t); cg.setValueAtTime(cg.value, t);
|
|
cg.linearRampToValueAtTime((carrierStarted ? CARRIER_LVL : 0) * carrierMul, t + 1.5);
|
|
|
|
if (fadeTimer) clearTimeout(fadeTimer);
|
|
fadeTimer = setTimeout(() => {
|
|
fadeTimer = null; fading = false;
|
|
if (pendingBiome && pendingBiome !== curBiome) { const p = pendingBiome; pendingBiome = null; setBiome(p); }
|
|
else pendingBiome = null;
|
|
}, 1600);
|
|
}
|
|
|
|
// L5 fluid grains — scheduled from the heartbeat pump, NEVER a second timer. This is what makes
|
|
// a drone read as a BODY rather than as a synth pad. Suppressed while a sampled bed plays (D's
|
|
// bed has this baked in and doubling it sounds like two bodies) and under ?shots.
|
|
let grainCount = 0;
|
|
function maybeGrain(beatT, period) {
|
|
if (shots || bedPlaying) return;
|
|
if (grainCount >= 2) return;
|
|
const g = droneGraphs.get(BIOME_BED[curBiome] ? curBiome : 'neutral');
|
|
if (!g) return;
|
|
const p = (HEART.grainBase + HEART.grainSpan * D) * g.grainP; // busier when you are in trouble
|
|
if (rnd() > p) return;
|
|
const t = beatT + rnd() * period;
|
|
const src = ctx.createBufferSource(); src.buffer = NZ.brown; src.loop = true;
|
|
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass';
|
|
bp.frequency.value = 180 + rnd() * 720; bp.Q.value = 8 + rnd() * 8;
|
|
const dur = 0.06 + rnd() * 0.16;
|
|
const vca = adsr(t, 0.07, 0.004, dur);
|
|
src.connect(bp); bp.connect(vca); vca.connect(B.drone.in); vca.connect(cavitySend);
|
|
grainCount++;
|
|
src.onended = () => { grainCount--; try { src.disconnect(); bp.disconnect(); } catch (e) { void e; } give(vca, 'gain'); };
|
|
try { src.start(t, rnd() * 3); src.stop(t + dur + 0.05); } catch (e) { void e; }
|
|
}
|
|
|
|
// ═══ 9. THE DANGER SCALAR AND THE HEARTBEAT ═════════════════════════════════════════════════
|
|
//
|
|
// THE MAPPING, documented as the charter requires:
|
|
//
|
|
// THREAT = max(tSurge, tHull, tCoat, tWarn) <- MAX, not a sum. The nearest death
|
|
// tSurge hazard:proximity.distance, signed, dominates. Summing makes a slow fight with
|
|
// >0 = you are ahead. 1 at <=3u low coat read as "surge behind you", a lie.
|
|
// (catchRadius), 0 at 220u (proximityRange), x0.35 while stalled.
|
|
// tHull (1 - hull/hullMax)^1.5 from player:state
|
|
// tCoat 0.55*(1 - coat/coatMax), latched to >=0.55 below 25% coat. The 0.25 threshold
|
|
// MATCHES comms.js exactly, so Voss's "coat low" line and the heart agree on the
|
|
// same edge rather than disagreeing by a frame.
|
|
// tWarn 0.6 on hazard:warn, decaying linearly to 0 over eta+1s.
|
|
//
|
|
// HEAT = 0.55*min(enemies/6,1) + 0.35*overheated + 0.25*min(combo/8,1) <- ADDITIVE. This is
|
|
// how HOT the fight is, not how close death is. (combat:state.enemies / .overheated / combo)
|
|
//
|
|
// D_target = clamp01(0.80*THREAT + 0.28*HEAT) coefficients sum >1 deliberately: a maxed
|
|
// bpm = 50 + 88 * D^1.30 threat AND a hot fight should pin.
|
|
//
|
|
// WHY COMBO IS ONLY 0.25 INSIDE 0.28: the heart belongs to the PATIENT, not to you. Wiring combo
|
|
// hard to bpm makes the body excited about your kill streak. Danger raises the pulse; skill
|
|
// sharpens the readout — which is why combo's real home is the enemy_hit and enemy_die ladders
|
|
// in the router above. The charter asks for combo in the heartbeat and a small term pays that
|
|
// honestly, because a big streak IS a hot fight.
|
|
//
|
|
// WHY 50.000 bpm EXACTLY AT IDLE: bed-esophagus is 24.000 s with a heartbeat baked in at 50 bpm
|
|
// — 20 beats, dividing the loop exactly. A synth heart at ANY other idle rate FLAMS against the
|
|
// baked one: a smeared double-thump that decodes as a broken audio decoder, not as danger. It
|
|
// will not show up in a spec review and it is the first thing anybody hears in the build.
|
|
const sig = { // preallocated. The 60 Hz handlers write scalars into this and do nothing else.
|
|
coatT: 0, hullT: 0, surge: 0, surgeStamp: -99, warn: 0, warnEta: 0,
|
|
// No `alive` field: player:death / player:spawn are the authoritative edges (see §11) and a
|
|
// per-frame copy of p.alive was written every frame and read by nothing.
|
|
heat: 0, overheated: 0, foes: 0, iframes: false,
|
|
};
|
|
let D = 0;
|
|
let beating = true, dying = false, lastDeathT = -99;
|
|
let nextBeat = 0, curBpm = 50;
|
|
|
|
function targetD() {
|
|
const threat = Math.max(sig.surge, sig.hullT, sig.coatT, sig.warn);
|
|
const heat = clamp01(0.55 * Math.min(sig.foes / 6, 1) + 0.35 * sig.overheated + 0.25 * Math.min(comboN / 8, 1));
|
|
return clamp01(HEART.threatW * threat + HEART.heatW * heat);
|
|
}
|
|
function bpmFromD() {
|
|
// ^1.30 keeps the first third of the range nearly at rest, so the heart MEANS something when
|
|
// it moves. 138 is the ceiling — faster reads as a drum machine, not as a body.
|
|
const want = HEART.bpmBase + HEART.bpmSpan * Math.pow(D, HEART.bpmExp);
|
|
// Slew the bpm too: +8/s up, -4/s down. Tempo is recomputed once per beat AT THE BOUNDARY,
|
|
// never mid-cycle — a continuously varying period stutters and decodes as a bug.
|
|
const dt = 60 / Math.max(curBpm, 1);
|
|
const up = HEART.bpmUp * dt, dn = HEART.bpmDn * dt;
|
|
curBpm += Math.max(-dn, Math.min(up, want - curBpm));
|
|
return curBpm;
|
|
}
|
|
|
|
// The pulse. The synth owns the SOUND where it offers heart_s1/heart_s2; this is the fallback so
|
|
// that the headline feature never depends on a file written in another process.
|
|
function thump(t, hz1, hz2, peak, dur) {
|
|
const osc = ctx.createOscillator(); osc.type = 'sine';
|
|
osc.frequency.setValueAtTime(hz1, t);
|
|
osc.frequency.exponentialRampToValueAtTime(Math.max(hz2, 1e-4), t + dur * 0.6);
|
|
const vca = adsr(t, peak, 0.006, dur); // 6 ms on a 62 Hz sine is under half a cycle: correct
|
|
osc.connect(vca); vca.connect(B.heart.in); // and necessary, a longer attack swallows the transient
|
|
const v = { name: 'heart', src: osc, vca, extra: [], at: ctx.currentTime };
|
|
voices.push(v);
|
|
osc.onended = () => releaseVoice(v);
|
|
// See bufVoice: onended cannot fire if start() threw, so reclaim the slot inline.
|
|
try { osc.start(t); osc.stop(t + dur + 0.05); } catch (e) { void e; releaseVoice(v); }
|
|
// valve slap: the cutoff OPENING with danger is the anxiety
|
|
const nsrc = ctx.createBufferSource(); nsrc.buffer = NZ.brown; nsrc.loop = true;
|
|
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass';
|
|
lp.frequency.value = HEART.slapLo + HEART.slapSpan * D; lp.Q.value = 1.4;
|
|
const nv = adsr(t, peak * 0.55, 0.004, dur * 0.5);
|
|
nsrc.connect(lp); lp.connect(nv); nv.connect(B.heart.in);
|
|
nsrc.onended = () => { try { nsrc.disconnect(); lp.disconnect(); } catch (e) { void e; } give(nv, 'gain'); };
|
|
try { nsrc.start(t, rnd() * 3); nsrc.stop(t + dur * 0.5 + 0.05); } catch (e) { void e; }
|
|
}
|
|
function scheduleBeat(t) {
|
|
const period = 60 / curBpm;
|
|
// The S1->S2 gap compresses LESS than proportionally with tempo, exactly as a real heart does.
|
|
// A fixed fraction sounds like a sped-up tape; a fixed absolute gap blurs into one thump at 138.
|
|
const gap = Math.max(0.14, Math.min(0.36, 0.30 * period));
|
|
// THE ENGINE OWNS THE TEMPO; synth.js OWNS THE SOUND. One call carries both S1 and S2, plus
|
|
// the danger scalar — the synth does its own timbre colouring off `d` (valve click above 0.45,
|
|
// murmur above 0.60, waveshaper above 0.70, and the S2 collapse above 0.88, where the
|
|
// ventricle can no longer fill). Duplicating any of that here would double every layer.
|
|
let used = false;
|
|
const s = synthFor('heartbeat');
|
|
if (s && s.has('heartbeat')) {
|
|
try { used = !!s.play('heartbeat', { when: t, d: D, gap, gain: 1 }); } catch (e) { void e; }
|
|
}
|
|
if (!used) {
|
|
// Fallback only: a crude two-thump heart so the headline feature survives a missing synth.
|
|
const lubG = 0.55 * (0.55 + 0.45 * D);
|
|
thump(t, 62 + 9 * D, 38, lubG, 0.15);
|
|
if (D <= 0.88) thump(t + gap, 74, 46, lubG * 0.65, 0.108);
|
|
}
|
|
maybeGrain(t, period);
|
|
}
|
|
|
|
// ── The pump. THE ONLY setTimeout used for scheduling in this file. ─────────────────────────
|
|
const LOOKAHEAD = 0.200, TICK_MS = 25;
|
|
let pumpTimer = null;
|
|
function pump() {
|
|
pumpTimer = null;
|
|
if (disposed) return;
|
|
const now = ctx.currentTime;
|
|
|
|
// hazard:proximity has NO clear event — it is emitted every frame but ONLY while |gap| < 220,
|
|
// so SILENCE means "no surge nearby". Decay it here, in the pump, on the audio clock. It must
|
|
// NOT live in the player:state handler: player:state stops emitting the instant you die and
|
|
// stays stopped for the whole respawn window, so any decay driven by it freezes at its
|
|
// death-time value exactly when the sequencing matters.
|
|
if (now - sig.surgeStamp > 0.30) sig.surge *= HEART.surgeDecay; // ~250 ms half-life:
|
|
// tWarn is documented as 0.6 decaying to 0 over eta+1 SECONDS, so the per-tick step is the
|
|
// full height times the tick, over that span. The old constant (0.025) was the step for a
|
|
// height of 1.0 and reached zero in 0.6*(eta+1) s — 40% early, i.e. the hole in the mix closed
|
|
// before the hazard it is warning about arrived.
|
|
if (sig.warn > 0) sig.warn = Math.max(0, sig.warn - WARN_PEAK * (TICK_MS / 1000) / Math.max(sig.warnEta + 1, 0.5));
|
|
|
|
// Drain the warn accelerando's queued ducks (see pendingDucks). Scanned rather than shifted:
|
|
// two overlapping warnings can queue out of order.
|
|
for (let i = pendingDucks.length - 1; i >= 0; i--) {
|
|
if (pendingDucks[i].at <= now + 0.03) {
|
|
applyDucks(pendingDucks[i].name, Math.max(pendingDucks[i].at, now + 0.005));
|
|
pendingDucks.splice(i, 1);
|
|
}
|
|
}
|
|
// fast enough that outrunning the surge
|
|
// Asymmetric slew: ~0.25 s up, ~1.3 s down. Danger arrives fast and leaves slowly; the reverse
|
|
// feels twitchy and unearned. is felt as RELIEF, slow enough that a
|
|
const tgt = targetD(); // one-frame gap does not stutter tempo
|
|
D += (tgt - D) * (tgt > D ? HEART.dUp : HEART.dDn);
|
|
|
|
// The bed lowshelf dissolves D's baked heartbeat as danger rises, so the live heart can take
|
|
// the low register over instead of flamming against it. Also lift the live heart's own level.
|
|
const shelfDb = HEART.shelfDb * D;
|
|
bedShelf.gain.setTargetAtTime(shelfDb, now, 1.5);
|
|
if (now >= B.heart.until) {
|
|
const hb = HEART.lvlBase + HEART.lvlSpan * D;
|
|
B.heart.base = hb;
|
|
B.heart.duck.gain.setTargetAtTime(hb, now, 0.25);
|
|
}
|
|
|
|
if (beating && unlocked) {
|
|
if (nextBeat < now) nextBeat = now + 0.05;
|
|
while (nextBeat < now + LOOKAHEAD) {
|
|
scheduleBeat(nextBeat); // allocates its ~8 nodes HERE, not per frame
|
|
const period = 60 / bpmFromD(); // period re-read per beat, at the boundary
|
|
let nb = nextBeat + period;
|
|
// THE PHASE-LOCK the §9 argument is actually about, and which used to be missing:
|
|
// bedStartTime was written and never read, so the live heart free-ran against the 50 bpm
|
|
// heartbeat Lane D baked into bed-esophagus and drifted into the smeared double-thump that
|
|
// argument names — and the humanise below actively guaranteed the drift, in exactly the
|
|
// calm state where the flam is audible. So while a sampled bed is playing AND we are at
|
|
// rest, snap the next beat onto the bed's own grid instead of jittering off it.
|
|
if (bedPlaying && D < HEART.calm) {
|
|
const grid = 60 / HEART.bpmBase;
|
|
const snapped = bedStartTime + Math.round((nb - bedStartTime) / grid) * grid;
|
|
// The guard is what makes this loop terminate: never accept a snap that does not move
|
|
// the beat clearly forward, or the while() above can re-schedule the same instant.
|
|
if (snapped > nextBeat + period * 0.5) nb = snapped;
|
|
} else if (D < HEART.calm) {
|
|
// No bed to flam against: humanise. At high danger the beat should be mechanical.
|
|
nb = nextBeat + period * (1 + (rnd() - 0.5) * HEART.humanise);
|
|
}
|
|
nextBeat = nb;
|
|
}
|
|
}
|
|
pumpTimer = setTimeout(pump, TICK_MS);
|
|
}
|
|
|
|
// ═══ 10. UNLOCK ═════════════════════════════════════════════════════════════════════════════
|
|
// The context starts suspended and browsers keep it that way until a real user gesture. Until
|
|
// then cue() silently no-ops. Cues arriving before unlock are DROPPED, NOT QUEUED — queuing
|
|
// produces a machine-gun burst of thirty stacked cues the instant the player clicks, which is
|
|
// the worst possible first impression.
|
|
const UNLOCK_EVENTS = ['pointerdown', 'keydown', 'touchend'];
|
|
function onUnlock() {
|
|
removeUnlock();
|
|
if (disposed || unlocked) return;
|
|
unlocked = true;
|
|
try {
|
|
ctx.resume();
|
|
// iOS needs an actual source STARTED inside the gesture; resume() alone is not reliable.
|
|
const s = ctx.createBufferSource();
|
|
s.buffer = ctx.createBuffer(1, 1, SR);
|
|
s.connect(ctx.destination); s.start(0);
|
|
} catch (e) { void e; }
|
|
if (!carrierStarted) {
|
|
carrierStarted = true;
|
|
const t = t0();
|
|
carrierGain.gain.setValueAtTime(0, t);
|
|
carrierGain.gain.linearRampToValueAtTime(CARRIER_LVL * carrierMul, t + 0.6);
|
|
try { carrierA.start(t); carrierB.start(t); } catch (e) { void e; }
|
|
}
|
|
if (curBiome == null) setBiome('esophagus'); // sane default until player:state names one
|
|
nextBeat = ctx.currentTime + 0.15;
|
|
warmSamples();
|
|
}
|
|
function removeUnlock() {
|
|
for (const ev of UNLOCK_EVENTS) {
|
|
try { window.removeEventListener(ev, onUnlock, { capture: true }); } catch (e) { void e; }
|
|
}
|
|
}
|
|
for (const ev of UNLOCK_EVENTS) {
|
|
try { window.addEventListener(ev, onUnlock, { capture: true, passive: true }); } catch (e) { void e; }
|
|
}
|
|
function onVisible() {
|
|
if (disposed) return;
|
|
if (document.visibilityState === 'hidden') { try { ctx.suspend(); } catch (e) { void e; } return; }
|
|
if (ctx.state === 'suspended' && unlocked) { try { ctx.resume(); } catch (e) { void e; } }
|
|
// RE-ANCHOR. Without this the while-loop in pump() tries to schedule several thousand beats in
|
|
// the past in a single tick and hangs the main thread. Write the re-anchor before the loop.
|
|
nextBeat = ctx.currentTime + 0.1;
|
|
}
|
|
try { document.addEventListener('visibilitychange', onVisible); } catch (e) { void e; }
|
|
|
|
// ═══ 11. BUS SUBSCRIPTIONS ══════════════════════════════════════════════════════════════════
|
|
// Verified against the emitters: every field below was read out of the file that emits it.
|
|
|
|
offs.push(bus.on('audio:cue', (e) => { if (e && e.name) cue(e.name); }));
|
|
|
|
// player:state — 60 Hz. R4: scalars only, no allocation, no node work. Also the biome edge.
|
|
offs.push(bus.on('player:state', (p) => {
|
|
if (!p) return;
|
|
const cm = p.coatMax, hm = p.hullMax;
|
|
const r = cm ? p.coat / cm : 1;
|
|
// 0.25 is comms.js's exact "coat low" threshold — the heart and Voss agree on the same edge.
|
|
sig.coatT = r < 0.25 ? Math.max(0.55 * (1 - r), 0.55) : 0.55 * (1 - r);
|
|
sig.hullT = hm ? Math.pow(1 - p.hull / hm, 1.5) : 0;
|
|
sig.iframes = !!p.iframes;
|
|
if (p.biome && p.biome !== curBiome && !fading) setBiome(p.biome); // ONE string compare/frame
|
|
else if (p.biome && p.biome !== curBiome) pendingBiome = p.biome;
|
|
}));
|
|
|
|
// combat:state — 60 Hz. `enemies` is the live enemy count (verified: combat/index.js:80).
|
|
offs.push(bus.on('combat:state', (c) => {
|
|
if (!c) return;
|
|
sig.heat = c.heatMax ? c.heat / c.heatMax : 0;
|
|
const wasOver = sig.overheated;
|
|
sig.overheated = c.overheated ? 1 : 0;
|
|
sig.foes = c.enemies || 0;
|
|
// There is no `overheat_clear` cue, so the falling edge is tracked here: the lockout needs an
|
|
// audible END or the player never learns they can shoot again. Rising tick = Law A.
|
|
if (wasOver && !sig.overheated && unlocked) {
|
|
const t = t0();
|
|
// synth.js sustains the overheat steam until we call this — it cannot see combat:state, so
|
|
// the falling edge is ours to detect and hand over. Without it the lockout never releases.
|
|
if (trySynth('overheat_clear', t, 1, 0, null)) return;
|
|
const o = ctx.createOscillator(); o.type = 'sine';
|
|
o.frequency.setValueAtTime(1600, t); o.frequency.exponentialRampToValueAtTime(2200, t + 0.008);
|
|
const v = adsr(t, 0.12, 0.002, 0.090);
|
|
o.connect(v); v.connect(B.warn.in);
|
|
o.onended = () => { try { o.disconnect(); } catch (e) { void e; } give(v, 'gain'); };
|
|
try { o.start(t); o.stop(t + 0.15); } catch (e) { void e; }
|
|
}
|
|
}));
|
|
|
|
// hazard:proximity — the rear channel's whole tension, in one signed number. distance > 0 means
|
|
// you are AHEAD of the surge; catchRadius 3.0, proximityRange 220 (combat/balance.js:69-70).
|
|
offs.push(bus.on('hazard:proximity', (h) => {
|
|
if (!h) return;
|
|
const d = h.distance;
|
|
// NEGATIVE distance is handled explicitly rather than left to fall through the `<= 3` arm.
|
|
// The emitter guards only |gap| < 220, so d in [-220, 3] reaches us: the surge is PAST you.
|
|
// That is maximum danger and 1 is the right answer — but it must be stated, not inherited from
|
|
// a comparison that happens to be true, or the next person to re-sign this number breaks it.
|
|
const p = d < 0 ? 1 : d <= 3 ? 1 : 1 - Math.min((d - 3) / 217, 1);
|
|
const stalled = !!h.stalled;
|
|
sig.surge = stalled ? p * 0.35 : p; // danger drops while an antacid stall holds it
|
|
sig.surgeStamp = ctx.currentTime;
|
|
|
|
// THE REAR CHANNEL. synth.js runs the chase loop from surge_start to surge_end but cannot see
|
|
// the bus, so without this the loop sits at a flat base level for the whole chase and carries
|
|
// no proximity information at all — the one continuous tension signal in the game, built and
|
|
// unhooked. Throttled on CHANGE, not per frame: synth.js ramps with setTargetAtTime, so a 2%
|
|
// step is inaudible, and this handler is a 60 Hz one (R4 — trySynth writes shared playOpts and
|
|
// allocates nothing). RAW proximity, not the stalled-scaled danger: stalling has its own
|
|
// gesture below, and colouring the loop by it twice thins it twice.
|
|
if (chaseP < 0 || Math.abs(p - chaseP) > 0.02) {
|
|
chaseP = p;
|
|
trySynth('surge_chase', t0(), p, 0, null);
|
|
}
|
|
// The FALLING edge of `stalled` exists nowhere else on the bus — hazards.js emits surge_stall
|
|
// but has no un-stall cue. Discarding it left the chase thinned to 0.35x behind a 6 kHz
|
|
// highpass until surge_end, i.e. the mix insisting the acid was still neutralised while it was
|
|
// actively chasing again. That is a lie the player acts on.
|
|
if (stalled !== surgeStalled) {
|
|
surgeStalled = stalled;
|
|
if (!stalled) trySynth('surge_unstall', t0(), 1, 0, null);
|
|
}
|
|
}));
|
|
|
|
// hazard:warn is emitted IMMEDIATELY BEFORE the warn_* audio:cue (hazards.js:108-109), so
|
|
// latching eta here is what lets the cue schedule its 3-pulse accelerando. Without this the
|
|
// warning would be a single beep and the acceleration — the actual information — would be lost.
|
|
offs.push(bus.on('hazard:warn', (h) => {
|
|
if (!h) return;
|
|
sig.warnEta = h.eta || 2.5;
|
|
sig.warn = 0.6;
|
|
}));
|
|
|
|
// combo {n} feeds the enemy_die chromatic ladder and the small HEAT term. {n:0} on window expiry.
|
|
offs.push(bus.on('combo', (e) => { comboN = (e && e.n) || 0; }));
|
|
|
|
// ── The three non-audio:cue subscriptions, each with a reason. Kept deliberately short. ──────
|
|
// 1. player:damage — the ONLY source of the damage AMOUNT. wall_scrape's cue carries no
|
|
// magnitude, and the scrape is a continuous voice whose level must track how hard you are
|
|
// grinding. `kind` here is the damage SOURCE ('acid'/'gate'/'dart'/'contact'/'aura'/
|
|
// 'squeeze'/wall kind) and NEVER 'hull_hit' — the coat-vs-hull truth lives on the sibling
|
|
// audio:cue at player.js:63, which is what this engine severity-grades off.
|
|
// Do NOT try to identify a scrape by matching `kind` against a guessed string: the wall path
|
|
// passes the COLLISION's own kind (player.js:221 `damage(..., hit.kind)`), whose vocabulary
|
|
// this lane cannot see. player.js:221-223 calls damage() and THEN emits the wall_scrape cue in
|
|
// the same frame, so latching the amount here unconditionally and letting the cue consume it
|
|
// is exact rather than a guess.
|
|
offs.push(bus.on('player:damage', (e) => { if (e) lastDamageAmount = e.amount || 1; }));
|
|
// 2. player:death — MUST be subscribed here rather than on audio:cue{death}: the hull-depletion
|
|
// path (player.js:64) emits player:death with NO audio cue, while kill() (player.js:78) emits
|
|
// BOTH. Listening only to the cue misses half of all deaths; listening to both without a latch
|
|
// fires lethal-hazard deaths twice. player:death + a 200 ms dedupe latch is the only correct
|
|
// subscription in the tree.
|
|
offs.push(bus.on('player:death', () => {
|
|
const t = ctx.currentTime;
|
|
if (dying && t - lastDeathT < 0.2) return;
|
|
lastDeathT = t; dying = true;
|
|
death();
|
|
}));
|
|
// 3. player:spawn — the ONLY signal that a respawn completed. boot.js sets run.respawnT = 2.0
|
|
// today but THAT IS NOT A CONTRACT; the window is bracketed by death -> spawn, never a
|
|
// hardcoded 2.0. Also the only way back from the feed-drop, since player:state is silent
|
|
// throughout it.
|
|
offs.push(bus.on('player:spawn', () => { reacquire(); }));
|
|
// pickup {kind,s,score,sample} — the SOUND already arrives on audio:cue, so this listener makes
|
|
// no noise of its own. It is here for `sample`, the authoritative biopsy count: counting cues
|
|
// instead would over-count, because pickup_sample can be voice-stolen or rate-limited and the
|
|
// third-of-three flourish must fire on the third SAMPLE, not the third audible cue.
|
|
offs.push(bus.on('pickup', (e) => { if (e && e.sample) sampleCount += e.sample; }));
|
|
|
|
offs.push(bus.on('level:complete', () => {
|
|
// One final beat, then stop. The bed fades over 2 s and the pathology-report card is left
|
|
// sitting in near-silence with only the carrier. That silence is the fiction landing.
|
|
beating = false;
|
|
const t = t0();
|
|
pendingDucks.length = 0;
|
|
// The feed-drop static outlives the death sequence by design — but not past the end of the
|
|
// level. Left up, it hisses under the pathology card, which is the opposite of the near-silence
|
|
// this handler exists to produce.
|
|
stopStatic(t, 1.0);
|
|
for (const slot of [bedSlotA, bedSlotB]) {
|
|
if (!slot) continue;
|
|
slot.vca.gain.cancelScheduledValues(t);
|
|
slot.vca.gain.setValueAtTime(slot.vca.gain.value, t);
|
|
slot.vca.gain.linearRampToValueAtTime(0, t + 2.0);
|
|
}
|
|
for (const g of droneGraphs.values()) {
|
|
g.out.gain.cancelScheduledValues(t);
|
|
g.out.gain.setValueAtTime(g.out.gain.value, t);
|
|
g.out.gain.linearRampToValueAtTime(0, t + 2.0);
|
|
}
|
|
}));
|
|
|
|
// ── DEATH: a global state change, not a cue. ─────────────────────────────────────────────────
|
|
// Everybody's instinct is to FILL this. Don't. A feed that drops does not make a sound — that is
|
|
// what dropping means. 180 ms of total silence is the loudest thing in the game, and the carrier
|
|
// detuning afterwards says "instrument failing" diegetically, where a flatline tone would be a
|
|
// hospital cliché reaching outside the fiction.
|
|
// The feed-static loop has no natural end — it is a loop:true source with no stop() — so every
|
|
// path that leaves the death state has to take it down. Factored out because there are three:
|
|
// reacquire, level:complete, and a second death() before a spawn (the 200 ms latch does not
|
|
// cover a hull-depletion death arriving minutes after a kill()), which used to overwrite the
|
|
// reference at the bottom of death() and leave the previous source hissing forever.
|
|
function stopStatic(t, fade = 0.300) {
|
|
if (!staticVoice) return;
|
|
const s = staticVoice; staticVoice = null;
|
|
try {
|
|
s.vca.gain.cancelScheduledValues(t);
|
|
s.vca.gain.setValueAtTime(s.vca.gain.value, t);
|
|
s.vca.gain.linearRampToValueAtTime(0, t + fade);
|
|
s.src.stop(t + fade + 0.05);
|
|
s.src.onended = () => { try { s.src.disconnect(); s.bp.disconnect(); s.vca.disconnect(); } catch (e) { void e; } };
|
|
} catch (e) { void e; }
|
|
}
|
|
|
|
function death() {
|
|
if (!unlocked) return;
|
|
const t = t0();
|
|
beating = false;
|
|
stopStatic(t, 0.050); // a second death before a spawn must not stack a second loop
|
|
pendingDucks.length = 0; // queued warn pulses belong to the run that just ended
|
|
master.gain.cancelScheduledValues(t);
|
|
master.gain.setValueAtTime(master.gain.value, t);
|
|
master.gain.linearRampToValueAtTime(0, t + 0.040);
|
|
for (const v of [...voices]) {
|
|
try {
|
|
v.vca.gain.cancelScheduledValues(t);
|
|
v.vca.gain.setValueAtTime(Math.max(v.vca.gain.value, 1e-4), t);
|
|
v.vca.gain.exponentialRampToValueAtTime(1e-4, t + 0.008);
|
|
v.src.stop(t + 0.05);
|
|
} catch (e) { void e; }
|
|
}
|
|
// 0.180 — the feed comes back carrying nothing but a detuned, gated carrier.
|
|
const t2 = t + 0.180;
|
|
master.gain.setValueAtTime(baseMaster(), t2);
|
|
const f = carrierA.frequency;
|
|
f.cancelScheduledValues(t2); f.setValueAtTime(CARRIER_HZ, t2);
|
|
f.linearRampToValueAtTime(2100, t2 + 0.120);
|
|
const cg = carrierGain.gain;
|
|
cg.cancelScheduledValues(t2); cg.setValueAtTime(cg.value, t2);
|
|
cg.linearRampToValueAtTime(CARRIER_DEAD * carrierMul, t2 + 0.05);
|
|
// 0.350 — feed static, stepped from a FIXED table. Deterministic, house style, reproducible.
|
|
const STATIC = [.30, .05, .22, .02, .35, .10, .04, .28, .06, .18, .03, .12];
|
|
const st = t + 0.350;
|
|
const n = ctx.createBufferSource(); n.buffer = NZ.white; n.loop = true;
|
|
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 3400; bp.Q.value = 2;
|
|
const sv = ctx.createGain(); sv.gain.setValueAtTime(0, st);
|
|
for (let i = 0; i < STATIC.length; i++) sv.gain.setValueAtTime(STATIC[i] * 0.22, st + i * 0.04);
|
|
n.connect(bp); bp.connect(sv); sv.connect(preMaster);
|
|
try { n.start(st, rnd()); } catch (e) { void e; }
|
|
staticVoice = { src: n, vca: sv, bp };
|
|
// 0.900 — the body's last thump, heard from far away (fully wet).
|
|
const dt = t + 0.900;
|
|
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = 55;
|
|
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 200; lp.Q.value = 0.7;
|
|
const dv = adsr(dt, 0.30, 0.040, 1.40);
|
|
o.connect(lp); lp.connect(dv); dv.connect(cavitySend); dv.connect(B.drone.in);
|
|
o.onended = () => { try { o.disconnect(); lp.disconnect(); } catch (e) { void e; } give(dv, 'gain'); };
|
|
try { o.start(dt); o.stop(dt + 1.6); } catch (e) { void e; }
|
|
}
|
|
|
|
function reacquire() {
|
|
dying = false;
|
|
if (!unlocked) return;
|
|
const t = t0();
|
|
const f = carrierA.frequency;
|
|
f.cancelScheduledValues(t); f.setValueAtTime(f.value, t);
|
|
f.linearRampToValueAtTime(CARRIER_HZ, t + 0.400);
|
|
const cg = carrierGain.gain;
|
|
cg.cancelScheduledValues(t); cg.setValueAtTime(cg.value, t);
|
|
cg.linearRampToValueAtTime(CARRIER_LVL * carrierMul, t + 0.400);
|
|
stopStatic(t, 0.300);
|
|
master.gain.cancelScheduledValues(t);
|
|
master.gain.setValueAtTime(master.gain.value, t);
|
|
master.gain.setTargetAtTime(baseMaster(), t, 0.25);
|
|
for (const k of ['bed', 'drone', 'sfx', 'heart', 'warn']) {
|
|
const s = B[k];
|
|
s.floor = s.base; s.until = 0;
|
|
s.duck.gain.cancelScheduledValues(t);
|
|
s.duck.gain.setValueAtTime(s.duck.gain.value, t);
|
|
s.duck.gain.setTargetAtTime(s.base, t, 0.25);
|
|
}
|
|
// D = 0 HARD, no smoothing — the heart restarts at exactly 50.000 bpm on the next boundary.
|
|
D = 0; curBpm = 50;
|
|
// Clear every THREAT term too. Leaving them latched would have the pump re-inflate D on its
|
|
// very next tick from a signal that describes the run you just died in, not the new one.
|
|
// sig.foes / sig.overheated are deliberately NOT cleared: combat:state re-states them next
|
|
// frame, and a respawn into a live fight should sound like one.
|
|
sig.surge = 0; sig.surgeStamp = -99; sig.warn = 0; sig.warnEta = 0; sig.hullT = 0; sig.coatT = 0;
|
|
comboN = 0;
|
|
// Same reasoning for the chase: forget the proximity and the stall we last handed the synth, so
|
|
// the first hazard:proximity of the new life is treated as a change and actually gets sent.
|
|
chaseP = -1; surgeStalled = false;
|
|
pendingDucks.length = 0;
|
|
beating = true;
|
|
nextBeat = ctx.currentTime + 0.15;
|
|
// The scanner locking back on: a filter sweep and three ticks.
|
|
const n = ctx.createBufferSource(); n.buffer = NZ.pink; n.loop = true;
|
|
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(200, t + 0.1);
|
|
lp.frequency.exponentialRampToValueAtTime(4000, t + 0.5); lp.Q.value = 0.8;
|
|
const v = adsr(t + 0.1, 0.18, 0.020, 0.400);
|
|
n.connect(lp); lp.connect(v); v.connect(B.sfx.in);
|
|
n.onended = () => { try { n.disconnect(); lp.disconnect(); } catch (e) { void e; } give(v, 'gain'); };
|
|
try { n.start(t + 0.1, rnd()); n.stop(t + 0.6); } catch (e) { void e; }
|
|
for (let i = 0; i < 3; i++) {
|
|
const tt = t + 0.1 + i * 0.09;
|
|
const tn = ctx.createBufferSource(); tn.buffer = NZ.white; tn.loop = true;
|
|
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2400; bp.Q.value = 6;
|
|
const tv = adsr(tt, 0.10, 0.001, 0.018);
|
|
tn.connect(bp); bp.connect(tv); tv.connect(B.sfx.in);
|
|
tn.onended = () => { try { tn.disconnect(); bp.disconnect(); } catch (e) { void e; } give(tv, 'gain'); };
|
|
try { tn.start(tt, rnd()); tn.stop(tt + 0.05); } catch (e) { void e; }
|
|
}
|
|
// feed re-acquired
|
|
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = 1046.5;
|
|
const ov = adsr(t + 0.5, 0.16, 0.006, 0.140);
|
|
o.connect(ov); ov.connect(B.sfx.in);
|
|
o.onended = () => { try { o.disconnect(); } catch (e) { void e; } give(ov, 'gain'); };
|
|
try { o.start(t + 0.5); o.stop(t + 0.75); } catch (e) { void e; }
|
|
}
|
|
|
|
pumpTimer = setTimeout(pump, TICK_MS);
|
|
|
|
// ═══ 12. PUBLIC HANDLE ══════════════════════════════════════════════════════════════════════
|
|
return {
|
|
// update(dt) exists for factory-signature parity and is INTENTIONALLY not the clock. The
|
|
// heartbeat runs on the setTimeout pump against ctx.currentTime because player:state — the
|
|
// only per-frame signal Lane E receives — STOPS EMITTING the instant you die and stays stopped
|
|
// for the whole respawn window, which is exactly the interval the death/re-acquire sequence
|
|
// has to sequence through. Nothing in boot.js calls this today; that is fine and expected.
|
|
update() {},
|
|
|
|
cue, // for the ?fakebus=1 harness
|
|
setVolume(v) {
|
|
userVol = clamp01(v);
|
|
const t = t0();
|
|
master.gain.cancelScheduledValues(t);
|
|
master.gain.setValueAtTime(master.gain.value, t);
|
|
master.gain.linearRampToValueAtTime(baseMaster(), t + 0.05); // R1: never a raw assignment
|
|
persist();
|
|
},
|
|
setMuted(m) {
|
|
userMuted = !!m;
|
|
const t = t0();
|
|
master.gain.cancelScheduledValues(t);
|
|
master.gain.setValueAtTime(master.gain.value, t);
|
|
master.gain.linearRampToValueAtTime(baseMaster(), t + 0.05);
|
|
persist();
|
|
},
|
|
get volume() { return userVol; },
|
|
get muted() { return userMuted; },
|
|
get danger() { return D; }, // ?dbg=1 readout
|
|
get bpm() { return curBpm; },
|
|
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true; // FIRST, so an in-flight pump cannot reschedule
|
|
for (const off of offs) off();
|
|
if (pumpTimer) clearTimeout(pumpTimer);
|
|
if (fadeTimer) clearTimeout(fadeTimer);
|
|
if (irTimer) clearTimeout(irTimer);
|
|
if (storeTimer) { clearTimeout(storeTimer); storeTimer = null; }
|
|
removeUnlock();
|
|
try { document.removeEventListener('visibilitychange', onVisible); } catch (e) { void e; }
|
|
const t = ctx.currentTime;
|
|
pendingDucks.length = 0;
|
|
// 'heart' included: it is a fourth createSynth instance with its own permanent nodes and its
|
|
// own timers array, and leaving it out left those timers running past teardown.
|
|
for (const k of ['sfx', 'warn', 'comms', 'heart']) { try { synths[k] && synths[k].dispose(); } catch (e) { void e; } }
|
|
// Ramp everything out over 8 ms rather than disconnecting: NEVER disconnect a sounding node.
|
|
for (const v of [...voices]) {
|
|
try {
|
|
v.vca.gain.cancelScheduledValues(t);
|
|
v.vca.gain.setValueAtTime(Math.max(v.vca.gain.value, 1e-4), t);
|
|
v.vca.gain.exponentialRampToValueAtTime(1e-4, t + 0.008);
|
|
v.src.stop(t + 0.01);
|
|
} catch (e) { void e; }
|
|
}
|
|
voices.length = 0;
|
|
try { master.gain.cancelScheduledValues(t); master.gain.setValueAtTime(master.gain.value, t); master.gain.linearRampToValueAtTime(0, t + 0.02); } catch (e) { void e; }
|
|
if (scrapeSrc) { try { scrapeSrc.stop(t + 0.02); } catch (e) { void e; } }
|
|
if (staticVoice) { try { staticVoice.src.stop(t + 0.02); } catch (e) { void e; } staticVoice = null; }
|
|
if (carrierStarted) { try { carrierA.stop(t + 0.02); carrierB.stop(t + 0.02); } catch (e) { void e; } }
|
|
for (const slot of [bedSlotA, bedSlotB]) if (slot) { try { slot.src.stop(t + 0.02); } catch (e) { void e; } }
|
|
bedSlotA = bedSlotB = null;
|
|
for (const g of droneGraphs.values()) {
|
|
for (const n of g.nodes) { try { if (n.stop) n.stop(t + 0.02); } catch (e) { void e; } }
|
|
}
|
|
droneGraphs.clear();
|
|
pool.gain.length = 0; pool.panner.length = 0;
|
|
sampleCache.clear(); sampleLoading.clear();
|
|
// close() ONLY after the fades land — closing immediately gives you a 20 ms burst of clipped
|
|
// noise on every level transition.
|
|
setTimeout(() => { try { ctx.close(); } catch (e) { void e; } }, 60);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── §BANNED — the audio equivalent of the red vignette ───────────────────────────────────────
|
|
// ART_BIBLE bans red screen-edges on damage. The exact audio equivalents are banned here by the
|
|
// same law, for the same reason, and nothing in this file does any of them:
|
|
// 1. No sustained health-linked alarm tone or rising siren. A persistent band of danger colour
|
|
// pasted over the feed is MORE offensive in audio than in vision, because you cannot look away.
|
|
// 2. No lowpassing the mix as health drops — the "concussed" filter every shooter shipped in
|
|
// 2010. It is seductive, atmospheric, and it removes information at the precise moment the
|
|
// player needs the most.
|
|
// 3. No health-linked rumble drone.
|
|
// Damage is communicated by ADDING, never by subtracting bandwidth from the whole mix: the heart,
|
|
// the valve click, the murmur, the carrier stutter, the static. All transient, all diegetic, all
|
|
// happening to the INSTRUMENT rather than to the mood. The only full-mix subtractions in here are
|
|
// the 40-70 ms master ducks on hull_hit/gate_hit (impacts, not states) and death.
|