mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
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:
co-authored by
Claude Opus 5
parent
f59612850d
commit
c3291f2912
@@ -146,6 +146,17 @@
|
||||
"file": "packages/studio/src/utils/studioHelpers.ts",
|
||||
"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
|
||||
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,9 +38,22 @@ export function synthesizeReverbImpulse(
|
||||
};
|
||||
const cutoff = Math.max(0.001, 1 - Math.max(0, Math.min(1, damping)));
|
||||
let lp = 0;
|
||||
let energy = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
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;
|
||||
}
|
||||
@@ -216,12 +229,19 @@ const PHASER_STAGES = 6;
|
||||
const allpassPhaser: Builder = (ctx, p) => {
|
||||
const input = 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 depth = ctx.createGain();
|
||||
const wet = ctx.createGain();
|
||||
const dry = ctx.createGain();
|
||||
const stages: BiquadFilterNode[] = [];
|
||||
let node: AudioNode = input;
|
||||
input.connect(inTrim);
|
||||
let node: AudioNode = inTrim;
|
||||
for (let i = 0; i < PHASER_STAGES; i++) {
|
||||
const ap = ctx.createBiquadFilter();
|
||||
ap.type = "allpass";
|
||||
@@ -232,9 +252,14 @@ const allpassPhaser: Builder = (ctx, p) => {
|
||||
stages.push(ap);
|
||||
}
|
||||
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();
|
||||
node.connect(wet).connect(out);
|
||||
input.connect(dry).connect(out);
|
||||
node.connect(wet).connect(outTrim);
|
||||
inTrim.connect(dry).connect(outTrim);
|
||||
outTrim.connect(out);
|
||||
const apply = (v: HfAudioFxParamValues): void => {
|
||||
// aphaser sweeps around a centre derived from its delay; mirror the range
|
||||
// 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;
|
||||
depth.gain.value = centre * n(v.decay);
|
||||
lfo.frequency.value = n(v.speed);
|
||||
wet.gain.value = n(v.out_gain);
|
||||
dry.gain.value = n(v.in_gain);
|
||||
lfo.type = String(v.type) === "1" ? "sine" : "triangle";
|
||||
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);
|
||||
return {
|
||||
@@ -256,7 +285,7 @@ const allpassPhaser: Builder = (ctx, p) => {
|
||||
} catch {
|
||||
/* 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) => {
|
||||
const p = normalizeAudioFxParams(node.type, node.params);
|
||||
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("|");
|
||||
}
|
||||
|
||||
@@ -12,18 +12,28 @@
|
||||
export const AUDIO_FX_WORKLET_SOURCE = `
|
||||
const dbToLin = (db) => Math.pow(10, db / 20);
|
||||
|
||||
/** One-pole envelope follower shared by the dynamics processors. */
|
||||
class Env {
|
||||
constructor(attackMs, releaseMs) { this.set(attackMs, releaseMs); this.value = 0; }
|
||||
/**
|
||||
* One-pole envelope followers, one per channel.
|
||||
*
|
||||
* 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) {
|
||||
this.a = Math.exp(-1 / (sampleRate * Math.max(1e-5, attackMs / 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 c = m > this.value ? this.a : this.r;
|
||||
this.value = m + c * (this.value - m);
|
||||
return this.value;
|
||||
const c = m > prev ? this.a : this.r;
|
||||
const next = m + c * (prev - m);
|
||||
this.values[ch] = next;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +51,7 @@ class HfCompressor extends AudioWorkletProcessor {
|
||||
constructor(o) {
|
||||
super();
|
||||
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.p = { ...this.p, ...e.data };
|
||||
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];
|
||||
for (let n = 0; n < inp.length; 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 g = dbToLin(kneeGain(envDb, p.threshold ?? -24, p.ratio ?? 4, kneeDb));
|
||||
out[n] = x * g * makeup * mix + x * (1 - mix);
|
||||
@@ -74,7 +84,7 @@ class HfLimiter extends AudioWorkletProcessor {
|
||||
constructor(o) {
|
||||
super();
|
||||
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.p = { ...this.p, ...e.data };
|
||||
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];
|
||||
for (let n = 0; n < inp.length; 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.
|
||||
const g = env > ceiling ? ceiling / env : 1;
|
||||
out[n] = x * g * outGain;
|
||||
@@ -104,8 +114,8 @@ class HfGate extends AudioWorkletProcessor {
|
||||
constructor(o) {
|
||||
super();
|
||||
this.p = o.processorOptions || {};
|
||||
this.env = new Env(this.p.attack ?? 1, this.p.release ?? 100);
|
||||
this.gain = 1;
|
||||
this.env = new EnvBank(this.p.attack ?? 1, this.p.release ?? 100);
|
||||
this.gains = [];
|
||||
this.port.onmessage = (e) => {
|
||||
this.p = { ...this.p, ...e.data };
|
||||
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 floor = dbToLin(p.range ?? -24);
|
||||
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++) {
|
||||
const inp = i[ch], out = o[ch];
|
||||
for (let n = 0; n < inp.length; n++) {
|
||||
const x = inp[n];
|
||||
const env = this.env.push(x);
|
||||
const env = this.env.push(ch, x);
|
||||
let target = 1;
|
||||
if (env < threshold) {
|
||||
const under = env > 1e-9 ? threshold / env : 1e9;
|
||||
@@ -130,9 +145,11 @@ class HfGate extends AudioWorkletProcessor {
|
||||
if (!isFinite(target)) target = floor;
|
||||
}
|
||||
// Smooth toward the target so the gate does not click on every sample.
|
||||
const c = target < this.gain ? this.env.r : this.env.a;
|
||||
this.gain = target + c * (this.gain - target);
|
||||
out[n] = x * this.gain;
|
||||
const held = this.gains[ch] ?? 1;
|
||||
const c = target < held ? this.env.r : this.env.a;
|
||||
const smoothed = target + c * (held - target);
|
||||
this.gains[ch] = smoothed;
|
||||
out[n] = x * smoothed;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -144,7 +161,7 @@ class HfBitcrush extends AudioWorkletProcessor {
|
||||
constructor(o) {
|
||||
super();
|
||||
this.p = o.processorOptions || {};
|
||||
this.hold = 0;
|
||||
this.holds = [];
|
||||
this.held = [];
|
||||
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++) {
|
||||
const inp = i[ch], out = o[ch];
|
||||
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++) {
|
||||
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);
|
||||
if (ch === i.length - 1) this.hold = (this.hold + 1) % step;
|
||||
this.holds[ch] = (this.holds[ch] + 1) % step;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user