Audit items 4 and 5. Both are shell behaviour a finished game is expected to have and this one
never did.
THE RUN NOW STARTS WHEN YOU START IT. cards.js has emitted `ui:start` on title dismissal since
round 1 and its own comment says nothing consumes it — so the simulation ran behind the title
card. Measured: 1.2 s of sitting on the title moved the ship and burned 1.2 s off the par clock
before the player had touched anything. L1's first checkpoint (s=20) and the opening comms line
both fired while the title was still up, and a hazard could hurt you before you started, which is
why cards.js already carries a player:damage auto-dismiss as a safety valve. Now `started` gates
step(); verified s stays at 2 across a second of real frames.
PAUSE ON BLUR. The ui:pause consumer has worked since round 2 and nothing but the pause key ever
produced for it. Alt-tab mid-fight and you came back to a dead ship. blur + visibilitychange now
produce it.
AUDIO HEARS THE PAUSE. engine.js handled eleven bus events and not that one, so the bed, drone,
grain scheduler and heartbeat ran at full level behind the pause card, and the heart slewed to
its resting 50 bpm while you read and snapped back on resume. Ducks the master rather than
suspending the context — the heartbeat's pump schedules against ctx.currentTime and would wake
up in the past.
FAILURE HAS A FACE. Every failure mode in this game presented as the same black rectangle, so a
player could not tell "loading" from "crashed" and had nothing to report:
- loadLevel did `return mod.getLevel(id)` without awaiting, so a rejection escaped its own
try/catch and killed module evaluation. A mistyped ?lvl= now falls back to the stub tube
(which names itself STUB ESOPHAGUS) instead of a dead page.
- frame() re-armed its rAF at the BOTTOM, so one throw anywhere stopped the loop forever while
the canvas kept showing the last good frame. Body is guarded; the loop always re-arms.
- bus.emit ran listeners bare, so a throw in one subscriber aborted the rest of the emit — and
player:state is emitted every frame from inside step(), so one bad frame in the HUD took
combat and audio down with it. Listeners are isolated now.
- webglcontextlost had no handler at all: a laptop sleeping left a white page with a floating
HUD.
All four now land on a FEED LOST card naming the cause, in the game's own voice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1763 lines
102 KiB
JavaScript
1763 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.
|
|
|
|
// PAUSE. The engine handled eleven bus events and not this one, so the bed, the drone, the
|
|
// grain scheduler and the heartbeat all ran at full level behind the pause card — and the
|
|
// heart slewed down to its resting 50 bpm while you read, then snapped back on resume.
|
|
// Ducking the master (rather than ctx.suspend) keeps ctx.currentTime advancing, which the
|
|
// heartbeat's setTimeout pump schedules against and would otherwise wake up in the past.
|
|
let pauseDuck = false;
|
|
offs.push(bus.on('ui:pause', (e) => {
|
|
const p = !!e?.paused;
|
|
if (p === pauseDuck) return;
|
|
pauseDuck = p;
|
|
const t = t0();
|
|
master.gain.cancelScheduledValues(t);
|
|
master.gain.setValueAtTime(master.gain.value, t);
|
|
master.gain.linearRampToValueAtTime(p ? 0.0001 : baseMaster(), t + 0.18);
|
|
}));
|
|
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.
|