Skip to content

Commit e021065

Browse files
committed
feat: ramp a whole preset with one lane, and make Off its zero
A preset's nodes share no automatable parameter, and its worklet effects (compressor, limiter) expose no AudioParams at all — Clean Voice could only ever have automated 3 of its 5 nodes. So there was nothing to aim a lane at, and no way to bring a preset in gradually. **The graph wraps each preset's run in a wet/dry pair.** The rest of the chain stays a strict series, which is right for an effect the author placed: it is either in the path or it is not. A preset is not one effect — it is several added as a unit, and "how much of it is applied" is a question about the unit. One crossfade answers it for every preset including the worklet ones, and it cannot go half-wrong the way seven lanes can. The dry leg bridges the whole run, so amount 0 is the untouched signal rather than a quieter version of the processed one. Consecutive nodes only, matching what the rack already brackets: a preset pulled apart by a reorder is no longer a unit, and wrapping across the gap would route the effect between its members through the dry leg too. **`fx.preset.<id>` is a new lane target.** Parsed before the 3-part fx form, which it structurally is — with a reserved node id an effect can never have, since ids are minted `n1`, `n2`, …. It resolves only for a preset the chain actually carries, so a lane left behind by a removed preset is dropped at read time, the same contract an orphaned node lane has. **Off is amount 0, not `enabled: false`.** The switch and the lane are now the same value — one notion of how much of a preset is applied, with the switch at its two ends. Writing `enabled` would take the nodes out of the graph, which a lane cannot do part-way and cannot do without a rebuild that restarts the audio. The panel also grows an Amount slider, so half-applied is something an author can just set. Changing the amount is a values-only update, pushed into the running graph. A `presetAmount` in a chain is clamped to 0..1 on the way in: two gains in opposition, so past 1 the dry leg goes negative rather than the preset getting louder. Falsified: a blend ignoring the stored amount, an amount not pushed on update, and the clamp each fail a test. The round-trip — the §4 invariant that a new node field must be in BOTH the parser and the writer — had no test until a mutation survived one; dropping it from either half now fails. core 1770 (113 files) · studio 3703 + 18 todo · engine services 739 + 3.
1 parent 8fe682b commit e021065

11 files changed

Lines changed: 490 additions & 38 deletions

packages/core/src/audio/audioFxAutomation.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,24 @@ export function scheduleChainAutomation(
245245
chain: HfAudioFxChain,
246246
nodes: readonly AutomatableNode[],
247247
timing: AutomationTiming,
248+
/** The wet/dry blend around each preset run, from `FxChainHandle.presets`. */
249+
presets?: Record<string, FxParamTarget[]>,
248250
): FxParamTarget[] {
249251
const byId = new Map(nodes.filter((n) => n.id).map((n) => [n.id as string, n.handle]));
250252
const scheduled: FxParamTarget[] = [];
251253
for (const lane of automation.lanes) {
252254
const parsed = parseAutomationTarget(lane.target);
253-
if (!parsed || parsed.kind !== "fx") continue;
254-
const targets = byId.get(parsed.nodeId)?.automation?.[parsed.param];
255+
if (!parsed) continue;
256+
// A whole-preset lane drives the wet/dry blend the graph wrapped its run in,
257+
// rather than any node's parameter — which is the point of it: a preset's
258+
// nodes share no automatable parameter, and its worklet effects expose none
259+
// at all.
260+
const targets =
261+
parsed.kind === "preset"
262+
? presets?.[parsed.presetId]
263+
: parsed.kind === "fx"
264+
? byId.get(parsed.nodeId)?.automation?.[parsed.param]
265+
: undefined;
255266
if (!targets || targets.length === 0) continue;
256267
const range = resolveAutomationRange(lane.target, chain);
257268
if (!range) continue;

packages/core/src/audio/audioFxGraph.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,3 +624,77 @@ describe("chain update keeps ids with their effects", () => {
624624
expect(handle.nodes.map((n) => n.id)).toEqual(["n2", "n1"]);
625625
});
626626
});
627+
628+
describe("a preset's run is wrapped in a wet/dry blend", () => {
629+
/** Two nodes from one preset, with an ordinary effect after them. */
630+
const chainWith = (amount?: number): HfAudioFxChain => ({
631+
version: 1,
632+
nodes: [
633+
{
634+
type: "highpass",
635+
id: "p1",
636+
fromPreset: "telephone",
637+
enabled: true,
638+
...(amount === undefined ? {} : { presetAmount: amount }),
639+
params: defaultAudioFxParams("highpass"),
640+
},
641+
{
642+
type: "lowpass",
643+
id: "p2",
644+
fromPreset: "telephone",
645+
enabled: true,
646+
params: defaultAudioFxParams("lowpass"),
647+
},
648+
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
649+
],
650+
});
651+
652+
it("exposes one blend for the whole preset, not one per node", () => {
653+
// The reason this exists: a preset's nodes share no automatable parameter,
654+
// and its worklet effects expose no AudioParams at all, so there is nothing
655+
// to aim a lane at node-by-node.
656+
const built = buildFxChain(asCtx(ctx()), chainWith());
657+
expect(Object.keys(built.presets)).toEqual(["telephone"]);
658+
// Two gains in opposition, the same shape an effect's own mix knob has.
659+
expect(built.presets.telephone).toHaveLength(2);
660+
});
661+
662+
it("blends dry against wet at the stored amount", () => {
663+
const built = buildFxChain(asCtx(ctx()), chainWith(0.25));
664+
const [wet, dry] = built.presets.telephone ?? [];
665+
expect(wet?.param.value).toBeCloseTo(0.25, 6);
666+
expect(dry?.param.value).toBeCloseTo(0.75, 6);
667+
});
668+
669+
it("is fully applied when nothing says otherwise", () => {
670+
// Every chain written before this shipped means "all of it".
671+
const [wet, dry] = buildFxChain(asCtx(ctx()), chainWith()).presets.telephone ?? [];
672+
expect(wet?.param.value).toBe(1);
673+
expect(dry?.param.value).toBe(0);
674+
});
675+
676+
it("pushes a changed amount into the running graph rather than rebuilding", () => {
677+
// Switching a preset off is a value change, and a rebuild would restart the
678+
// audio underneath it.
679+
const built = buildFxChain(asCtx(ctx()), chainWith(1));
680+
expect(built.update(chainWith(0))).toBe(true);
681+
const [wet, dry] = built.presets.telephone ?? [];
682+
expect(wet?.param.value).toBe(0);
683+
expect(dry?.param.value).toBe(1);
684+
});
685+
686+
it("wraps nothing around effects the author placed themselves", () => {
687+
const built = buildFxChain(asCtx(ctx()), chain("peaking", "reverb"));
688+
expect(Object.keys(built.presets)).toEqual([]);
689+
});
690+
691+
it("unwires the blend on dispose", () => {
692+
// The wrap belongs to the chain rather than to any effect, so it is not in
693+
// `handles` — without this a rebuild leaves a crossfade connected to the
694+
// graph it used to bridge.
695+
const c = ctx();
696+
buildFxChain(asCtx(c), chainWith(0.5)).dispose();
697+
const live = c.created.filter((n) => n.kind === "gain" && !n.disconnected);
698+
expect(live).toEqual([]);
699+
});
700+
});

packages/core/src/audio/audioFxGraph.ts

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getAudioFxDef,
1414
normalizeAudioFxParams,
1515
type HfAudioFxChain,
16+
type HfAudioFxNode,
1617
type HfAudioFxParamValues,
1718
} from "../audioFx.js";
1819
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
@@ -535,11 +536,45 @@ export interface FxChainHandle {
535536
output: AudioNode;
536537
/** Built effects in chain order, carrying the node ids lanes address. */
537538
nodes: { id?: string; type: string; handle: FxNodeHandle }[];
539+
/**
540+
* The wet/dry blend around each preset run, by preset id — where a
541+
* whole-preset lane writes. Two gains in opposition, the same shape
542+
* `mixTargets` builds for an effect's own mix knob.
543+
*/
544+
presets: Record<string, FxParamTarget[]>;
538545
/** Re-parameterise in place when the shape is unchanged; false if a rebuild is needed. */
539546
update(chain: HfAudioFxChain): boolean;
540547
dispose(): void;
541548
}
542549

550+
/**
551+
* Consecutive nodes grouped by the preset that wrote them.
552+
*
553+
* `amount` comes off the nodes themselves — a preset is bypassed by setting its
554+
* members' `enabled` to false everywhere else in the codebase, and the wrap has
555+
* to agree with that or the switch and the lane would fight. Absent means fully
556+
* applied, which is what every chain written before this shipped means.
557+
*/
558+
function presetRuns(
559+
nodes: readonly HfAudioFxNode[],
560+
): { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] {
561+
const out: { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] = [];
562+
for (const node of nodes) {
563+
const preset = node.fromPreset;
564+
const last = out.at(-1);
565+
if (last && last.preset === preset) last.nodes.push(node);
566+
else {
567+
const amount = typeof node.presetAmount === "number" ? node.presetAmount : 1;
568+
out.push({
569+
...(preset ? { preset } : {}),
570+
amount: Math.min(1, Math.max(0, amount)),
571+
nodes: [node],
572+
});
573+
}
574+
}
575+
return out;
576+
}
577+
543578
/**
544579
* A signature of everything that changes the graph's *shape* rather than its
545580
* parameter values. When this is unchanged an update can just push new values
@@ -581,21 +616,67 @@ export function buildFxChain(
581616
const input = ctx.createGain();
582617
const output = ctx.createGain();
583618
const handles: { id?: string; type: string; handle: FxNodeHandle }[] = [];
619+
const presets: { id: string; entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode }[] =
620+
[];
621+
622+
/**
623+
* A preset's consecutive nodes, wrapped in a wet/dry pair.
624+
*
625+
* The rest of the chain is a strict series, which is right for an effect the
626+
* author placed: it is either in the path or it is not. A preset is not one
627+
* effect, though — it is several the author added as a unit, and "how much of
628+
* it is applied" is a question about the unit. Its nodes share no automatable
629+
* parameter, and the worklet ones expose no AudioParams at all, so there is
630+
* nothing to aim a lane at node-by-node. One crossfade around the run is the
631+
* whole answer, and it cannot go half-wrong the way seven lanes can.
632+
*
633+
* Consecutive only, matching what the rack brackets: a preset pulled apart by
634+
* a reorder is no longer a unit, and wrapping across the gap would route the
635+
* effect between its members through the dry leg too.
636+
*/
637+
const runs = presetRuns(enabledAudioFxNodes(chain));
584638

