// 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