From 464f5c8a185026b513ac92e74449baedb863eb65 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 13:57:38 +1000 Subject: [PATCH] [lane E] The game finds its voice: procedural synth bank, the WebAudio graph, the damage feed, and the medal/title/pause cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 Lane E audio + the two remaining UI surfaces, consolidated from the JING5 clone onto main and made determinism-clean: - audio/synth.js — procedural voice bank (primary path; audible with an empty manifest) - audio/engine.js — WebAudio graph, cue router, bed, heartbeat; now the listener for the 11 gameplay bus events that were previously firing into the void - ui/feedback.js — feed-corruption damage overlay (an ART_BIBLE law previously unmet) - ui/cards.js — title / medal / pause cards; boot now honours ui:pause - boot.js — mounts all five modules with per-module failure isolation and a frame() tick (never step(), so stepped sims stay deterministic) Determinism gate: threaded engine's seeded rnd (mulberry32 off ?seed=) into createSynth, replacing every Math.random in synth.js. Audio texture is now reproducible per-seed and qa.sh is green. Verified: QA green; runtime smoke on ?seed=7 scheduled 182 osc + 91 buffer voices + 268 envelope ramps during play, zero console errors. Co-Authored-By: Claude Opus 4.8 --- web/js/audio/engine.js | 1747 ++++++++++++++++++++++++++++++++++++++ web/js/audio/synth.js | 1819 ++++++++++++++++++++++++++++++++++++++++ web/js/boot.js | 40 +- web/js/ui/cards.js | 742 ++++++++++++++++ web/js/ui/feedback.js | 708 ++++++++++++++++ 5 files changed, 5052 insertions(+), 4 deletions(-) create mode 100644 web/js/audio/engine.js create mode 100644 web/js/audio/synth.js create mode 100644 web/js/ui/cards.js create mode 100644 web/js/ui/feedback.js diff --git a/web/js/audio/engine.js b/web/js/audio/engine.js new file mode 100644 index 0000000..c523c21 --- /dev/null +++ b/web/js/audio/engine.js @@ -0,0 +1,1747 @@ +// 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