Skip to content

Sub D: Gain effect — end-to-end proof + public API shape #5

Description

@yuichkun

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";

const ctx = new AudioContext();
await Gain.register(ctx);                  // once per context
const gain = new Gain(ctx, { gain: 0.5 });  // initial -6 dB

source.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
  • gain.value = 0 produces silence output (input × 0)
  • 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]

extern crate alloc;

use core::panic::PanicInfo;
#[panic_handler]
fn panic(_: &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()
}

pub mod alloc_shim;
pub mod effects;

// Re-export all WASM-side symbols from effects modules (each effect is a sibling file).
pub use effects::gain::*;
pub use effects::passthrough::*;
pub use alloc_shim::*;

crates/den-core/src/alloc_shim.rs: moves the alloc/dealloc from Sub B here.

crates/den-core/src/effects/mod.rs:

pub mod gain;
pub mod 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".
const TAU_SECONDS: f64 = 0.020;

#[repr(C)]
pub struct GainState {
    smoothed_l: f64,
    smoothed_r: f64,
    smooth_coef: f64,
}

#[unsafe(no_mangle)]
pub extern "C" fn den_gain_size() -> usize {
    core::mem::size_of::<GainState>()
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn den_gain_init(state: *mut GainState, 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)]
pub unsafe extern "C" fn den_gain_process(
    state: *mut GainState,
    l_in: *const f32, r_in: *const f32,
    l_out: *mut f32, r_out: *mut f32,
    n: usize,
    gain_values: *const f32,
    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 in 0..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 in 0..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 {
    use super::*;
    use alloc::vec::Vec;

    fn alloc_state(sr: f32) -> GainState {
        let mut 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]
    fn unity_gain_is_identity_after_steady_state() {
        let mut state = alloc_state(48000.0);
        let n = 1024usize;
        let input: Vec<f32> = (0..n).map(|i| (i as f32 / n as f32).sin()).collect();
        let mut out_l = vec![0f32; n];
        let mut out_r = vec![0f32; n];
        let gain = [1.0f32]; // k-rate
        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
            );
        }
        // Initial smoothed == 1.0 (init), target == 1.0; should be identity from sample 0.
        for i in 0..n {
            assert!((out_l[i] - input[i]).abs() < 1e-6, "mismatch at {i}");
        }
    }

    #[test]
    fn zero_gain_produces_silence_eventually() {
        let mut state = alloc_state(48000.0);
        let n = 48000; // 1 second
        let input = vec![0.5f32; n];
        let mut out_l = vec![0f32; n];
        let mut 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 ms
        let 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]
    fn step_change_smooths_no_click() {
        let mut state = alloc_state(48000.0);
        let n = 128;
        let input = vec![1.0f32; n];
        let mut out_l = vec![0f32; n];
        let mut 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.
        let mut 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).

6.2 Worklet dispatch update — packages/worklet/src/processor.ts

Replace the Sub B callKernel with a per-kernel dispatch that manages state + params:

// Inside DenProcessor class:

interface KernelHandles {
  stateHeapPtr: number;
  stateHeapSize: number;
  paramScratchPtr: number;      // 128 * 4 bytes for a-rate Float32Array copy
  paramScratchSize: number;
  kind: "passthrough" | "gain";
}

// replaces the simple `instance` + `kernelId`:
private kernel!: KernelHandles;

// in constructor (after wasm instantiate):
const ex = this.instance.exports as any;
const kernelId = po.__denKernelId as KernelHandles["kind"];
const paramBytes = DenProcessor.QUANTUM * 4;
const paramPtr = ex.den_alloc(paramBytes);
if (kernelId === "gain") {
  const stateSize = ex.den_gain_size();
  const statePtr = ex.den_alloc(stateSize);
  ex.den_gain_init(statePtr, sampleRate); // sampleRate is AudioWorkletGlobalScope global
  this.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.
static get parameterDescriptors(): 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) return false; // post-destroy: terminate the processor
switch (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": {
    const gainValues = _parameters.gain; // Float32Array of length 1 or 128 (NEVER 0 per W3C spec)
    const pScratch = 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;
  }
}
return true; // 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.
const CACHE = Symbol.for("den.worklet.cache");
type Cached = { bytes: ArrayBuffer; moduleAdded: true };

export interface RegisterOptions {
  wasmUrl?: string;
  workletUrl?: string;
}

