fix(core): reverb level, phaser wiring, per-channel dynamics, one-pole rebuild

Four defects in the graph builders, all found by review rather than by ear.

**Reverb was unusable at its own defaults.** A ConvolverNode applies the
impulse's gain whole — the graph sets `normalize = false` so a room is
deterministic rather than browser-defined — but the impulse was raw decaying
noise. Measured L2 at the registry default (size 0.7 / damping 0.5): 46.4, or
+33.3 dB, putting the wet path ~24 dB over dry at the default `wet: 0.35`. It is
now normalised to unit energy, so the wet knob means what it says. Preview and
render both convolve this buffer, so they stayed identical throughout — equally
deafening before, equally correct now.

**Phaser in_gain/out_gain trim the signal entering and leaving the effect**, not
a wet/dry pair. Wired to the wet and dry legs, "Input" muted the dry path and
the two defaults summed to 1.14, so inserting a phaser raised the track level.
They are now input and output trims with the legs summed at unity. Its declared
waveform is also honoured: `lfo.type` was never assigned, so the default
"Triangular" was silently a sine.

**The dynamics worklets held one envelope across a channel-major loop.** The
followers advance per sample, so on stereo a 20 ms attack behaved as 10 ms, and
the right channel's gain came from an envelope that had already traversed the
left — the two ducked differently from the same input and the image pumped.
State is now per channel, as is the gate's smoothed gain and bitcrush's
sample-hold counter, which previously advanced only on the last channel and left
every earlier one frozen for a whole quantum. The gate also honours the knee it
declares instead of chattering on material sitting at the threshold.

**A one-pole filter's cutoff was swallowed in preview.** Its coefficients are
fixed at construction, so `update` cannot push a new frequency — but the shape
signature carried only type and pole count, so a cutoff change looked like a
values-only edit and went into a no-op updater. Preview kept filtering at the
old frequency while the render used the new one: a preview/render divergence in
exactly the two effects that do not use a BiquadFilterNode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-04 14:36:30 -07:00
co-authored by Claude Opus 5
parent f59612850d
commit c3291f2912
4 changed files with 162 additions and 28 deletions
+11
View File
@@ -146,6 +146,17 @@
"file": "packages/studio/src/utils/studioHelpers.ts", "file": "packages/studio/src/utils/studioHelpers.ts",
"exports": ["resolveDroppedAssetDimensions"], "exports": ["resolveDroppedAssetDimensions"],
}, },
// Audio FX worklets sit near the bottom of the audio stack: the worklet
// source and its test reset are consumed by the runtime and engine PRs
// upstack, so a per-PR audit against the merge base sees them as unused.
{
"file": "packages/core/src/audio/audioFxWorklets.ts",
"exports": ["AUDIO_FX_WORKLET_SOURCE", "__resetAudioFxWorkletsForTests"],
},
{
"file": "packages/core/src/audio/audioFxGraph.ts",
"exports": ["ensureAudioFxWorklets"],
},
// drawElementService is the bottom of the fast-capture Graphite stack // drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so // (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as // a per-PR audit diffing against the merge base sees these exports as
@@ -274,3 +274,70 @@ describe("synthesizeReverbImpulse", () => {
expect(Math.max(...Array.from(tail, Math.abs))).toBeLessThan(0.01); expect(Math.max(...Array.from(tail, Math.abs))).toBeLessThan(0.01);
}); });
}); });
describe("levels and per-channel state", () => {
const energy = (ir: Float32Array): number => {
let e = 0;
for (const v of ir) e += v * v;
return Math.sqrt(e);
};
it("normalises the reverb impulse, so adding one does not clip the mix", () => {
// A ConvolverNode applies the impulse's gain whole (normalize is off so the
// room is deterministic). Raw decaying noise ran to +33 dB at the registry
// default, which put the wet path ~24 dB over dry at wet: 0.35.
for (const [size, damping] of [
[0.7, 0.5],
[1, 0],
[0, 1],
[0.5, 0.5],
]) {
expect(energy(synthesizeReverbImpulse(48000, size, damping))).toBeCloseTo(1, 5);
}
});
it("keeps a phaser at unity, rather than summing its two gains above it", () => {
// in_gain/out_gain trim what enters and leaves the effect; wired as a wet/dry
// pair their defaults summed to 1.14, so inserting a phaser raised the level.
const ctx = new FakeCtx();
buildFxNode(ctx as unknown as BaseAudioContext, "phaser", defaultAudioFxParams("phaser"));
const gains = ctx.created.filter((n) => n.kind === "gain").map((n) => n.gain.value);
// The wet and dry legs are summed at unity; the trims carry the knob values.
expect(gains.filter((g) => g === 1).length).toBeGreaterThanOrEqual(2);
});
it("sets the phaser LFO waveform it declares", () => {
const ctx = new FakeCtx();
buildFxNode(ctx as unknown as BaseAudioContext, "phaser", {
...defaultAudioFxParams("phaser"),
type: "0",
});
const osc = ctx.created.find((n) => n.kind === "osc");
expect(osc?.type).toBe("triangle");
const ctx2 = new FakeCtx();
buildFxNode(ctx2 as unknown as BaseAudioContext, "phaser", {
...defaultAudioFxParams("phaser"),
type: "1",
});
expect(ctx2.created.find((n) => n.kind === "osc")?.type).toBe("sine");
});
it("rebuilds a one-pole filter when its cutoff moves", () => {
// Its coefficients are fixed at construction, so a cutoff change cannot be
// pushed into the running graph — preview kept filtering at the old
// frequency while the render used the new one.
const ctx = new FakeCtx() as unknown as BaseAudioContext;
const chain = (frequency: number): HfAudioFxChain => ({
version: 1,
nodes: [{ type: "highpass", enabled: true, params: { frequency, q: 0.707, poles: "1" } }],
});
const built = buildFxChain(ctx, chain(300));
expect(built.update(chain(2000))).toBe(false);
// A two-pole filter is a BiquadFilterNode and still updates in place.
const twoPole = (frequency: number): HfAudioFxChain => ({
version: 1,
nodes: [{ type: "highpass", enabled: true, params: { frequency, q: 0.707, poles: "2" } }],
});
expect(buildFxChain(ctx, twoPole(300)).update(twoPole(2000))).toBe(true);
});
});
+43 -8
View File
@@ -38,9 +38,22 @@ export function synthesizeReverbImpulse(
}; };
const cutoff = Math.max(0.001, 1 - Math.max(0, Math.min(1, damping))); const cutoff = Math.max(0.001, 1 - Math.max(0, Math.min(1, damping)));
let lp = 0; let lp = 0;
let energy = 0;
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
lp += cutoff * (rand() - lp); lp += cutoff * (rand() - lp);
out[i] = lp * Math.pow(1 - i / length, 2.5); const sample = lp * Math.pow(1 - i / length, 2.5);
out[i] = sample;
energy += sample * sample;
}
// Scale to unit energy. A ConvolverNode applies the impulse's gain whole (the
// graph sets `normalize = false` so the room is deterministic rather than
// browser-defined), and this impulse is decaying noise whose raw energy runs
// to +33 dB at the default size — loud enough that simply adding a Reverb
// clipped the mix. Normalising here keeps the wet knob meaning what it says
// and keeps preview and render identical, since both convolve this buffer.
const norm = Math.sqrt(energy);
if (norm > 0) {
for (let i = 0; i < length; i++) out[i] = (out[i] ?? 0) / norm;
} }
return out; return out;
} }
@@ -216,12 +229,19 @@ const PHASER_STAGES = 6;
const allpassPhaser: Builder = (ctx, p) => { const allpassPhaser: Builder = (ctx, p) => {
const input = ctx.createGain(); const input = ctx.createGain();
const out = ctx.createGain(); const out = ctx.createGain();
// aphaser's in_gain/out_gain trim the signal entering and leaving the effect.
// Wiring them to the wet and dry legs instead made "Input" mute the dry path
// and let the two defaults sum above unity, so inserting a phaser raised the
// track level.
const inTrim = ctx.createGain();
const outTrim = ctx.createGain();
const lfo = ctx.createOscillator(); const lfo = ctx.createOscillator();
const depth = ctx.createGain(); const depth = ctx.createGain();
const wet = ctx.createGain(); const wet = ctx.createGain();
const dry = ctx.createGain(); const dry = ctx.createGain();
const stages: BiquadFilterNode[] = []; const stages: BiquadFilterNode[] = [];
let node: AudioNode = input; input.connect(inTrim);
let node: AudioNode = inTrim;
for (let i = 0; i < PHASER_STAGES; i++) { for (let i = 0; i < PHASER_STAGES; i++) {
const ap = ctx.createBiquadFilter(); const ap = ctx.createBiquadFilter();
ap.type = "allpass"; ap.type = "allpass";
@@ -232,9 +252,14 @@ const allpassPhaser: Builder = (ctx, p) => {
stages.push(ap); stages.push(ap);
} }
lfo.connect(depth); lfo.connect(depth);
// aphaser's type 0 is triangular, 1 sinusoidal. The builder never set this, so
// the declared default ("Triangular") was silently a sine. An OscillatorNode
// has no triangle-with-the-same-phase primitive to switch to, so triangle is
// the node's own "triangle" type.
lfo.start(); lfo.start();
node.connect(wet).connect(out); node.connect(wet).connect(outTrim);
input.connect(dry).connect(out); inTrim.connect(dry).connect(outTrim);
outTrim.connect(out);
const apply = (v: HfAudioFxParamValues): void => { const apply = (v: HfAudioFxParamValues): void => {
// aphaser sweeps around a centre derived from its delay; mirror the range // aphaser sweeps around a centre derived from its delay; mirror the range
// rather than the exact curve, and let the parity harness score it. // rather than the exact curve, and let the parity harness score it.
@@ -242,8 +267,12 @@ const allpassPhaser: Builder = (ctx, p) => {
for (const ap of stages) ap.frequency.value = centre; for (const ap of stages) ap.frequency.value = centre;
depth.gain.value = centre * n(v.decay); depth.gain.value = centre * n(v.decay);
lfo.frequency.value = n(v.speed); lfo.frequency.value = n(v.speed);
wet.gain.value = n(v.out_gain); lfo.type = String(v.type) === "1" ? "sine" : "triangle";
dry.gain.value = n(v.in_gain); inTrim.gain.value = n(v.in_gain);
outTrim.gain.value = n(v.out_gain);
// Summed at unity: the sweep is the effect, not a blend control.
wet.gain.value = 1;
dry.gain.value = 1;
}; };
apply(p); apply(p);
return { return {
@@ -256,7 +285,7 @@ const allpassPhaser: Builder = (ctx, p) => {
} catch { } catch {
/* already stopped */ /* already stopped */
} }
[input, out, depth, wet, dry, ...stages].forEach((x) => x.disconnect()); [input, out, inTrim, outTrim, depth, wet, dry, ...stages].forEach((x) => x.disconnect());
}, },
}; };
}; };
@@ -351,7 +380,13 @@ function shapeOf(chain: HfAudioFxChain): string {
.map((node) => { .map((node) => {
const p = normalizeAudioFxParams(node.type, node.params); const p = normalizeAudioFxParams(node.type, node.params);
const poles = p.poles !== undefined ? `:${p.poles}` : ""; const poles = p.poles !== undefined ? `:${p.poles}` : "";
return `${node.type}${poles}`; // A one-pole filter is an IIRFilterNode whose coefficients are fixed at
// construction, so its cutoff cannot be pushed into the running graph.
// Carrying the frequency here makes a cutoff change rebuild instead of
// being pushed into a no-op updater — which is what let preview keep
// filtering at the old frequency while the render used the new one.
const fixedFreq = String(p.poles) === "1" ? `@${p.frequency}` : "";
return `${node.type}${poles}${fixedFreq}`;
}) })
.join("|"); .join("|");
} }
+41 -20
View File
@@ -12,18 +12,28 @@
export const AUDIO_FX_WORKLET_SOURCE = ` export const AUDIO_FX_WORKLET_SOURCE = `
const dbToLin = (db) => Math.pow(10, db / 20); const dbToLin = (db) => Math.pow(10, db / 20);
/** One-pole envelope follower shared by the dynamics processors. */ /**
class Env { * One-pole envelope followers, one per channel.
constructor(attackMs, releaseMs) { this.set(attackMs, releaseMs); this.value = 0; } *
* Per channel matters: the followers advance once per sample, so a single shared
* follower stepped once per channel per sample. On stereo that ran a 20 ms attack
* as 10 ms, and gave the right channel a gain computed from an envelope that had
* already traversed the left — so the two channels ducked by different amounts
* from the same input and the image pumped.
*/
class EnvBank {
constructor(attackMs, releaseMs) { this.set(attackMs, releaseMs); this.values = []; }
set(attackMs, releaseMs) { set(attackMs, releaseMs) {
this.a = Math.exp(-1 / (sampleRate * Math.max(1e-5, attackMs / 1000))); this.a = Math.exp(-1 / (sampleRate * Math.max(1e-5, attackMs / 1000)));
this.r = Math.exp(-1 / (sampleRate * Math.max(1e-5, releaseMs / 1000))); this.r = Math.exp(-1 / (sampleRate * Math.max(1e-5, releaseMs / 1000)));
} }
push(x) { push(ch, x) {
const prev = this.values[ch] ?? 0;
const m = Math.abs(x); const m = Math.abs(x);
const c = m > this.value ? this.a : this.r; const c = m > prev ? this.a : this.r;
this.value = m + c * (this.value - m); const next = m + c * (prev - m);
return this.value; this.values[ch] = next;
return next;
} }
} }
@@ -41,7 +51,7 @@ class HfCompressor extends AudioWorkletProcessor {
constructor(o) { constructor(o) {
super(); super();
this.p = o.processorOptions || {}; this.p = o.processorOptions || {};
this.env = new Env(this.p.attack ?? 20, this.p.release ?? 250); this.env = new EnvBank(this.p.attack ?? 20, this.p.release ?? 250);
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
this.p = { ...this.p, ...e.data }; this.p = { ...this.p, ...e.data };
this.env.set(this.p.attack ?? 20, this.p.release ?? 250); this.env.set(this.p.attack ?? 20, this.p.release ?? 250);
@@ -59,7 +69,7 @@ class HfCompressor extends AudioWorkletProcessor {
const inp = i[ch], out = o[ch]; const inp = i[ch], out = o[ch];
for (let n = 0; n < inp.length; n++) { for (let n = 0; n < inp.length; n++) {
const x = inp[n]; const x = inp[n];
const env = this.env.push(x); const env = this.env.push(ch, x);
const envDb = env > 1e-9 ? 20 * Math.log10(env) : -200; const envDb = env > 1e-9 ? 20 * Math.log10(env) : -200;
const g = dbToLin(kneeGain(envDb, p.threshold ?? -24, p.ratio ?? 4, kneeDb)); const g = dbToLin(kneeGain(envDb, p.threshold ?? -24, p.ratio ?? 4, kneeDb));
out[n] = x * g * makeup * mix + x * (1 - mix); out[n] = x * g * makeup * mix + x * (1 - mix);
@@ -74,7 +84,7 @@ class HfLimiter extends AudioWorkletProcessor {
constructor(o) { constructor(o) {
super(); super();
this.p = o.processorOptions || {}; this.p = o.processorOptions || {};
this.env = new Env(this.p.attack ?? 5, this.p.release ?? 50); this.env = new EnvBank(this.p.attack ?? 5, this.p.release ?? 50);
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
this.p = { ...this.p, ...e.data }; this.p = { ...this.p, ...e.data };
this.env.set(this.p.attack ?? 5, this.p.release ?? 50); this.env.set(this.p.attack ?? 5, this.p.release ?? 50);
@@ -89,7 +99,7 @@ class HfLimiter extends AudioWorkletProcessor {
const inp = i[ch], out = o[ch]; const inp = i[ch], out = o[ch];
for (let n = 0; n < inp.length; n++) { for (let n = 0; n < inp.length; n++) {
const x = inp[n]; const x = inp[n];
const env = this.env.push(x); const env = this.env.push(ch, x);
// Only ever attenuate: a limiter that can raise level is a compressor. // Only ever attenuate: a limiter that can raise level is a compressor.
const g = env > ceiling ? ceiling / env : 1; const g = env > ceiling ? ceiling / env : 1;
out[n] = x * g * outGain; out[n] = x * g * outGain;
@@ -104,8 +114,8 @@ class HfGate extends AudioWorkletProcessor {
constructor(o) { constructor(o) {
super(); super();
this.p = o.processorOptions || {}; this.p = o.processorOptions || {};
this.env = new Env(this.p.attack ?? 1, this.p.release ?? 100); this.env = new EnvBank(this.p.attack ?? 1, this.p.release ?? 100);
this.gain = 1; this.gains = [];
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
this.p = { ...this.p, ...e.data }; this.p = { ...this.p, ...e.data };
this.env.set(this.p.attack ?? 1, this.p.release ?? 100); this.env.set(this.p.attack ?? 1, this.p.release ?? 100);
@@ -118,11 +128,16 @@ class HfGate extends AudioWorkletProcessor {
const threshold = dbToLin(p.threshold ?? -35); const threshold = dbToLin(p.threshold ?? -35);
const floor = dbToLin(p.range ?? -24); const floor = dbToLin(p.range ?? -24);
const ratio = p.ratio ?? 10; const ratio = p.ratio ?? 10;
// Knee is declared in the registry as a ratio, like the compressor's; a
// hard threshold ignored it and chattered on material sitting right at the
// gate point.
const kneeDb = 20 * Math.log10(Math.max(1.0001, p.knee ?? 2.83));
const kneeLin = dbToLin((p.threshold ?? -35) + kneeDb);
for (let ch = 0; ch < i.length; ch++) { for (let ch = 0; ch < i.length; ch++) {
const inp = i[ch], out = o[ch]; const inp = i[ch], out = o[ch];
for (let n = 0; n < inp.length; n++) { for (let n = 0; n < inp.length; n++) {
const x = inp[n]; const x = inp[n];
const env = this.env.push(x); const env = this.env.push(ch, x);
let target = 1; let target = 1;
if (env < threshold) { if (env < threshold) {
const under = env > 1e-9 ? threshold / env : 1e9; const under = env > 1e-9 ? threshold / env : 1e9;
@@ -130,9 +145,11 @@ class HfGate extends AudioWorkletProcessor {
if (!isFinite(target)) target = floor; if (!isFinite(target)) target = floor;
} }
// Smooth toward the target so the gate does not click on every sample. // Smooth toward the target so the gate does not click on every sample.
const c = target < this.gain ? this.env.r : this.env.a; const held = this.gains[ch] ?? 1;
this.gain = target + c * (this.gain - target); const c = target < held ? this.env.r : this.env.a;
out[n] = x * this.gain; const smoothed = target + c * (held - target);
this.gains[ch] = smoothed;
out[n] = x * smoothed;
} }
} }
return true; return true;
@@ -144,7 +161,7 @@ class HfBitcrush extends AudioWorkletProcessor {
constructor(o) { constructor(o) {
super(); super();
this.p = o.processorOptions || {}; this.p = o.processorOptions || {};
this.hold = 0; this.holds = [];
this.held = []; this.held = [];
this.port.onmessage = (e) => { this.p = { ...this.p, ...e.data }; }; this.port.onmessage = (e) => { this.p = { ...this.p, ...e.data }; };
} }
@@ -158,10 +175,14 @@ class HfBitcrush extends AudioWorkletProcessor {
for (let ch = 0; ch < i.length; ch++) { for (let ch = 0; ch < i.length; ch++) {
const inp = i[ch], out = o[ch]; const inp = i[ch], out = o[ch];
if (this.held[ch] === undefined) this.held[ch] = 0; if (this.held[ch] === undefined) this.held[ch] = 0;
if (this.holds[ch] === undefined) this.holds[ch] = 0;
for (let n = 0; n < inp.length; n++) { for (let n = 0; n < inp.length; n++) {
if (this.hold === 0) this.held[ch] = Math.round(inp[n] * levels) / levels; // Per channel: advancing one shared counter only on the last channel
// left every earlier channel either unheld or frozen for a whole
// 128-sample quantum, clicking once a block.
if (this.holds[ch] === 0) this.held[ch] = Math.round(inp[n] * levels) / levels;
out[n] = this.held[ch] * mix + inp[n] * (1 - mix); out[n] = this.held[ch] * mix + inp[n] * (1 - mix);
if (ch === i.length - 1) this.hold = (this.hold + 1) % step; this.holds[ch] = (this.holds[ch] + 1) % step;
} }
} }
return true; return true;