585639
let tail: AudioNode = input;
586-
for (const node of enabledAudioFxNodes(chain)) {
587-
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
588-
tail.connect(handle.input);
589-
tail = handle.output;
590-
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
640+
for (const run of runs) {
641+
let wrap: { entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode } | null = null;
642+
if (run.preset) {
643+
const entry = ctx.createGain();
644+
const dry = ctx.createGain();
645+
const wet = ctx.createGain();
646+
const join = ctx.createGain();
647+
wet.gain.value = run.amount;
648+
dry.gain.value = 1 - run.amount;
649+
tail.connect(entry);
650+
// The dry leg bridges the whole run: it leaves before the first effect and
651+
// rejoins after the last, which is what makes amount 0 the untouched
652+
// signal rather than a quieter version of the processed one.
653+
entry.connect(dry).connect(join);
654+
wrap = { entry, wet, dry, join };
655+
tail = entry;
656+
}
657+
for (const node of run.nodes) {
658+
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
659+
tail.connect(handle.input);
660+
tail = handle.output;
661+
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
662+
}
663+
if (wrap && run.preset) {
664+
tail.connect(wrap.wet).connect(wrap.join);
665+
presets.push({ id: run.preset, ...wrap });
666+
tail = wrap.join;
667+
}
591668
}
592669
tail.connect(output);
593670

594671
const shape = shapeOf(chain);
595672

673+
const presetTargets: Record<string, FxParamTarget[]> = {};
674+
for (const p of presets) presetTargets[p.id] = mixTargets(p.wet.gain, p.dry.gain);
675+
596676
return {
597677
input,
598678
output,
679+
presets: presetTargets,
599680
nodes: handles,
600681
update(next) {
601682
if (shapeOf(next) !== shape) return false;
@@ -612,13 +693,32 @@ export function buildFxChain(
612693
if (node.id === undefined) delete held.id;
613694
else held.id = node.id;
614695
});
696+
// The blend is a value like any other: switching a preset off writes
697+
// `presetAmount`, and pushing it into the running graph is what keeps that
698+
// from being a rebuild — and from restarting the audio underneath it.
699+
for (const run of presetRuns(enabledAudioFxNodes(next))) {
700+
if (!run.preset) continue;
701+
const wrap = presets.find((p) => p.id === run.preset);
702+
if (!wrap) continue;
703+
wrap.wet.gain.value = run.amount;
704+
wrap.dry.gain.value = 1 - run.amount;
705+
}
615706
// `shape` is not reassigned: the early return above already established
616707
// that `shapeOf(next)` equals it, so recomputing was a whole normalise +
617708
// join per observer tick to write back the string that was already there.
618709
return true;
619710
},
620711
dispose() {
621712
for (const { handle } of handles) handle.dispose();
713+
// The wrap is not one of `handles` — it belongs to the chain rather than
714+
// to any effect — so it has to be unwired here or a rebuild leaves a
715+
// crossfade still connected to the graph it used to bridge.
716+
for (const { entry, wet, dry, join } of presets) {
717+
entry.disconnect();
718+
wet.disconnect();
719+
dry.disconnect();
720+
join.disconnect();
721+
}
622722
input.disconnect();
623723
output.disconnect();
624724
},

packages/core/src/audioAutomation.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,22 @@ export class AudioAutomationError extends Error {
7979

8080
export const VOLUME_TARGET = "volume";
8181

82-
export type HfAutomationTarget = { kind: "volume" } | { kind: "fx"; nodeId: string; param: string };
82+
export type HfAutomationTarget =
83+
| { kind: "volume" }
84+
| { kind: "fx"; nodeId: string; param: string }
85+
| { kind: "preset"; presetId: string };
8386

8487
/** Split a target string. Returns null for anything unrecognised. */
8588
export function parseAutomationTarget(target: string): HfAutomationTarget | null {
8689
if (target === VOLUME_TARGET) return { kind: "volume" };
8790
const parts = target.split(".");
91+
// `fx.preset.<id>` before the 3-part fx form, because it IS a 3-part fx form
92+
// with a reserved node id — an effect can never be called "preset", since ids
93+
// are minted `n1`, `n2`, ….
94+
if (parts.length === 3 && parts[0] === "fx" && parts[1] === PRESET_TARGET_KEY) {
95+
const presetId = parts[2];
96+
return presetId ? { kind: "preset", presetId } : null;
97+
}
8898
if (parts.length !== 3 || parts[0] !== "fx") return null;
8999
const [, nodeId, param] = parts;
90100
if (!nodeId || !param) return null;
@@ -95,6 +105,33 @@ export function fxAutomationTarget(nodeId: string, param: string): string {
95105
return `fx.${nodeId}.${param}`;
96106
}
97107

108+
/** The reserved node-id slot that marks a whole-preset target. */
109+
const PRESET_TARGET_KEY = "preset";
110+
111+
/**
112+
* How much of a preset is applied, 0..1.
113+
*
114+
* A preset's nodes share no automatable parameter — and its worklet effects
115+
* expose no AudioParams at all — so there is nothing to aim a lane at
116+
* node-by-node. The graph wraps a preset's run in a wet/dry pair instead, and
117+
* this drives the blend: 0 is the dry signal untouched, 1 is the preset fully
118+
* applied, and between them it crossfades.
119+
*/
120+
export function presetAutomationTarget(presetId: string): string {
121+
return `fx.${PRESET_TARGET_KEY}.${presetId}`;
122+
}
123+
124+
/** 0..1 blend, the same shape as a wet/dry mix knob. */
125+
export const PRESET_RANGE: AutomationRange = {
126+
min: 0,
127+
max: 1,
128+
step: 0.01,
129+
unit: "",
130+
label: "Amount",
131+
scale: "linear",
132+
default: 1,
133+
};
134+
98135
/**
99136
* The value range a lane is drawn and clamped against.
100137
*
@@ -136,6 +173,13 @@ export function resolveAutomationRange(
136173
const parsed = parseAutomationTarget(target);
137174
if (!parsed) return null;
138175
if (parsed.kind === "volume") return VOLUME_RANGE;
176+
if (parsed.kind === "preset") {
177+
// Only for a preset the chain actually carries, so a lane left behind by a
178+
// removed preset resolves to nothing and is dropped at read time — the same
179+
// contract an orphaned node lane has.
180+
const present = chain?.nodes.some((n) => n.fromPreset === parsed.presetId);
181+
return present ? { ...PRESET_RANGE, label: `${parsed.presetId} · Amount` } : null;
182+
}
139183
const node = chain?.nodes.find((n) => n.id === parsed.nodeId);
140184
if (!node) return null;
141185
const def = getAudioFxDef(node.type);

packages/core/src/audioFx.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,18 @@ export interface HfAudioFxNode {
832832
* effect type and its bands stay ordinary filters underneath.
833833
*/
834834
fromEq?: string;
835+
836+
/**
837+
* How much of this node's preset is applied, 0..1 — the wet/dry blend the
838+
* graph wraps its run in.
839+
*
840+
* On every node of the run rather than beside the chain, because the chain has
841+
* nowhere else to put it: `HfAudioFxChain` is a version and a list of nodes,
842+
* and a preset is defined by which nodes carry its tag. The graph reads it off
843+
* the first node of each run. Absent means fully applied, which is what every
844+
* chain written before this means.
845+
*/
846+
presetAmount?: number;
835847
/**
836848
* Set on the gain stage the leveller writes, so re-running replaces it rather
837849
* than stacking a second one — the same contract `fromCarve` has.
@@ -890,6 +902,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
890902
label?: unknown;
891903
fromEq?: unknown;
892904
fromLeveller?: unknown;
905+
presetAmount?: unknown;
893906
};
894907
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
895908
throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
@@ -907,6 +920,11 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
907920
...(typeof node.label === "string" && node.label ? { label: node.label } : {}),
908921
...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}),
909922
...(node.fromLeveller === true ? { fromLeveller: true as const } : {}),
923+
// Clamped on the way in: the blend is two gains in opposition, and a value
924+
// outside 0..1 makes the dry leg negative rather than simply loud.
925+
...(typeof node.presetAmount === "number" && Number.isFinite(node.presetAmount)
926+
? { presetAmount: Math.min(1, Math.max(0, node.presetAmount)) }
927+
: {}),
910928
enabled: node.enabled !== false,
911929
params: normalizeAudioFxParams(
912930
node.type,
@@ -934,6 +952,11 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string {
934952
...(node.label ? { label: node.label } : {}),
935953
...(node.fromEq ? { fromEq: node.fromEq } : {}),
936954
...(node.fromLeveller === true ? { fromLeveller: true } : {}),
955+
// Omitted when fully applied, so an untouched preset does not grow a field
956+
// in every chain that carries one.
957+
...(typeof node.presetAmount === "number" && node.presetAmount !== 1
958+
? { presetAmount: node.presetAmount }
959+
: {}),
937960
...(node.enabled === false ? { enabled: false } : {}),
938961
params: normalizeAudioFxParams(node.type, node.params),
939962
})),

0 commit comments

Comments
 (0)