export async function registerDenWorklet(ctx: BaseAudioContext, options: RegisterOptions = {}): Promise<void> {
  const c = ctx as unknown as { [CACHE]?: Cached };
  if (c[CACHE]) return;
  const workletUrl = options.workletUrl ?? new URL("./processor.js", import.meta.url).href;
  const [bytes] = await Promise.all([
    fetchWasmBytes(options.wasmUrl),
    ctx.audioWorklet.addModule(workletUrl),
  ]);
  c[CACHE] = { bytes, moduleAdded: true };
}

export function getCachedWasmBytes(ctx: BaseAudioContext): ArrayBuffer {
  const c = ctx as unknown as { [CACHE]?: Cached };
  if (!c[CACHE]) throw new Error("den: call await Effect.register(ctx) first");
  return c[CACHE].bytes;
}

export const DEN_PROCESSOR_NAME = "den-processor" as const;

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,
  type RegisterOptions,
} from "@denaudio/worklet";

export interface GainOptions {
  /** 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".

export class Gain extends AudioWorkletNode {
  readonly gain: 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 = {}) {
    const bytes = 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,
      },
    });
    const p = this.parameters.get("gain");
    if (!p) throw new Error("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. */
  static async register(ctx: BaseAudioContext, options: RegisterOptions = {}): Promise<void> {
    await registerDenWorklet(ctx, options);
  }
}

Update @denaudio/effects/src/index.ts:

export { Gain, type GainOptions } 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:

export class Passthrough extends AudioWorkletNode {
  constructor(ctx: BaseAudioContext) {
    const bytes = getCachedWasmBytes(ctx);
    super(ctx, DEN_PROCESSOR_NAME, {
      numberOfInputs: 1,
      numberOfOutputs: 1,
      outputChannelCount: [2],
      processorOptions: { __denKernelId: "passthrough", __denWasmBytes: bytes },
    });
  }
  dispose(): void { this.port.postMessage({ __denCmd: "destroy" }); this.disconnect(); }
  static async register(ctx: BaseAudioContext, options: RegisterOptions = {}): Promise<void> {
    await registerDenWorklet(ctx, options);
  }
}

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.5 scipy reference — update scripts/gen-golden/effects.py

def gain_process(x: np.ndarray, target: float, sr: int = SR, tau: float = 0.020, init: float = 1.0) -> np.ndarray:
    """Per-channel linear gain with 1-pole exponential smoothing.
    Mirrors den_gain_process exactly (including k-rate broadcast)."""
    coef = 1.0 - np.exp(-1.0 / (sr * tau))
    smoothed = np.full(x.shape[1], init, dtype=np.float64)
    tgt = np.full(x.shape[1], target, dtype=np.float64)
    out = np.empty_like(x)
    xf = x.astype(np.float64)
    for i in range(xf.shape[0]):
        smoothed += (tgt - smoothed) * coef
        out[i] = (xf[i] * smoothed).astype(np.float32)
    return out

REGISTRY["gain"] = Effect(
    name="gain",
    process=lambda x, gain=1.0: gain_process(x, gain),
    presets={
        "unity":      {"gain": 1.0},
        "minus_6db":  {"gain": 0.5011872336272722},
        "plus_6db":   {"gain": 1.9952623149688795},
        "silence":    {"gain": 0.0},
        "mid_fade":   {"gain": 0.25},
    },
)

Run vp run gen-golden gain — produces packages/test-utils/golden/gain/<preset>__<signal>.wav. Commit all outputs.

6.6 Tier2 test — packages/test-utils/tests/gain.test.ts

import { runGoldenNull } from "../src/runner.js";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

const SR = 48000;
const wasm = readFileSync(resolve(__dirname, "../../core/dist/den_core.wasm"));
const inst = new WebAssembly.Instance(new WebAssembly.Module(wasm));
const ex = inst.exports as any;

const presetToGain: Record<string, number> = {
  unity: 1.0, minus_6db: 0.5011872336272722, plus_6db: 1.9952623149688795,
  silence: 0.0, mid_fade: 0.25,
};

