fix(core): aim the phaser's trim lanes at the trims, not at the pinned wet/dry

`in_gain` and `out_gain` trim the signal entering and leaving the phaser, and
apply() drives them through inTrim/outTrim while pinning wet and dry to 1. The
automation map aimed both lanes at wet.gain and dry.gain instead — so an
envelope on either knob modulated a constant, the trim it was supposed to move
stayed wherever apply() last left it, and any values-only edit re-ran apply()
and slammed wet/dry back to 1 over the running ramp.

Heard as: "fade the phaser to silence" ends at full dry signal. Wrong in
preview and in the render, since both share this builder.

The builder's own comment records that wiring these knobs to wet/dry was
already found wrong once and moved to the trims; that refactor updated apply()
and missed the map. The existing exposure test only asserted that SOME param
was present under each key, which a mis-aimed target passes — the new test
asserts by value, which is the only thing a lane actually cares about.
This commit is contained in:
Vance Ingalls
2026-08-08 10:40:19 -07:00
parent 714f662edd
commit accac99843
2 changed files with 30 additions and 2 deletions
@@ -438,3 +438,26 @@ describe("automatable parameters", () => {
expect(onePole.automation?.frequency).toBeUndefined();
});
});
describe("phaser automation targets", () => {
/**
* `in_gain` and `out_gain` trim the signal entering and leaving the effect,
* which the builder drives through inTrim/outTrim while pinning wet and dry
* to 1. The automation map used to aim both lanes at wet/dry — so an envelope
* modulated a constant, the trim it was supposed to move stayed frozen, and
* "fade the phaser out" left the dry leg playing at full level.
*
* Asserted by VALUE rather than by node identity: the trims are internal, and
* the only honest question is whether the param a lane would drive is the one
* the knob sets.
*/
it("drives the trims a lane is named for, not the pinned wet/dry pair", () => {
const handle = buildFxNode(ctx() as unknown as BaseAudioContext, "phaser", {
...defaultAudioFxParams("phaser"),
in_gain: 0.25,
out_gain: 0.5,
});
expect(handle.automation?.in_gain?.[0]?.param.value).toBeCloseTo(0.25, 6);
expect(handle.automation?.out_gain?.[0]?.param.value).toBeCloseTo(0.5, 6);
});
});
+7 -2
View File
@@ -360,8 +360,13 @@ const allpassPhaser: Builder = (ctx, p) => {
// frequency at once — not one knob, one param — so they stay unautomated.
automation: {
speed: [{ param: lfo.frequency }],
in_gain: [{ param: dry.gain }],
out_gain: [{ param: wet.gain }],
// The trims, not wet/dry. apply() drives inTrim/outTrim from these knobs
// and pins wet and dry to 1 — so a lane aimed at wet/dry modulated a
// constant and left the trim frozen, and the next values-only edit slammed
// it back over the running envelope. The comment above records that this
// wiring was already moved once; the automation map was missed.
in_gain: [{ param: inTrim.gain }],
out_gain: [{ param: outTrim.gain }],
},
dispose: () => {
try {