You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sub D: Gain effect — end-to-end proof + public API shape
Parent: #1 Blocks: Sub E Blocked by: Sub C (merged via PR #12)
1. TL;DR
Ship the first real effect — Gain — end-to-end through every layer: Rust kernel (with 1-pole exponential smoothing), WASM exports, worklet dispatch, @denaudio/effects public class that extends AudioWorkletNode and exposes a real AudioParam, Tier1 Rust unit tests, Tier2 golden null vs scipy reference, Tier3a Playwright+OAC, Tier3b catalog page on Vercel preview. This issue also refactors the transitional Sub B/C passthrough plumbing into the canonical effect shape that every future effect will reuse. On merge, a user can npm install @denaudio/effects, await Gain.register(ctx); const g = new Gain(ctx, { gain: 0.5 }); source.connect(g).connect(ctx.destination); g.gain.setValueAtTime(1.0, t) and hear the textbook behavior, matching scipy to -96 dBFS.
2. Context & Motivation
Gain is the simplest effect conceivable and therefore the most honest test of the pipeline (Parent D20). If Gain works end-to-end with all four test tiers green, every future effect can follow the exact same pattern by swapping only the Rust kernel + scipy reference. If Gain fails any tier, the pipeline is wrong — not the effect. Sub E takes Gain's actual implementation and extracts it into a reusable issue template.
This issue is also where the public API shape becomes canonical. Every @denaudio/effects export from here on follows the template established by Gain.
Related:
Parent decisions D10, D11, D12, D13, D20
Sub B's Passthrough class is the transitional shape; this issue refines it
3. Decisions Made (and Roads Not Taken)
Decision
Chosen
Rejected
Reason
Gain.gain units
linear multiplier, default 1.0, min 0.0, max 10.0
dB (needs unit handling), [0,1] unit interval
Matches native GainNode. Users wanting dB do 10**(db/20) themselves or use a future GainDb wrapper. max=10 is generous headroom without inviting nonsense.
AudioParam rate
a-rate (sample-accurate)
k-rate
Supports all AudioParam automation methods faithfully. Cost is negligible for gain.
Smoothing time
20 ms tau (1-pole, Parent D13)
no smoothing, 5 ms, 100 ms, configurable
20 ms is the Web Audio convention (native GainNode uses ~10 ms but is unspecified); 20 ms fully eliminates zipper at the cost of imperceptible delay on step changes.
Smoothing location
inside the WASM kernel, per-sample, state in WASM
TS pre-filter, worklet-side smoothing
Keeps main loop tight; no JS ↔ WASM crossing per-sample. State size is trivial (3 × f32).
API shape: class vs wrapper
Gain extends AudioWorkletNode (native feel)
wrapper object with .node
User code: source.connect(gain) works. AudioParam automation just works. Done.
Construction
sync new Gain(ctx, opts) after await Gain.register(ctx)
async factory only
Web Audio idiom is sync construction; async only for resource loading. Register caches bytes; construction uses cached bytes synchronously.
register behavior
idempotent per context; stores wasm bytes on a hidden Symbol-keyed slot on the context
re-register each new Gain(ctx)
Users create many nodes per context; register is a one-time cost.
Rust state layout
#[repr(C)] struct per effect, JS pre-allocates via den_<effect>_size() + den_alloc()
untyped byte blob
Explicit ABI; size query lets JS allocate the correct amount without hardcoding.
Unit tests
native Rust #[cfg(test)] in crates/den-core/src/effects/gain.rs running on host target (no WASM)
WASM-only tests
Standard Rust tests run faster than WASM-based Node tests; kernel math is the same on any arch.
Initial smoothed state
smoothed initialized to options.gain (or 1.0 default) via the __denInitialGain processorOption, which the worklet forwards into den_gain_init(state, sr, initial)
smoothed always initialized to 1.0 (the original §6.1 sketch)
Reversed after Tier3b feedback: the always-1.0 init produced an audible 100 ms decay from unity-volume to silence whenever new Gain(ctx, { gain: 0 }) was constructed and immediately played — the exact opposite of user intent ("I asked for silence, why is there sound?"). Initializing the smoother to the constructor's gain option means the FIRST sample is already at the user-requested gain. Subsequent automation (setValueAtTime, linearRampToValueAtTime, etc.) still smooths via the kernel's per-sample 1-pole as before. Verified: (a) Tier1 tests still pass with init=1.0 (back-compat path stays meaningful for the back-end), (b) Tier2 unchanged (scipy reference uses init=1.0 and the JS test passes 1.0 too), (c) Tier3a Gain spec uses new Gain(ctx) then setValueAtTime(target, 0) to exercise the SUBSEQUENT-automation transient, which is still 1.0 → target.
Refactored worklet API
Sub D replaces Sub B's registerDenWorklet → Promise<WasmReady> with Promise<void> + separate sync getCachedWasmBytes(ctx)
Keep Sub B's transitional shape
Cleaner ergonomics: effect classes need only new Effect(ctx, opts) after await Effect.register(ctx); the cache lookup is opaque. Sub B's exported WasmReady interface is removed. Underlying Symbol cache key (den.worklet.cache) is unchanged so the transition is data-compatible. Cache uses Symbol.for("den.worklet.cache") as a hidden property on BaseAudioContext — assumes the host does not freeze BaseAudioContext (true in all 2026 browsers; Web Audio spec does not mandate Object.freeze on context objects).
Node disposal
Gain.dispose() posts a {__denCmd: "destroy"} message; processor handles it by calling den_dealloc on state + param-scratch + I/O buffers, then returns false from the next process() to terminate
accept the leak (Sub B's stance), use FinalizationRegistry
Sub B accepted "few-KB leak on disposal" because no clean API existed. Sub D adds explicit dispose() so dynamic UIs (per-track effect chains) can free state. FinalizationRegistry is unreliable timing for audio. Returning false from process() is the spec-defined keep-alive termination.
process() return value
return true (keep alive) by default; return false ONLY after a destroy message has been processed and dealloc completed
always true
Per W3C: returning false permits the user agent to GC the processor. Necessary for dispose() semantics. Future effects with finite playback (e.g., a one-shot trigger) may use the same convention; document in Sub E template.
FunDSP dependency
defer — Gain is too trivial to pull a crate for
add fundsp now
Keep core dep-free until a real reason arises (a later effect may pull it in behind a feature flag).
Passthrough.dispose()
also gets dispose() in Sub D — same shape as Gain (port {__denCmd:"destroy"}, processor frees state + I/O, returns false)
Passthrough is @internal / test-only, so leave it without dispose()
The whole point of refactoring Passthrough alongside Gain is to land a single canonical effect shape that Sub E can codify into one template. Splitting "real" effects (with dispose) from "test-infra" effects (without) creates a parallel code path the template would have to fork on. One dispose() implementation, one port.onmessage handler in the processor.
GainOptions shape
GainOptions = { gain?: number } — does NOT extend RegisterOptions
interface GainOptions extends RegisterOptions
RegisterOptions carries wasmUrl / workletUrl which are only meaningful to the async Gain.register() phase. Allowing them on new Gain(ctx, opts) is a footgun: TypeScript accepts them, runtime silently ignores them. Splitting types makes the two phases obvious. The original §6.4 sketch's extends was a copy-paste error; this row makes the correction authoritative.
createDenNode (Sub B)
remove entirely in Sub D — both function and WasmReady interface
keep with @deprecated for back-compat
After Passthrough is refactored to the sync-construct shape, no consumer remains. We are pre-1.0 (no published versions), so back-compat carries zero cost. Smaller public API surface = less to document, less to support.
Catalog page common code (superseded — see "Catalog page architecture" below)
extract mountEffectPage() helper
Inline-copy the boilerplate
Two near-identical copies make Sub E's template harder to write
User-file picker — separate widget (superseded — folded into the source picker, see below)
mountFilePicker() widget + extend mountABPlayer with optional getUserFile
—
—
Catalog page architecture
Shared renderEffectPage helper in packages/examples/src/lib/effect-page.ts codifies the canonical effect shape: HTML skeleton, source picker (CANONICAL signals + "Custom file…" option), per-effect param sliders, Play/Stop, Bypass toggle, AnalyserNode + requestAnimationFrame waveform + spectrum, AbortSignal teardown, file decode (50 MB pre-decode size guard, temp-ctx close after decode). Each pages/<effect>.ts is a ~25-line declarative config (title, description, register, makeNode, applyParam?, params?, bridge, workletUrl). Stateless drawing primitives in lib/viz.ts (drawWaveform + drawSpectrum, module-scope cached scratch buffers).
Self-contained ~200-line pages with no shared scaffolding (a mid-Sub-D detour: see "Why the journey" below).
Why the journey: Sub D first attempted a mountEffectPage helper that quickly accumulated bugs — currentNode aliasing across realtime/offline ctxes, refreshViz race conditions, repeated WebAssembly.Instance allocations causing OOM under slider drag, half-mounted pages on register failure, file picker confusingly placed. The author misdiagnosed those as "abstraction is bad" and pivoted to fully self-contained pages, but the maintainer correctly pushed back: with 50+ effects on the v1.0–v3.0 roadmap (per ROADMAP.md), inlining ~150 lines of identical scaffolding into every page would mean 7,500+ lines of pure copy-paste. The original bugs were specific to the FIRST helper's design (offline-render-on-slider strategy, single currentNode shared between realtime/offline), not inherent to abstraction. The current renderEffectPage keeps every lesson the self-contained pages proved out: ONE realtime ctx (no offline-render-on-slider, so currentNode aliasing is structurally impossible), AbortSignal teardown registered before the first await, AnalyserNode + rAF (constant memory), inline file picker (with pre-decode size guard, temp-ctx close, multichannel handled by Web Audio's channelInterpretation: "speakers" ITU-R BS.775 downmix), Bypass toggle replacing wet/dry crossfade. New effects opt into all of it via a one-screen config. Sub E's add-effect template points directly at this helper.
Smoothing-state precision
f64 smoothing state + per-sample multiplication, output cast to f32
f32 throughout (the original §6.1 sketch)
Empirically, an f32 kernel hits the steady-state multiplication noise floor at ~-91 dBFS at +6 dB peak (chirp -3 dBFS × gain 1.995 ≈ +1.41 peak), forcing per-preset Tier2 tolerance overrides (Issue §8 Fallback #2(b)) that drift over toolchain bumps. f64 state matches the scipy reference (also f64 throughout, f32 output) bit-for-bit on the trajectory and clears -96 dBFS on every preset by > 30 dB. State grows from 12 → 24 bytes (negligible) and per-sample work doubles from f32 to f64 mul (still trivial — gain is the simplest effect; modern CPUs have identical throughput). This is the canonical pattern Sub E's template will recommend for any effect with smoothed feedback or recursive coefficients.
Tier2 tolerance
single -96 dBFS for every preset (parent epic D19 default)
per-preset overrides for plus_6db (-90 dBFS, Fallback #2 (b))
Made redundant by the f64 state decision above. The f32 kernel needed -90 for plus_6db; the f64 kernel clears -96 on all 5 presets with >30 dB margin. No per-preset configs to maintain, no toolchain-drift cliffs.
Allocation OOM handling
processor sets alive = false and short-circuits process() to false if any den_alloc returns 0 (null); Rust kernel exports also no-op on null state pointer
rely on debug_assert!(!state.is_null()) only
Codex review caught: den_alloc() documents *mut u8 with 0-on-OOM, but the worklet was using the result blindly. In a release build with no debug_assert, the next den_gain_init(0, sr) would scribble at WASM linear-memory offset 0 (UB on the kernel's part, opaque silent corruption from JS's). Belt-and-braces: JS guards before init AND Rust kernels guard against null state in release mode. The user-visible signal of OOM is a silenced node — Web Audio's coarsest possible failure mode but the only one the spec exposes.
registerDenWorklet failure recovery
try { … } finally { clearInflight(ctx) } so a failed register() clears the in-flight slot and a subsequent retry can succeed
rely on the cache flag alone (was: clearInflight only on success)
Without the finally, a single failed register (bad wasmUrl, network blip, addModule rejection) leaves INFLIGHT_KEY pointing at a permanently-rejected promise. Subsequent Effect.register(ctx) calls return that same rejected promise forever — the user can't retry without rebuilding the AudioContext. Caught by the TS-API-lifecycle review agent.
Catalog goldens reproducibility
gen.py zeroes the libsndfile PEAK chunk timestamp in every WAV after sf.write
accept that vp run gen-golden shows a noisy git status even when audio data is unchanged
libsndfile injects a Unix timestamp into the optional PEAK RIFF chunk; without zeroing, every regen flips ~80 bytes across the 40 gain goldens for no semantic reason. The audio data itself is byte-deterministic; only the metadata header drifts. The post-process is 3 lines of Python and trims contributor friction (and accidental commits) significantly.
4. Out of Scope
Any effect other than Gain
A dedicated dB-unit GainDb / mute / bypass class (separate follow-up)
Stereo width / panning (would be a different effect, Pan or Width)
Oversampling (N/A for linear gain)
Preset JSON format (future)
The add-effect issue template itself (Sub E — written once this Gain PR is merged and the workflow is proven)
5. Behavior Specification
5.1 Happy path
import{Gain}from"@denaudio/effects";constctx=newAudioContext();awaitGain.register(ctx);// once per contextconstgain=newGain(ctx,{gain: 0.5});// initial -6 dBsource.connect(gain).connect(ctx.destination);// AudioParam automation works as-is.gain.gain.setValueAtTime(1.0,ctx.currentTime);gain.gain.linearRampToValueAtTime(0.0,ctx.currentTime+2.0);
Observable properties:
Output at steady state with gain.value = g equals input * g sample-accurately (within f32 float precision)
Abrupt step in gain (via setValueAtTime) produces a 1-pole exponential ramp with tau=20 ms — no audible click even at ±∞ dB jumps
Stereo channels are gained identically and independently (same gain, separate smoothed state per channel — functionally same because both channels see the same target, but smoothing state is separate so any future per-channel gain is trivial)
5.2 Error paths
new Gain(ctx, ...) before await Gain.register(ctx) → throws Error("den: call await Gain.register(ctx) first")
gain option out of [0, 10] → clamps silently to range (AudioParam honors min/max in parameterDescriptor)
Node disposal → user calls gain.dispose(). Posts {__denCmd: "destroy"} to the worklet, which frees state + I/O via den_dealloc and sets alive = false so the next process() returns false (host GCs the processor). The method also calls this.disconnect() — see the Gain.dispose() TSDoc for the explicit "disconnects ALL routing" warning. Idempotent.
5.3 Edge cases
AudioContext with sampleRate other than 48000 (e.g., 44100, 96000): smoothing coefficient is computed from sampleRate inside the worklet's constructor, passed to den_gain_init(state, sr). Tier2 itself runs at a fixed SR = 48000 (matches the scipy goldens, which are 48 kHz); cross-rate behavior is exercised via the Tier1 unit tests' parametrized rates and via the Tier3a OfflineAudioContext running at the catalog's 48 kHz. Bumping Tier2 to multiple sample rates is a Sub E template note, not a Sub D blocker.
Negative gain values: not currently allowed (minValue=0). A future PolarityInvert effect handles sign flip.
Very small gain (e.g., 1e-7): reaches denormals. Rust code does not specifically handle denormals; any audible-level issues will be caught in golden null. Document as known-benign.
6. Implementation Plan
6.1 Rust: effect module layout
Introduce crates/den-core/src/effects/mod.rs + per-effect files. This structure is reused by every future effect.
crates/den-core/src/lib.rs (update):
#![no_std]externcrate alloc;use core::panic::PanicInfo;#[panic_handler]fnpanic(_:&PanicInfo) -> ! {// `core::arch::wasm32::unreachable` is a SAFE fn (stable since Rust 1.37);// emits a wasm `unreachable` trap. No `unsafe {}` block needed — adding// one would trigger `unused_unsafe` under `clippy -D warnings`.
core::arch::wasm32::unreachable()}pubmod alloc_shim;pubmod effects;// Re-export all WASM-side symbols from effects modules (each effect is a sibling file).pubuse effects::gain::*;pubuse effects::passthrough::*;pubuse alloc_shim::*;
crates/den-core/src/alloc_shim.rs: moves the alloc/dealloc from Sub B here.
crates/den-core/src/effects/mod.rs:
pubmod gain;pubmod passthrough;
crates/den-core/src/effects/passthrough.rs: the Sub B passthrough kernel, moved from lib.rs unchanged.
crates/den-core/src/effects/gain.rs:
//! Gain effect: per-channel multiply with 1-pole exponential smoothing.//!//! Reference: textbook (input * gain). Smoothing: y[n] = y[n-1] + coef*(target - y[n-1]),//! coef = 1 - exp(-1/(sr*tau)), tau = 20 ms.//!//! State (GainState) must be allocated by JS (via `den_gain_size()` + `den_alloc`)//! and initialized via `den_gain_init(state_ptr, sample_rate)`.use core::slice;// State is f64 even though audio buffers are f32. See decisions row// "Smoothing-state precision".constTAU_SECONDS:f64 = 0.020;#[repr(C)]pubstructGainState{smoothed_l:f64,smoothed_r:f64,smooth_coef:f64,}#[unsafe(no_mangle)]pubextern"C"fnden_gain_size() -> usize{
core::mem::size_of::<GainState>()}#[unsafe(no_mangle)]pubunsafeextern"C"fnden_gain_init(state:*mutGainState,sample_rate:f32){debug_assert!(!state.is_null());let s = unsafe{&mut*state };
s.smoothed_l = 1.0;
s.smoothed_r = 1.0;
s.smooth_coef = 1.0 - libm::expf(-1.0 / (sample_rate *TAU_SECONDS));}#[unsafe(no_mangle)]pubunsafeextern"C"fnden_gain_process(state:*mutGainState,l_in:*constf32,r_in:*constf32,l_out:*mutf32,r_out:*mutf32,n:usize,gain_values:*constf32,n_gain_values:usize,){debug_assert!(!state.is_null());// Per W3C Web Audio spec, the AudioParam float array passed to process()// is either length 1 (k-rate, or a-rate when no scheduled events for the// quantum) OR length n (sample-accurate a-rate). It is NEVER zero in the// worklet path. Worklet-side dispatch must guarantee n_gain_values >= 1;// we still defensively guard n == 0 (no audio frames) early.if n == 0{return;}let s = unsafe{&mut*state };let li = unsafe{ slice::from_raw_parts(l_in, n)};let ri = unsafe{ slice::from_raw_parts(r_in, n)};let lo = unsafe{ slice::from_raw_parts_mut(l_out, n)};let ro = unsafe{ slice::from_raw_parts_mut(r_out, n)};// SAFETY for `gvs`: avoid `from_raw_parts(null, 0)` UB. Worklet-side// dispatch guarantees n_gain_values >= 1, but we explicitly guard so that// no path constructs a slice from a possibly-null pointer.let gvs:&[f32] = if n_gain_values == 0{debug_assert!(false,"den_gain_process: n_gain_values must be >= 1 (k-rate=1, a-rate=n)");&[]}else{unsafe{ slice::from_raw_parts(gain_values, n_gain_values)}};let coef = s.smooth_coef;let a_rate = n_gain_values == n;if a_rate {for i in0..n {let target = gvs[i];
s.smoothed_l += (target - s.smoothed_l)* coef;
s.smoothed_r += (target - s.smoothed_r)* coef;
lo[i] = li[i]* s.smoothed_l;
ro[i] = ri[i]* s.smoothed_r;}}else{// k-rate broadcast (length 1). If n_gain_values is unexpectedly 0// (debug_assert above caught it), fall back to the AudioParam default.let target = if gvs.is_empty(){1.0_f32}else{ gvs[0]};for i in0..n {
s.smoothed_l += (target - s.smoothed_l)* coef;
s.smoothed_r += (target - s.smoothed_r)* coef;
lo[i] = li[i]* s.smoothed_l;
ro[i] = ri[i]* s.smoothed_r;}}}// --- Tier1 unit tests (host-target, not WASM) ------------------------#[cfg(test)]mod tests {usesuper::*;use alloc::vec::Vec;fnalloc_state(sr:f32) -> GainState{letmut s = GainState{smoothed_l:0.0,smoothed_r:0.0,smooth_coef:0.0};unsafe{den_gain_init(&mut s as*mut_, sr)};
s
}#[test]fnunity_gain_is_identity_after_steady_state(){letmut state = alloc_state(48000.0);let n = 1024usize;let input:Vec<f32> = (0..n).map(|i| (i asf32 / n asf32).sin()).collect();letmut out_l = vec![0f32; n];letmut out_r = vec![0f32; n];let gain = [1.0f32];// k-rateunsafe{den_gain_process(&mut state as*mut_,
input.as_ptr(), input.as_ptr(),
out_l.as_mut_ptr(), out_r.as_mut_ptr(),
n, gain.as_ptr(),1);}// Initial smoothed == 1.0 (init), target == 1.0; should be identity from sample 0.for i in0..n {assert!((out_l[i] - input[i]).abs() < 1e-6,"mismatch at {i}");}}#[test]fnzero_gain_produces_silence_eventually(){letmut state = alloc_state(48000.0);let n = 48000;// 1 secondlet input = vec![0.5f32; n];letmut out_l = vec![0f32; n];letmut out_r = vec![0f32; n];let gain = [0.0f32];unsafe{den_gain_process(&mut state as*mut_,
input.as_ptr(), input.as_ptr(),
out_l.as_mut_ptr(), out_r.as_mut_ptr(),
n, gain.as_ptr(),1);}// After 15 tau (300 ms), residual ~exp(-15) ≈ 3e-7 ≈ -130 dBFS.// We assert < -100 dBFS to leave float-arithmetic headroom.// (200 ms / 10 tau gives only -86 dBFS — too tight for the -90 we'd want.)let tail = &out_l[(48000*3 / 10)..];// after 300 mslet peak = tail.iter().map(|v| v.abs()).fold(0.0f32, f32::max);assert!(20.0* peak.max(1e-12).log10() < -100.0,"peak {peak} above -100 dB tail");}#[test]fnstep_change_smooths_no_click(){letmut state = alloc_state(48000.0);let n = 128;let input = vec![1.0f32; n];letmut out_l = vec![0f32; n];letmut out_r = vec![0f32; n];// k-rate step from default 1.0 to 0.0: smoothing kicks in.let gain = [0.0f32];unsafe{den_gain_process(&mut state as*mut_,
input.as_ptr(), input.as_ptr(),
out_l.as_mut_ptr(), out_r.as_mut_ptr(),
n, gain.as_ptr(),1);}// First sample: smoothed_l was 1.0, target 0.0, coef ~ 1e-3 for 48kHz/20ms.// out[0] ≈ 1 * (1.0 - 1e-3) = very close to 1.// Over 128 samples we should see monotonic decrease and no jump.letmut prev = 1.0f32;for v in&out_l {assert!(*v <= prev + 1e-6,"non-monotonic at {v} vs {prev}");
prev = *v;}assert!(out_l[n-1] < 0.999);assert!(out_l[n-1] > 0.85);// not fully decayed in 128 samples @ 48k, tau 20 ms}}
Add libm = "0.2" to crates/den-core/Cargo.toml (no_std-compatible math).
Replace the Sub B callKernel with a per-kernel dispatch that manages state + params:
// Inside DenProcessor class:interfaceKernelHandles{stateHeapPtr: number;stateHeapSize: number;paramScratchPtr: number;// 128 * 4 bytes for a-rate Float32Array copyparamScratchSize: number;kind: "passthrough"|"gain";}// replaces the simple `instance` + `kernelId`:privatekernel!: KernelHandles;// in constructor (after wasm instantiate):constex=this.instance.exportsasany;constkernelId=po.__denKernelIdasKernelHandles["kind"];constparamBytes=DenProcessor.QUANTUM*4;constparamPtr=ex.den_alloc(paramBytes);if(kernelId==="gain"){conststateSize=ex.den_gain_size();conststatePtr=ex.den_alloc(stateSize);ex.den_gain_init(statePtr,sampleRate);// sampleRate is AudioWorkletGlobalScope globalthis.kernel={stateHeapPtr: statePtr,stateHeapSize: stateSize,paramScratchPtr: paramPtr,paramScratchSize: paramBytes,kind: "gain"};}else{this.kernel={stateHeapPtr: 0,stateHeapSize: 0,paramScratchPtr: paramPtr,paramScratchSize: paramBytes,kind: "passthrough"};}// parameterDescriptors: static method now reads from processorOptions? No — processorOptions// isn't available at the static class level. Instead, declare all possible params as static// descriptors; effects that don't use them simply ignore their Float32Array.staticgetparameterDescriptors(): AudioParamDescriptor[]{
return [{name: "gain",defaultValue: 1,minValue: 0,maxValue: 10,automationRate: "a-rate"},// future params here, each guarded by kernel kind at dispatch time];}// in constructor (extend the post-instantiate block):this.port.onmessage=(ev: MessageEvent)=>{if(ev.data?.__denCmd==="destroy"){// Free WASM-owned buffers in the order: param scratch → state → I/O// (allocated in the reverse order). Sizes MUST match the alloc sizes// exactly (Layout::from_size_align in Rust requires same {size,align}).if(this.kernel.paramScratchPtr){ex.den_dealloc(this.kernel.paramScratchPtr,this.kernel.paramScratchSize);this.kernel.paramScratchPtr=0;}if(this.kernel.stateHeapPtr){ex.den_dealloc(this.kernel.stateHeapPtr,this.kernel.stateHeapSize);this.kernel.stateHeapPtr=0;}// I/O buffers (l_in_ptr/r_in_ptr/l_out_ptr/r_out_ptr) are allocated// in the base processor; the same dispose path frees them via// this.disposeIoBuffers() (defined in Sub B's processor base).this.disposeIoBuffers();this.alive=false;}};// process:// ...(existing L/R copy-in logic unchanged)...if(!this.alive)returnfalse;// post-destroy: terminate the processorswitch(this.kernel.kind){case"passthrough":
ex.den_passthrough(this.l_in_ptr,this.r_in_ptr,this.l_out_ptr,this.r_out_ptr,n);break;case"gain": {constgainValues=_parameters.gain;// Float32Array of length 1 or 128 (NEVER 0 per W3C spec)constpScratch=this.kernel.paramScratchPtr>>2;// Always copy to heap (cheap; keeps FFI signature uniform)this.heap_f32.set(gainValues,pScratch);ex.den_gain_process(this.kernel.stateHeapPtr,this.l_in_ptr,this.r_in_ptr,this.l_out_ptr,this.r_out_ptr,n,this.kernel.paramScratchPtr,gainValues.length,);break;}}returntrue;// keep processor alive
6.3 Refactor @denaudio/worklet main API
Replace Sub B's createDenNode(ctx, kernelId, nodeOptions, registerOpts) with a cache-first pair:
packages/worklet/src/main.ts:
import{fetchWasmBytes}from"@denaudio/core";// The cache key is a global symbol so that multiple bundled copies of// @denaudio/worklet (rare but possible with bad pinning) still share the// same per-context cache. Storing on `BaseAudioContext` as a hidden// property assumes the host does not freeze context objects — true in// every 2026 browser; the W3C Web Audio spec doesn't require freezing.// If a future spec change locks BaseAudioContext, fall back to a// `WeakMap<BaseAudioContext, Cached>` module-private map.constCACHE=Symbol.for("den.worklet.cache");typeCached={bytes: ArrayBuffer;moduleAdded: true};exportinterfaceRegisterOptions{wasmUrl?: string;workletUrl?: string;}exportasyncfunctionregisterDenWorklet(ctx: BaseAudioContext,options: RegisterOptions={}): Promise<void>{constc=ctxasunknownas{[CACHE]?: Cached};if(c[CACHE])return;constworkletUrl=options.workletUrl??newURL("./processor.js",import.meta.url).href;const[bytes]=awaitPromise.all([fetchWasmBytes(options.wasmUrl),ctx.audioWorklet.addModule(workletUrl),]);c[CACHE]={ bytes,moduleAdded: true};}exportfunctiongetCachedWasmBytes(ctx: BaseAudioContext): ArrayBuffer{constc=ctxasunknownas{[CACHE]?: Cached};if(!c[CACHE])thrownewError("den: call await Effect.register(ctx) first");returnc[CACHE].bytes;}exportconstDEN_PROCESSOR_NAME="den-processor"asconst;
Remove createDenNode and the WasmReady interface entirely — the user-facing effect classes now extend AudioWorkletNode directly with sync constructors after await Effect.register(ctx). No consumers remain after §6.4's Passthrough refactor; pre-1.0 status means no back-compat burden.
Re-entrancy: serialize concurrent register() calls so the first wins and subsequent awaits resolve when it finishes (use an in-flight Promise<void> slot, e.g., Symbol.for("den.worklet.inflight"), cleared once the realized Cached is written). This implements Fallback Plans #4 inline rather than waiting for a bug.
6.4 @denaudio/effects/src/gain.ts
import{DEN_PROCESSOR_NAME,getCachedWasmBytes,registerDenWorklet,typeRegisterOptions,}from"@denaudio/worklet";exportinterfaceGainOptions{/** Initial linear gain. Default 1.0. Clamped to [0, 10] by the AudioParam descriptor. */gain?: number;}// Note: `GainOptions` does NOT extend `RegisterOptions`. URL overrides// (`wasmUrl` / `workletUrl`) belong only to `Gain.register(ctx, opts)`,// not to the sync constructor. See decisions table row "GainOptions shape".exportclassGainextendsAudioWorkletNode{readonlygain: AudioParam;/** * Construct a Gain node. Stereo in, stereo out. If the source connected * upstream is mono (single-channel `AudioBuffer` etc.), the worklet * duplicates the L channel into R for processing — the output is always * stereo, with both channels carrying the same gain-applied signal. * Effects with non-trivial stereo behavior (e.g., a future `StereoWidth`) * should document any non-passthrough mono handling explicitly. */constructor(ctx: BaseAudioContext,options: GainOptions={}){constbytes=getCachedWasmBytes(ctx);super(ctx,DEN_PROCESSOR_NAME,{numberOfInputs: 1,numberOfOutputs: 1,outputChannelCount: [2],parameterData: options.gain!==undefined ? {gain: options.gain} : undefined,processorOptions: {__denKernelId: "gain",__denWasmBytes: bytes,},});constp=this.parameters.get("gain");if(!p)thrownewError("den: AudioParam 'gain' not registered");this.gain=p;}/** * Free WASM-side state and param-scratch buffers. AudioWorkletProcessor * has no JS-side destructor hook, so users that create-and-destroy many * Gain nodes (e.g., per-track UI) MUST call `.dispose()` to avoid leaking * a few hundred bytes per node into the WASM linear memory. After dispose * the node MUST NOT be re-used; disconnect() upstream/downstream first. */dispose(): void{this.port.postMessage({__denCmd: "destroy"});this.disconnect();}/** Idempotent: loads WASM and installs the worklet module in this context. */staticasyncregister(ctx: BaseAudioContext,options: RegisterOptions={}): Promise<void>{awaitregisterDenWorklet(ctx,options);}}
Update @denaudio/effects/src/index.ts:
export{Gain,typeGainOptions}from"./gain.js";export{Passthrough}from"./passthrough.js";// kept for tests; marked @internal in TSDoc
Update packages/effects/src/passthrough.ts to use the new cache-based construction — same pattern as Gain (sync new Passthrough(ctx) after await Passthrough.register(ctx)) but no gain param. Also gains dispose() (decisions table row "Passthrough.dispose"). The shape becomes:
The existing Tier3a passthrough.spec.ts and pages/passthrough.ts must be updated from the old Passthrough.create() factory to new Passthrough(ctx) after register(). Same null-test behavior; the API shape change is part of the canonical-shape refactor.
6.7 Tier3a test — packages/examples/tests/gain.spec.ts
Model after passthrough.spec.ts, loop over each preset, compare against the same golden. Use gain.setValueAtTime(preset_target, 0) before rendering — which tests the AudioParam pathway, not just the constructor's initial.
6.8 Catalog pages — delegate to shared renderEffectPage helper
Per the §3 decision row "Catalog page architecture", pages/passthrough.ts and pages/gain.ts are ~25-line declarative configs that delegate every piece of UI / lifecycle scaffolding to renderEffectPage in packages/examples/src/lib/effect-page.ts. Future effects added by Sub E follow the same shape with no ceremony — only the effect-specific bits (constructor, param list, applyParam mapping, bridge globals) are per-page.
pages/passthrough.ts follows the same pattern (no params, no applyParam, Passthrough in the bridge). Each page's render accepts the AbortSignal from main.ts and forwards it to the helper, which uses it for synchronous teardown on navigation (RAF cancel, source.stop, effect.dispose, ctx.close).
Register in packages/examples/src/main.ts: import * as gain from "./pages/gain.js"; PAGES.gain = gain;. Sidebar order: passthrough → gain. main.ts mints an AbortController per render and aborts the previous before mounting the next.
Update packages/examples/src/test-bridge.ts to widen Window.__denTier3a to { Passthrough?, Gain?, CANONICAL, workletUrl } so both pages can spread without clobbering.
The catalog has exactly two helpers; both live under packages/examples/src/lib/ so pages import from one canonical location. No widgets.ts, no separate file-picker widget — the interactive bits collapse into renderEffectPage because every effect needs the same shape.
importtype{RegisterOptions}from"@denaudio/worklet";exportinterfaceParamSpec{name: string;// matches the AudioParam name on the nodemin: number;max: number;step: number;initial: number;label?: string;}exportinterfaceEffectPageOptions<TNodeextendsAudioNode>{title: string;description: string;register: (ctx: BaseAudioContext,opts: RegisterOptions)=>Promise<void>;/** * Sync constructor for the live effect node. Called ONLY against the * realtime ctx — the helper never feeds an offline ctx here, so * "currentNode aliasing across realtime/offline" cannot happen. * `params` is the live slider dictionary so each fresh node starts * at the user's current edits. */makeNode: (ctx: AudioContext,params: Record<string,number>)=>TNode;/** Apply a slider change to the live node (typically `setValueAtTime`). */applyParam?: (node: TNode,name: string,value: number,ctx: AudioContext)=>void;params?: ParamSpec[];/** Wire `window.__denTier3a`. Helper sets `__denReady = true` after. */bridge: (extras: {workletUrl: string})=>void;workletUrl: string;}exportasyncfunctionrenderEffectPage<TNodeextendsAudioNode>(root: HTMLElement,signal: AbortSignal,opts: EffectPageOptions<TNode>,): Promise<void>
Behavior (all of which is the lessons-learned distillation from the self-contained-page detour):
Render the HTML skeleton inline: #status, source <select> (CANONICAL signals + "Custom file…" option) + <input type="file">, one slider per ParamSpec, Play / Bypass buttons, two <canvas> (waveform / spectrum).
Register signal.addEventListener("abort", teardownLive) SYNCHRONOUSLY before the first await so even mid-probe navigations tear down cleanly.
Probe pipeline on a throw-away OfflineAudioContext (no user gesture needed). Bail early on signal.aborted.
Call opts.bridge({ workletUrl }) then set window.__denReady = true.
Source <select> change → if currently playing, rebuild source (one-shot AudioBufferSourceNode); always toggles the file <input> visibility.
File <input> change → 50 MB pre-decode size cap → decodeAudioData((await f.arrayBuffer()).slice(0)) against the play ctx if available, else a temp AudioContext that gets await-closed in finally (browsers cap concurrent ctxes at ~6). Multichannel handled by Web Audio (effect node has outputChannelCount: [2] + default channelInterpretation: "speakers" → ITU-R BS.775 downmix).
Each slider's input event updates params[name], calls opts.applyParam against the live node if any.
Bypass click toggles live.bypass and re-routes (source → analyser → dest vs source → effect → analyser → dest).
Play creates a fresh AudioContext, calls opts.register(ctx), then opts.makeNode(ctx, params), builds the source, starts requestAnimationFrame viz loop. Stop tears down via teardownLive (RAF cancel, source.stop, source/effect.disconnect, effect.dispose if available, analyser.disconnect, ctx.close).
Pure functions called per requestAnimationFrame tick. Module-scope scratch Float32Arrays reused across frames (lazy-allocate, resize only when fftSize / frequencyBinCount changes) — no per-frame allocations.
Waveform shows the most recent ~5 ms (min(256, fftSize) samples) as a connected line trace, sliding-window. Why 5 ms (not the full ~42 ms fftSize=2048 window): at 10 kHz that's 53 cycles ≈ 18 px / cycle in a 962-pixel canvas, while still showing 5+ cycles of a 1 kHz sine. The full window collapses high-freq content to a solid blue band.
Spectrum is log-frequency (20 Hz left → Nyquist right), magnitude in dBFS mapped to bar height inside [-100 dB, 0 dB].
These primitives qualify as "pure utility": (1) take inputs as arguments, (2) hold no per-page state (only the analyser-keyed scratch buffers), (3) paint a canvas without owning a lifecycle. Sub E's add-effect template is allowed to add more primitives of this shape into lib/ but not to introduce new stateful / lifecycle abstractions outside renderEffectPage.
7. Testing & Verification
7.1 Tier1 (cargo)
cargo test -p den-core --lib effects::gain::tests -- --nocapture → all 3 tests green
cargo fmt --check clean
cargo clippy -- -D warnings clean
7.2 Tier2 (Node golden null)
vp run gen-golden gain produces 5 presets × 8 signals = 40 .wav files (committed). The Python CANONICAL set is 8 signals (chirp, pink, sine_1k, sine_5k, sine_10k, impulse, dc_half, silence); the earlier "5×7=35" arithmetic in this issue was off by one.
vp run test:tier2 green for passthrough AND gain
7.3 Tier3a (Playwright + OAC)
vp run test:tier3a green: 3 existing passthrough tests + new gain tests for each (preset × signal) subset
Minimum Tier3a for Gain: 4 tests — unity/chirp, minus_6db/chirp, silence/chirp, mid_fade/chirp. Others optional but encouraged.
7.4 Tier3b (human review via Vercel preview)
PR produces a Vercel preview URL within 3 min
Open URL → #/gain page → A/B player works, hearing a CC0 drum loop through gain=0.5 sounds half the volume
Waveform and spectrogram update in real time
Slider at values 0, 0.5, 1, 2 produces monotonic changes in loudness
The null diff toggle (wired from widgets.ts) plays (source - (gain * source) * (1/gain)) = silence to prove the inverse
7.5 Gates
Gate
Command
Expected
Rust test
cargo test -p den-core effects::gain
3/3 pass
Lint/fmt
cargo clippy -- -D warnings && cargo fmt --check
clean
WASM build
vp run build --filter @denaudio/core
den_core.wasm contains den_gain_* exports (verify via wasm-objdump -x)
Golden gen
vp run gen-golden gain
5 presets × 8 signals = 40 files in golden/gain/
Tier2
vp run test:tier2
green for gain
Tier3a
vp run test:tier3a
green for gain subset
Tier3b
Vercel preview
#/gain page loads and all widgets work
8. Fallback Plans
If a-rate processing shows audible artifacts that k-rate doesn't, temporarily change the descriptor to "k-rate" and log a follow-up issue. The kernel already handles both.
If the 1-pole smoothing causes Tier2 null test to fail at high gain deltas (e.g., silence→unity in one step), re-examine: (a) confirm scipy reference uses identical coef arithmetic (double precision intermediate, float32 output), (b) loosen tolerance to -90 dBFS only for presets that involve sub-sample transitions, (c) last resort: inject gain.setValueAtTime(target, 0) in golden generation to avoid the implicit ramp from the default-1.0 initial state. Note on f32 vs f64: scipy uses f64 intermediates per gain_process in effects.py, while Rust kernel uses f32 throughout. Both cast to f32 for output. Numerical drift over 2 s @ 48 kHz is well under -120 dBFS for linear gain (no exponential accumulation); -96 dBFS tolerance is safe with margin. If a future effect compounds drift (recursive filters with feedback), revisit per-effect.
If libm dependency inflates WASM size by > 2 KB, inline a hand-rolled expf approximation for the one call site (init only; not in hot loop).
If getCachedWasmBytes pattern has reentrancy bugs (user calls register twice concurrently before either resolves), add an in-flight promise lock.
9. Definition of Done — Completion Checklist
crates/den-core/src/effects/gain.rs exists with den_gain_size, den_gain_init, den_gain_process exports
crates/den-core/src/alloc_shim.rs and crates/den-core/src/effects/{mod,passthrough}.rs exist; lib.rs is slim (no DSP, no alloc bodies)
libm = "0.2" added to den-core/Cargo.toml
cargo test -p den-core effects::gain passes all 3 tests (unity_gain_is_identity_after_steady_state, zero_gain_produces_silence_eventually, step_change_smooths_no_click)
@denaudio/worklet/src/main.ts refactored: registerDenWorklet → Promise<void> + getCachedWasmBytes (sync); createDenNode and WasmReady interface removed entirely; concurrent-register reentrancy serialized via in-flight slot
@denaudio/worklet/src/processor.ts dispatches "gain" kernel with a-rate param copy to heap; port.onmessage handles {__denCmd:"destroy"} by freeing param scratch + state + I/O buffers and setting alive = false; process() short-circuits to return false when !alive
@denaudio/effects/src/gain.ts exports Gain extends AudioWorkletNode with gain: AudioParam and dispose(); GainOptions = { gain?: number } does NOT extend RegisterOptions
@denaudio/effects/src/passthrough.ts refactored to the same sync-construct pattern AND gains a dispose() method (regression: passthrough still green)
scripts/gen-golden/effects.py contains gain_process + REGISTRY["gain"] with 5 presets
vp run gen-golden gain produces 40 golden .wavs (5 presets × 8 signals); they are committed to the repo
packages/test-utils/tests/gain.test.ts passes (vp run test:tier2)
packages/examples/tests/gain.spec.ts adds ≥4 Playwright tests covering at least unity, minus_6db, silence, mid_fade against the chirp signal (vp run test:tier3a)
packages/examples/tests/passthrough.spec.ts updated from the old Passthrough.create() factory to new Passthrough(ctx) after register() and still green
packages/examples/src/lib/effect-page.ts exports renderEffectPage(root, signal, opts); both pages/passthrough.ts and pages/gain.ts are ~25-line declarative configs that call it
No packages/examples/src/widgets.ts file (the file picker is folded into renderEffectPage's source dropdown as a "Custom file…" option, with 50 MB pre-decode size cap and post-decode tempCtx.close()); multichannel handling is delegated to Web Audio's channelInterpretation: "speakers" (ITU-R BS.775 downmix), no JS-side downmix
packages/examples/src/main.ts mints an AbortController per render and aborts the previous before mounting the next; pages take signal: AbortSignal and forward it to renderEffectPage
packages/examples/src/test-bridge.ts widened to { Passthrough?, Gain?, CANONICAL, workletUrl }
packages/examples/src/main.ts registers the gain page (sidebar order: passthrough → gain)
packages/effects/README.md contains a usage section with the Section 5.1 example
Vercel preview URL posted on the PR; reviewer confirms #/gain page looks and sounds right (slider 0–2 monotonic; gain=0 → silence within ~300 ms; auto-refresh on signal AND slider; file picker accepts a 3 s wav and offline-renders it through Gain)
rg "Passthrough" packages/effects/src shows it moved to @internal / kept for test infra only
No regressions: Sub A/B/C tiers all still green
PR description contains a link to the 4 golden WAV sonograms (attach as CI artifact images or embed Vercel preview URL with query fragments)
Sub D: Gain effect — end-to-end proof + public API shape
1. TL;DR
Ship the first real effect —
Gain— end-to-end through every layer: Rust kernel (with 1-pole exponential smoothing), WASM exports, worklet dispatch,@denaudio/effectspublic class that extendsAudioWorkletNodeand exposes a realAudioParam, Tier1 Rust unit tests, Tier2 golden null vs scipy reference, Tier3a Playwright+OAC, Tier3b catalog page on Vercel preview. This issue also refactors the transitional Sub B/C passthrough plumbing into the canonical effect shape that every future effect will reuse. On merge, a user cannpm install @denaudio/effects,await Gain.register(ctx); const g = new Gain(ctx, { gain: 0.5 }); source.connect(g).connect(ctx.destination); g.gain.setValueAtTime(1.0, t)and hear the textbook behavior, matching scipy to -96 dBFS.2. Context & Motivation
Gain is the simplest effect conceivable and therefore the most honest test of the pipeline (Parent D20). If Gain works end-to-end with all four test tiers green, every future effect can follow the exact same pattern by swapping only the Rust kernel + scipy reference. If Gain fails any tier, the pipeline is wrong — not the effect. Sub E takes Gain's actual implementation and extracts it into a reusable issue template.
This issue is also where the public API shape becomes canonical. Every
@denaudio/effectsexport from here on follows the template established byGain.Related:
Passthroughclass is the transitional shape; this issue refines it3. Decisions Made (and Roads Not Taken)
Gain.gainunits1.0, min0.0, max10.0[0,1]unit intervalGainNode. Users wanting dB do10**(db/20)themselves or use a futureGainDbwrapper.max=10is generous headroom without inviting nonsense.a-rate(sample-accurate)k-rateGain extends AudioWorkletNode(native feel).nodesource.connect(gain)works. AudioParam automation just works. Done.new Gain(ctx, opts)afterawait Gain.register(ctx)registerbehaviornew Gain(ctx)#[repr(C)]struct per effect, JS pre-allocates viaden_<effect>_size()+den_alloc()#[cfg(test)]incrates/den-core/src/effects/gain.rsrunning on host target (no WASM)options.gain(or 1.0 default) via the__denInitialGainprocessorOption, which the worklet forwards intoden_gain_init(state, sr, initial)new Gain(ctx, { gain: 0 })was constructed and immediately played — the exact opposite of user intent ("I asked for silence, why is there sound?"). Initializing the smoother to the constructor'sgainoption means the FIRST sample is already at the user-requested gain. Subsequent automation (setValueAtTime,linearRampToValueAtTime, etc.) still smooths via the kernel's per-sample 1-pole as before. Verified: (a) Tier1 tests still pass with init=1.0 (back-compat path stays meaningful for the back-end), (b) Tier2 unchanged (scipy reference usesinit=1.0and the JS test passes 1.0 too), (c) Tier3a Gain spec usesnew Gain(ctx)thensetValueAtTime(target, 0)to exercise the SUBSEQUENT-automation transient, which is still 1.0 → target.registerDenWorklet → Promise<WasmReady>withPromise<void>+ separate syncgetCachedWasmBytes(ctx)new Effect(ctx, opts)afterawait Effect.register(ctx); the cache lookup is opaque. Sub B's exportedWasmReadyinterface is removed. Underlying Symbol cache key (den.worklet.cache) is unchanged so the transition is data-compatible. Cache usesSymbol.for("den.worklet.cache")as a hidden property onBaseAudioContext— assumes the host does not freeze BaseAudioContext (true in all 2026 browsers; Web Audio spec does not mandateObject.freezeon context objects).Gain.dispose()posts a{__denCmd: "destroy"}message; processor handles it by callingden_deallocon state + param-scratch + I/O buffers, then returnsfalsefrom the nextprocess()to terminatedispose()so dynamic UIs (per-track effect chains) can free state. FinalizationRegistry is unreliable timing for audio. Returningfalsefromprocess()is the spec-defined keep-alive termination.process()return valuetrue(keep alive) by default; returnfalseONLY after adestroymessage has been processed and dealloc completedtruefalsepermits the user agent to GC the processor. Necessary fordispose()semantics. Future effects with finite playback (e.g., a one-shot trigger) may use the same convention; document in Sub E template.Passthrough.dispose()dispose()in Sub D — same shape as Gain (port{__denCmd:"destroy"}, processor frees state + I/O, returnsfalse)@internal/ test-only, so leave it withoutdispose()dispose) from "test-infra" effects (without) creates a parallel code path the template would have to fork on. Onedispose()implementation, oneport.onmessagehandler in the processor.GainOptionsshapeGainOptions = { gain?: number }— does NOT extendRegisterOptionsinterface GainOptions extends RegisterOptionsRegisterOptionscarrieswasmUrl/workletUrlwhich are only meaningful to the asyncGain.register()phase. Allowing them onnew Gain(ctx, opts)is a footgun: TypeScript accepts them, runtime silently ignores them. Splitting types makes the two phases obvious. The original §6.4 sketch'sextendswas a copy-paste error; this row makes the correction authoritative.createDenNode(Sub B)WasmReadyinterface@deprecatedfor back-compatCatalog page common code(superseded — see "Catalog page architecture" below)extractmountEffectPage()helperInline-copy the boilerplateTwo near-identical copies make Sub E's template harder to writeUser-file picker — separate widget(superseded — folded into the source picker, see below)mountFilePicker()widget + extendmountABPlayerwith optionalgetUserFilerenderEffectPagehelper inpackages/examples/src/lib/effect-page.tscodifies the canonical effect shape: HTML skeleton, source picker (CANONICAL signals + "Custom file…" option), per-effect param sliders, Play/Stop, Bypass toggle, AnalyserNode +requestAnimationFramewaveform + spectrum, AbortSignal teardown, file decode (50 MB pre-decode size guard, temp-ctx close after decode). Eachpages/<effect>.tsis a ~25-line declarative config (title,description,register,makeNode,applyParam?,params?,bridge,workletUrl). Stateless drawing primitives inlib/viz.ts(drawWaveform+drawSpectrum, module-scope cached scratch buffers).mountEffectPagehelper that quickly accumulated bugs — currentNode aliasing across realtime/offline ctxes, refreshViz race conditions, repeatedWebAssembly.Instanceallocations causing OOM under slider drag, half-mounted pages onregisterfailure, file picker confusingly placed. The author misdiagnosed those as "abstraction is bad" and pivoted to fully self-contained pages, but the maintainer correctly pushed back: with 50+ effects on the v1.0–v3.0 roadmap (perROADMAP.md), inlining ~150 lines of identical scaffolding into every page would mean 7,500+ lines of pure copy-paste. The original bugs were specific to the FIRST helper's design (offline-render-on-slider strategy, singlecurrentNodeshared between realtime/offline), not inherent to abstraction. The currentrenderEffectPagekeeps every lesson the self-contained pages proved out: ONE realtime ctx (no offline-render-on-slider, so currentNode aliasing is structurally impossible), AbortSignal teardown registered before the first await, AnalyserNode + rAF (constant memory), inline file picker (with pre-decode size guard, temp-ctx close, multichannel handled by Web Audio'schannelInterpretation: "speakers"ITU-R BS.775 downmix), Bypass toggle replacing wet/dry crossfade. New effects opt into all of it via a one-screen config. Sub E's add-effect template points directly at this helper.f64smoothing state + per-sample multiplication, output cast tof32f32throughout (the original §6.1 sketch)f32kernel hits the steady-state multiplication noise floor at ~-91 dBFS at +6 dB peak (chirp -3 dBFS × gain 1.995 ≈ +1.41 peak), forcing per-preset Tier2 tolerance overrides (Issue §8 Fallback #2(b)) that drift over toolchain bumps.f64state matches the scipy reference (alsof64throughout,f32output) bit-for-bit on the trajectory and clears -96 dBFS on every preset by > 30 dB. State grows from 12 → 24 bytes (negligible) and per-sample work doubles fromf32tof64mul (still trivial — gain is the simplest effect; modern CPUs have identical throughput). This is the canonical pattern Sub E's template will recommend for any effect with smoothed feedback or recursive coefficients.plus_6db(-90 dBFS, Fallback #2 (b))f64state decision above. Thef32kernel needed-90forplus_6db; thef64kernel clears-96on all 5 presets with>30 dBmargin. No per-preset configs to maintain, no toolchain-drift cliffs.alive = falseand short-circuitsprocess()tofalseif anyden_allocreturns 0 (null); Rust kernel exports also no-op on nullstatepointerdebug_assert!(!state.is_null())onlyden_alloc()documents*mut u8with 0-on-OOM, but the worklet was using the result blindly. In a release build with nodebug_assert, the nextden_gain_init(0, sr)would scribble at WASM linear-memory offset 0 (UB on the kernel's part, opaque silent corruption from JS's). Belt-and-braces: JS guards before init AND Rust kernels guard against null state in release mode. The user-visible signal of OOM is a silenced node — Web Audio's coarsest possible failure mode but the only one the spec exposes.registerDenWorkletfailure recoverytry { … } finally { clearInflight(ctx) }so a failedregister()clears the in-flight slot and a subsequent retry can succeedclearInflightonly on success)finally, a single failed register (badwasmUrl, network blip,addModulerejection) leavesINFLIGHT_KEYpointing at a permanently-rejected promise. SubsequentEffect.register(ctx)calls return that same rejected promise forever — the user can't retry without rebuilding the AudioContext. Caught by the TS-API-lifecycle review agent.gen.pyzeroes the libsndfile PEAK chunk timestamp in every WAV aftersf.writevp run gen-goldenshows a noisygit statuseven when audio data is unchanged4. Out of Scope
GainDb/ mute / bypass class (separate follow-up)PanorWidth)5. Behavior Specification
5.1 Happy path
Observable properties:
gain.value = gequalsinput * gsample-accurately (within f32 float precision)gain(viasetValueAtTime) produces a 1-pole exponential ramp with tau=20 ms — no audible click even at ±∞ dB jumpsgain.value = 0produces silence output (input × 0)gain, separate smoothed state per channel — functionally same because both channels see the same target, but smoothing state is separate so any future per-channel gain is trivial)5.2 Error paths
new Gain(ctx, ...)beforeawait Gain.register(ctx)→ throwsError("den: call await Gain.register(ctx) first")gainoption out of [0, 10] → clamps silently to range (AudioParam honors min/max in parameterDescriptor)gain.dispose(). Posts{__denCmd: "destroy"}to the worklet, which frees state + I/O viaden_deallocand setsalive = falseso the nextprocess()returnsfalse(host GCs the processor). The method also callsthis.disconnect()— see theGain.dispose()TSDoc for the explicit "disconnects ALL routing" warning. Idempotent.5.3 Edge cases
sampleRateinside the worklet's constructor, passed toden_gain_init(state, sr). Tier2 itself runs at a fixedSR = 48000(matches the scipy goldens, which are 48 kHz); cross-rate behavior is exercised via the Tier1 unit tests' parametrized rates and via the Tier3aOfflineAudioContextrunning at the catalog's 48 kHz. Bumping Tier2 to multiple sample rates is a Sub E template note, not a Sub D blocker.PolarityInverteffect handles sign flip.6. Implementation Plan
6.1 Rust: effect module layout
Introduce
crates/den-core/src/effects/mod.rs+ per-effect files. This structure is reused by every future effect.crates/den-core/src/lib.rs(update):crates/den-core/src/alloc_shim.rs: moves the alloc/dealloc from Sub B here.crates/den-core/src/effects/mod.rs:crates/den-core/src/effects/passthrough.rs: the Sub B passthrough kernel, moved from lib.rs unchanged.crates/den-core/src/effects/gain.rs:Add
libm = "0.2"tocrates/den-core/Cargo.toml(no_std-compatible math).6.2 Worklet dispatch update —
packages/worklet/src/processor.tsReplace the Sub B
callKernelwith a per-kernel dispatch that manages state + params:6.3 Refactor
@denaudio/workletmain APIReplace Sub B's
createDenNode(ctx, kernelId, nodeOptions, registerOpts)with a cache-first pair:packages/worklet/src/main.ts:Remove
createDenNodeand theWasmReadyinterface entirely — the user-facing effect classes now extendAudioWorkletNodedirectly with sync constructors afterawait Effect.register(ctx). No consumers remain after §6.4's Passthrough refactor; pre-1.0 status means no back-compat burden.Re-entrancy: serialize concurrent
register()calls so the first wins and subsequent awaits resolve when it finishes (use an in-flightPromise<void>slot, e.g.,Symbol.for("den.worklet.inflight"), cleared once the realizedCachedis written). This implements Fallback Plans #4 inline rather than waiting for a bug.6.4
@denaudio/effects/src/gain.tsUpdate
@denaudio/effects/src/index.ts:Update
packages/effects/src/passthrough.tsto use the new cache-based construction — same pattern as Gain (syncnew Passthrough(ctx)afterawait Passthrough.register(ctx)) but nogainparam. Also gainsdispose()(decisions table row "Passthrough.dispose"). The shape becomes:The existing Tier3a
passthrough.spec.tsandpages/passthrough.tsmust be updated from the oldPassthrough.create()factory tonew Passthrough(ctx)afterregister(). Same null-test behavior; the API shape change is part of the canonical-shape refactor.6.5 scipy reference — update
scripts/gen-golden/effects.pyRun
vp run gen-golden gain— producespackages/test-utils/golden/gain/<preset>__<signal>.wav. Commit all outputs.6.6 Tier2 test —
packages/test-utils/tests/gain.test.ts6.7 Tier3a test —
packages/examples/tests/gain.spec.tsModel after passthrough.spec.ts, loop over each preset, compare against the same golden. Use
gain.setValueAtTime(preset_target, 0)before rendering — which tests the AudioParam pathway, not just the constructor's initial.6.8 Catalog pages — delegate to shared
renderEffectPagehelperPer the §3 decision row "Catalog page architecture",
pages/passthrough.tsandpages/gain.tsare ~25-line declarative configs that delegate every piece of UI / lifecycle scaffolding torenderEffectPageinpackages/examples/src/lib/effect-page.ts. Future effects added by Sub E follow the same shape with no ceremony — only the effect-specific bits (constructor, param list,applyParammapping, bridge globals) are per-page.pages/passthrough.tsfollows the same pattern (noparams, noapplyParam,Passthroughin the bridge). Each page'srenderaccepts theAbortSignalfrommain.tsand forwards it to the helper, which uses it for synchronous teardown on navigation (RAF cancel, source.stop, effect.dispose, ctx.close).Register in
packages/examples/src/main.ts:import * as gain from "./pages/gain.js"; PAGES.gain = gain;. Sidebar order: passthrough → gain.main.tsmints anAbortControllerper render and aborts the previous before mounting the next.Update
packages/examples/src/test-bridge.tsto widenWindow.__denTier3ato{ Passthrough?, Gain?, CANONICAL, workletUrl }so both pages can spread without clobbering.6.9 Documentation —
packages/effects/README.mdFirst-look docs (detailed docs come in Sub E/F):
npm i @denaudio/effects)Gainexample (copy from Section 5.1)6.10 Shared catalog helpers —
lib/effect-page.ts+lib/viz.tsThe catalog has exactly two helpers; both live under
packages/examples/src/lib/so pages import from one canonical location. Nowidgets.ts, no separate file-picker widget — the interactive bits collapse intorenderEffectPagebecause every effect needs the same shape.6.10.1
packages/examples/src/lib/effect-page.ts—renderEffectPageBehavior (all of which is the lessons-learned distillation from the self-contained-page detour):
#status, source<select>(CANONICAL signals + "Custom file…" option) +<input type="file">, one slider perParamSpec, Play / Bypass buttons, two<canvas>(waveform / spectrum).signal.addEventListener("abort", teardownLive)SYNCHRONOUSLY before the first await so even mid-probe navigations tear down cleanly.OfflineAudioContext(no user gesture needed). Bail early onsignal.aborted.opts.bridge({ workletUrl })then setwindow.__denReady = true.<select>change → if currently playing, rebuild source (one-shotAudioBufferSourceNode); always toggles the file<input>visibility.<input>change → 50 MB pre-decode size cap →decodeAudioData((await f.arrayBuffer()).slice(0))against the play ctx if available, else a tempAudioContextthat getsawait-closed infinally(browsers cap concurrent ctxes at ~6). Multichannel handled by Web Audio (effect node hasoutputChannelCount: [2]+ defaultchannelInterpretation: "speakers"→ ITU-R BS.775 downmix).inputevent updatesparams[name], callsopts.applyParamagainst the live node if any.live.bypassand re-routes (source → analyser → destvssource → effect → analyser → dest).AudioContext, callsopts.register(ctx), thenopts.makeNode(ctx, params), builds the source, startsrequestAnimationFrameviz loop. Stop tears down viateardownLive(RAF cancel, source.stop, source/effect.disconnect, effect.dispose if available, analyser.disconnect, ctx.close).6.10.2
packages/examples/src/lib/viz.ts—drawWaveform+drawSpectrumPure functions called per
requestAnimationFrametick. Module-scope scratchFloat32Arrays reused across frames (lazy-allocate, resize only whenfftSize/frequencyBinCountchanges) — no per-frame allocations.Waveform shows the most recent ~5 ms (
min(256, fftSize)samples) as a connected line trace, sliding-window. Why 5 ms (not the full ~42 msfftSize=2048window): at 10 kHz that's 53 cycles ≈ 18 px / cycle in a 962-pixel canvas, while still showing 5+ cycles of a 1 kHz sine. The full window collapses high-freq content to a solid blue band.Spectrum is log-frequency (20 Hz left → Nyquist right), magnitude in dBFS mapped to bar height inside [-100 dB, 0 dB].
These primitives qualify as "pure utility": (1) take inputs as arguments, (2) hold no per-page state (only the analyser-keyed scratch buffers), (3) paint a canvas without owning a lifecycle. Sub E's add-effect template is allowed to add more primitives of this shape into
lib/but not to introduce new stateful / lifecycle abstractions outsiderenderEffectPage.7. Testing & Verification
7.1 Tier1 (cargo)
cargo test -p den-core --lib effects::gain::tests -- --nocapture→ all 3 tests greencargo fmt --checkcleancargo clippy -- -D warningsclean7.2 Tier2 (Node golden null)
vp run gen-golden gainproduces 5 presets × 8 signals = 40.wavfiles (committed). The PythonCANONICALset is 8 signals (chirp, pink, sine_1k, sine_5k, sine_10k, impulse, dc_half, silence); the earlier "5×7=35" arithmetic in this issue was off by one.vp run test:tier2green forpassthroughANDgain7.3 Tier3a (Playwright + OAC)
vp run test:tier3agreen: 3 existing passthrough tests + new gain tests for each (preset × signal) subsetunity/chirp,minus_6db/chirp,silence/chirp,mid_fade/chirp. Others optional but encouraged.7.4 Tier3b (human review via Vercel preview)
#/gainpage → A/B player works, hearing a CC0 drum loop throughgain=0.5sounds half the volumenull difftoggle (wired fromwidgets.ts) plays(source - (gain * source) * (1/gain))= silence to prove the inverse7.5 Gates
cargo test -p den-core effects::gaincargo clippy -- -D warnings && cargo fmt --checkvp run build --filter @denaudio/coreden_core.wasmcontainsden_gain_*exports (verify viawasm-objdump -x)vp run gen-golden gaingolden/gain/vp run test:tier2vp run test:tier3a#/gainpage loads and all widgets work8. Fallback Plans
"k-rate"and log a follow-up issue. The kernel already handles both.gain.setValueAtTime(target, 0)in golden generation to avoid the implicit ramp from the default-1.0 initial state. Note on f32 vs f64: scipy uses f64 intermediates pergain_processin effects.py, while Rust kernel uses f32 throughout. Both cast to f32 for output. Numerical drift over 2 s @ 48 kHz is well under -120 dBFS for linear gain (no exponential accumulation); -96 dBFS tolerance is safe with margin. If a future effect compounds drift (recursive filters with feedback), revisit per-effect.libmdependency inflates WASM size by > 2 KB, inline a hand-rolledexpfapproximation for the one call site (init only; not in hot loop).getCachedWasmBytespattern has reentrancy bugs (user callsregistertwice concurrently before either resolves), add an in-flight promise lock.9. Definition of Done — Completion Checklist
crates/den-core/src/effects/gain.rsexists withden_gain_size,den_gain_init,den_gain_processexportscrates/den-core/src/alloc_shim.rsandcrates/den-core/src/effects/{mod,passthrough}.rsexist;lib.rsis slim (no DSP, no alloc bodies)libm = "0.2"added toden-core/Cargo.tomlcargo test -p den-core effects::gainpasses all 3 tests (unity_gain_is_identity_after_steady_state,zero_gain_produces_silence_eventually,step_change_smooths_no_click)@denaudio/worklet/src/main.tsrefactored:registerDenWorklet → Promise<void>+getCachedWasmBytes(sync);createDenNodeandWasmReadyinterface removed entirely; concurrent-register reentrancy serialized via in-flight slot@denaudio/worklet/src/processor.tsdispatches"gain"kernel with a-rate param copy to heap;port.onmessagehandles{__denCmd:"destroy"}by freeing param scratch + state + I/O buffers and settingalive = false;process()short-circuits toreturn falsewhen!alive@denaudio/effects/src/gain.tsexportsGain extends AudioWorkletNodewithgain: AudioParamanddispose();GainOptions = { gain?: number }does NOT extendRegisterOptions@denaudio/effects/src/passthrough.tsrefactored to the same sync-construct pattern AND gains adispose()method (regression: passthrough still green)scripts/gen-golden/effects.pycontainsgain_process+REGISTRY["gain"]with 5 presetsvp run gen-golden gainproduces 40 golden.wavs (5 presets × 8 signals); they are committed to the repopackages/test-utils/tests/gain.test.tspasses (vp run test:tier2)packages/examples/tests/gain.spec.tsadds ≥4 Playwright tests covering at leastunity,minus_6db,silence,mid_fadeagainst thechirpsignal (vp run test:tier3a)packages/examples/tests/passthrough.spec.tsupdated from the oldPassthrough.create()factory tonew Passthrough(ctx)afterregister()and still greenpackages/examples/src/lib/effect-page.tsexportsrenderEffectPage(root, signal, opts); bothpages/passthrough.tsandpages/gain.tsare ~25-line declarative configs that call itpackages/examples/src/lib/viz.tsexportsdrawWaveform+drawSpectrum(AnalyserNode-driven, module-scope scratch buffer caches — no per-frame allocations)packages/examples/src/widgets.tsfile (the file picker is folded intorenderEffectPage's source dropdown as a "Custom file…" option, with 50 MB pre-decode size cap and post-decodetempCtx.close()); multichannel handling is delegated to Web Audio'schannelInterpretation: "speakers"(ITU-R BS.775 downmix), no JS-side downmixpackages/examples/src/main.tsmints anAbortControllerper render and aborts the previous before mounting the next; pages takesignal: AbortSignaland forward it torenderEffectPagepackages/examples/src/test-bridge.tswidened to{ Passthrough?, Gain?, CANONICAL, workletUrl }packages/examples/src/main.tsregisters thegainpage (sidebar order: passthrough → gain)packages/effects/README.mdcontains a usage section with the Section 5.1 example#/gainpage looks and sounds right (slider 0–2 monotonic;gain=0→ silence within ~300 ms; auto-refresh on signal AND slider; file picker accepts a 3 s wav and offline-renders it through Gain)rg "Passthrough" packages/effects/srcshows it moved to@internal/ kept for test infra only10. Reference material
GainNodeMDN (for API parity): https://developer.mozilla.org/en-US/docs/Web/API/GainNodeAudioWorkletProcessor.parameterDescriptors: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletProcessor/parameterDescriptorslibmcrate: https://crates.io/crates/libmgain()(for API inspiration, not used here): https://docs.rs/fundsp/latest/fundsp/fn.gain.html