await runGoldenNull({
  effect: "gain",
  process: (stereoIn, preset) => {
    const target = presetToGain[preset];
    if (target === undefined) throw new Error(`unknown preset ${preset}`);
    const n = stereoIn[0].length;
    const bytes = n * 4;
    const lp = ex.den_alloc(bytes), rp = ex.den_alloc(bytes);
    const lo = ex.den_alloc(bytes), ro = ex.den_alloc(bytes);
    const gp = ex.den_alloc(4);
    const heap = new Float32Array(ex.memory.buffer);
    heap.set(stereoIn[0], lp >> 2);
    heap.set(stereoIn[1], rp >> 2);
    heap[gp >> 2] = target;
    const ss = ex.den_gain_size();
    const sp = ex.den_alloc(ss);
    ex.den_gain_init(sp, SR);
    ex.den_gain_process(sp, lp, rp, lo, ro, n, gp, 1);
    const L = heap.slice(lo >> 2, (lo >> 2) + n);
    const R = heap.slice(ro >> 2, (ro >> 2) + n);
    [lp, rp, lo, ro, gp, sp].forEach((p, idx) => {
      const sz = idx === 4 ? 4 : idx === 5 ? ss : bytes;
      ex.den_dealloc(p, sz);
    });
    return [L, R];
  }
});

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.

// packages/examples/src/pages/gain.ts
import { Gain } from "@denaudio/effects";
import { CANONICAL } from "@denaudio/test-utils/signals";
import workletUrl from "../../../worklet/dist/processor.js?url";
import "../test-bridge.js";
import { renderEffectPage } from "../lib/effect-page.js";

export const name = "Gain";

export async function render(root: HTMLElement, signal: AbortSignal): Promise<void> {
  await renderEffectPage(root, signal, {
    title: "Gain",
    description: "Linear per-channel multiplier with 20 ms 1-pole smoothing.",
    register: (ctx, opts) => Gain.register(ctx, opts),
    makeNode: (ctx, params) =>
      new Gain(ctx, params.gain !== undefined ? { gain: params.gain } : {}),
    applyParam: (node, name, value, ctx) => {
      if (name === "gain") node.gain.setValueAtTime(value, ctx.currentTime);
    },
    params: [{ name: "gain", min: 0, max: 2, step: 0.01, initial: 1 }],
    bridge: ({ workletUrl }) => {
      window.__denTier3a = { ...window.__denTier3a, Gain, CANONICAL, workletUrl };
    },
    workletUrl,
  });
}

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.

6.9 Documentation — packages/effects/README.md

First-look docs (detailed docs come in Sub E/F):

  • Install (npm i @denaudio/effects)
  • One-paragraph intro
  • Full Gain example (copy from Section 5.1)
  • Link to repo / issue tracker

6.10 Shared catalog helpers — lib/effect-page.ts + lib/viz.ts

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.

6.10.1 packages/examples/src/lib/effect-page.tsrenderEffectPage

import type { RegisterOptions } from "@denaudio/worklet";

export interface ParamSpec {
  name: string;             // matches the AudioParam name on the node
  min: number; max: number; step: number; initial: number;
  label?: string;
}

export interface EffectPageOptions<TNode extends AudioNode> {
  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;
}

export async function renderEffectPage<TNode extends AudioNode>(
  root: HTMLElement,
  signal: AbortSignal,
  opts: EffectPageOptions<TNode>,
): Promise<void>

Behavior (all of which is the lessons-learned distillation from the self-contained-page detour):

  1. 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).
  2. Register signal.addEventListener("abort", teardownLive) SYNCHRONOUSLY before the first await so even mid-probe navigations tear down cleanly.
  3. Probe pipeline on a throw-away OfflineAudioContext (no user gesture needed). Bail early on signal.aborted.
  4. Call opts.bridge({ workletUrl }) then set window.__denReady = true.
  5. Source <select> change → if currently playing, rebuild source (one-shot AudioBufferSourceNode); always toggles the file <input> visibility.
  6. 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).
  7. Each slider's input event updates params[name], calls opts.applyParam against the live node if any.
  8. Bypass click toggles live.bypass and re-routes (source → analyser → dest vs source → effect → analyser → dest).
  9. 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).

6.10.2 packages/examples/src/lib/viz.tsdrawWaveform + drawSpectrum

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.

export function drawWaveform(canvas: HTMLCanvasElement, analyser: AnalyserNode): void
export function drawSpectrum(canvas: HTMLCanvasElement, analyser: AnalyserNode): void

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

  1. 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.
  2. 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.
  3. 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).
  4. 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
  • packages/examples/src/lib/viz.ts exports drawWaveform + drawSpectrum (AnalyserNode-driven, module-scope scratch buffer caches — no per-frame allocations)
  • 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)

10. Reference material

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions