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.
This commit is contained in:
Vance Ingalls
2026-08-10 15:39:06 -07:00
parent e727e39b7f
commit 373cdbbcb8
11 changed files with 490 additions and 38 deletions
+13 -2
View File
@@ -245,13 +245,24 @@ export function scheduleChainAutomation(
chain: HfAudioFxChain,
nodes: readonly AutomatableNode[],
timing: AutomationTiming,
/** The wet/dry blend around each preset run, from `FxChainHandle.presets`. */
presets?: Record<string, FxParamTarget[]>,
): FxParamTarget[] {
const byId = new Map(nodes.filter((n) => n.id).map((n) => [n.id as string, n.handle]));
const scheduled: FxParamTarget[] = [];
for (const lane of automation.lanes) {
const parsed = parseAutomationTarget(lane.target);
if (!parsed || parsed.kind !== "fx") continue;
const targets = byId.get(parsed.nodeId)?.automation?.[parsed.param];
if (!parsed) continue;
// A whole-preset lane drives the wet/dry blend the graph wrapped its run in,
// rather than any node's parameter — which is the point of it: a preset's
// nodes share no automatable parameter, and its worklet effects expose none
// at all.
const targets =
parsed.kind === "preset"
? presets?.[parsed.presetId]
: parsed.kind === "fx"
? byId.get(parsed.nodeId)?.automation?.[parsed.param]
: undefined;
if (!targets || targets.length === 0) continue;
const range = resolveAutomationRange(lane.target, chain);
if (!range) continue;
@@ -624,3 +624,77 @@ describe("chain update keeps ids with their effects", () => {
expect(handle.nodes.map((n) => n.id)).toEqual(["n2", "n1"]);
});
});
describe("a preset's run is wrapped in a wet/dry blend", () => {
/** Two nodes from one preset, with an ordinary effect after them. */
const chainWith = (amount?: number): HfAudioFxChain => ({
version: 1,
nodes: [
{
type: "highpass",
id: "p1",
fromPreset: "telephone",
enabled: true,
...(amount === undefined ? {} : { presetAmount: amount }),
params: defaultAudioFxParams("highpass"),
},
{
type: "lowpass",
id: "p2",
fromPreset: "telephone",
enabled: true,
params: defaultAudioFxParams("lowpass"),
},
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
],
});
it("exposes one blend for the whole preset, not one per node", () => {
// The reason this exists: a preset's nodes share no automatable parameter,
// and its worklet effects expose no AudioParams at all, so there is nothing
// to aim a lane at node-by-node.
const built = buildFxChain(asCtx(ctx()), chainWith());
expect(Object.keys(built.presets)).toEqual(["telephone"]);
// Two gains in opposition, the same shape an effect's own mix knob has.
expect(built.presets.telephone).toHaveLength(2);
});
it("blends dry against wet at the stored amount", () => {
const built = buildFxChain(asCtx(ctx()), chainWith(0.25));
const [wet, dry] = built.presets.telephone ?? [];
expect(wet?.param.value).toBeCloseTo(0.25, 6);
expect(dry?.param.value).toBeCloseTo(0.75, 6);
});
it("is fully applied when nothing says otherwise", () => {
// Every chain written before this shipped means "all of it".
const [wet, dry] = buildFxChain(asCtx(ctx()), chainWith()).presets.telephone ?? [];
expect(wet?.param.value).toBe(1);
expect(dry?.param.value).toBe(0);
});
it("pushes a changed amount into the running graph rather than rebuilding", () => {
// Switching a preset off is a value change, and a rebuild would restart the
// audio underneath it.
const built = buildFxChain(asCtx(ctx()), chainWith(1));
expect(built.update(chainWith(0))).toBe(true);
const [wet, dry] = built.presets.telephone ?? [];
expect(wet?.param.value).toBe(0);
expect(dry?.param.value).toBe(1);
});
it("wraps nothing around effects the author placed themselves", () => {
const built = buildFxChain(asCtx(ctx()), chain("peaking", "reverb"));
expect(Object.keys(built.presets)).toEqual([]);
});
it("unwires the blend on dispose", () => {
// The wrap belongs to the chain rather than to any effect, so it is not in
// `handles` — without this a rebuild leaves a crossfade connected to the
// graph it used to bridge.
const c = ctx();
buildFxChain(asCtx(c), chainWith(0.5)).dispose();
const live = c.created.filter((n) => n.kind === "gain" && !n.disconnected);
expect(live).toEqual([]);
});
});
+105 -5
View File
@@ -13,6 +13,7 @@ import {
getAudioFxDef,
normalizeAudioFxParams,
type HfAudioFxChain,
type HfAudioFxNode,
type HfAudioFxParamValues,
} from "../audioFx.js";
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
@@ -535,11 +536,45 @@ export interface FxChainHandle {
output: AudioNode;
/** Built effects in chain order, carrying the node ids lanes address. */
nodes: { id?: string; type: string; handle: FxNodeHandle }[];
/**
* The wet/dry blend around each preset run, by preset id — where a
* whole-preset lane writes. Two gains in opposition, the same shape
* `mixTargets` builds for an effect's own mix knob.
*/
presets: Record<string, FxParamTarget[]>;
/** Re-parameterise in place when the shape is unchanged; false if a rebuild is needed. */
update(chain: HfAudioFxChain): boolean;
dispose(): void;
}
/**
* Consecutive nodes grouped by the preset that wrote them.
*
* `amount` comes off the nodes themselves — a preset is bypassed by setting its
* members' `enabled` to false everywhere else in the codebase, and the wrap has
* to agree with that or the switch and the lane would fight. Absent means fully
* applied, which is what every chain written before this shipped means.
*/
function presetRuns(
nodes: readonly HfAudioFxNode[],
): { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] {
const out: { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] = [];
for (const node of nodes) {
const preset = node.fromPreset;
const last = out.at(-1);
if (last && last.preset === preset) last.nodes.push(node);
else {
const amount = typeof node.presetAmount === "number" ? node.presetAmount : 1;
out.push({
...(preset ? { preset } : {}),
amount: Math.min(1, Math.max(0, amount)),
nodes: [node],
});
}
}
return out;
}
/**
* A signature of everything that changes the graph's *shape* rather than its
* parameter values. When this is unchanged an update can just push new values
@@ -581,21 +616,67 @@ export function buildFxChain(
const input = ctx.createGain();
const output = ctx.createGain();
const handles: { id?: string; type: string; handle: FxNodeHandle }[] = [];
const presets: { id: string; entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode }[] =
[];
/**
* A preset's consecutive nodes, wrapped in a wet/dry pair.
*
* The rest of the chain is 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, though — it is several the author added as a unit, and "how much of
* it is applied" is a question about the unit. Its nodes share no automatable
* parameter, and the worklet ones expose no AudioParams at all, so there is
* nothing to aim a lane at node-by-node. One crossfade around the run is the
* whole answer, and it cannot go half-wrong the way seven lanes can.
*
* Consecutive only, matching what the rack 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.
*/
const runs = presetRuns(enabledAudioFxNodes(chain));
let tail: AudioNode = input;
for (const node of enabledAudioFxNodes(chain)) {
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
tail.connect(handle.input);
tail = handle.output;
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
for (const run of runs) {
let wrap: { entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode } | null = null;
if (run.preset) {
const entry = ctx.createGain();
const dry = ctx.createGain();
const wet = ctx.createGain();
const join = ctx.createGain();
wet.gain.value = run.amount;
dry.gain.value = 1 - run.amount;
tail.connect(entry);
// The dry leg bridges the whole run: it leaves before the first effect and
// rejoins after the last, which is what makes amount 0 the untouched
// signal rather than a quieter version of the processed one.
entry.connect(dry).connect(join);
wrap = { entry, wet, dry, join };
tail = entry;
}
for (const node of run.nodes) {
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
tail.connect(handle.input);
tail = handle.output;
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
}
if (wrap && run.preset) {
tail.connect(wrap.wet).connect(wrap.join);
presets.push({ id: run.preset, ...wrap });
tail = wrap.join;
}
}
tail.connect(output);
const shape = shapeOf(chain);
const presetTargets: Record<string, FxParamTarget[]> = {};
for (const p of presets) presetTargets[p.id] = mixTargets(p.wet.gain, p.dry.gain);
return {
input,
output,
presets: presetTargets,
nodes: handles,
update(next) {
if (shapeOf(next) !== shape) return false;
@@ -612,6 +693,16 @@ export function buildFxChain(
if (node.id === undefined) delete held.id;
else held.id = node.id;
});
// The blend is a value like any other: switching a preset off writes
// `presetAmount`, and pushing it into the running graph is what keeps that
// from being a rebuild — and from restarting the audio underneath it.
for (const run of presetRuns(enabledAudioFxNodes(next))) {
if (!run.preset) continue;
const wrap = presets.find((p) => p.id === run.preset);
if (!wrap) continue;
wrap.wet.gain.value = run.amount;
wrap.dry.gain.value = 1 - run.amount;
}
// `shape` is not reassigned: the early return above already established
// that `shapeOf(next)` equals it, so recomputing was a whole normalise +
// join per observer tick to write back the string that was already there.
@@ -619,6 +710,15 @@ export function buildFxChain(
},
dispose() {
for (const { handle } of handles) handle.dispose();
// The wrap is not one of `handles` — it belongs to the chain rather than
// to any effect — so it has to be unwired here or a rebuild leaves a
// crossfade still connected to the graph it used to bridge.
for (const { entry, wet, dry, join } of presets) {
entry.disconnect();
wet.disconnect();
dry.disconnect();
join.disconnect();
}
input.disconnect();
output.disconnect();
},
+45 -1
View File
@@ -79,12 +79,22 @@ export class AudioAutomationError extends Error {
export const VOLUME_TARGET = "volume";
export type HfAutomationTarget = { kind: "volume" } | { kind: "fx"; nodeId: string; param: string };
export type HfAutomationTarget =
| { kind: "volume" }
| { kind: "fx"; nodeId: string; param: string }
| { kind: "preset"; presetId: string };
/** Split a target string. Returns null for anything unrecognised. */
export function parseAutomationTarget(target: string): HfAutomationTarget | null {
if (target === VOLUME_TARGET) return { kind: "volume" };
const parts = target.split(".");
// `fx.preset.<id>` before the 3-part fx form, because it IS a 3-part fx form
// with a reserved node id — an effect can never be called "preset", since ids
// are minted `n1`, `n2`, ….
if (parts.length === 3 && parts[0] === "fx" && parts[1] === PRESET_TARGET_KEY) {
const presetId = parts[2];
return presetId ? { kind: "preset", presetId } : null;
}
if (parts.length !== 3 || parts[0] !== "fx") return null;
const [, nodeId, param] = parts;
if (!nodeId || !param) return null;
@@ -95,6 +105,33 @@ export function fxAutomationTarget(nodeId: string, param: string): string {
return `fx.${nodeId}.${param}`;
}
/** The reserved node-id slot that marks a whole-preset target. */
const PRESET_TARGET_KEY = "preset";
/**
* How much of a preset is applied, 0..1.
*
* A preset's nodes share no automatable parameter — and its worklet effects
* expose no AudioParams at all — so there is nothing to aim a lane at
* node-by-node. The graph wraps a preset's run in a wet/dry pair instead, and
* this drives the blend: 0 is the dry signal untouched, 1 is the preset fully
* applied, and between them it crossfades.
*/
export function presetAutomationTarget(presetId: string): string {
return `fx.${PRESET_TARGET_KEY}.${presetId}`;
}
/** 0..1 blend, the same shape as a wet/dry mix knob. */
export const PRESET_RANGE: AutomationRange = {
min: 0,
max: 1,
step: 0.01,
unit: "",
label: "Amount",
scale: "linear",
default: 1,
};
/**
* The value range a lane is drawn and clamped against.
*
@@ -136,6 +173,13 @@ export function resolveAutomationRange(
const parsed = parseAutomationTarget(target);
if (!parsed) return null;
if (parsed.kind === "volume") return VOLUME_RANGE;
if (parsed.kind === "preset") {
// Only for a preset the chain actually carries, so a lane left behind by a
// removed preset resolves to nothing and is dropped at read time — the same
// contract an orphaned node lane has.
const present = chain?.nodes.some((n) => n.fromPreset === parsed.presetId);
return present ? { ...PRESET_RANGE, label: `${parsed.presetId} · Amount` } : null;
}
const node = chain?.nodes.find((n) => n.id === parsed.nodeId);
if (!node) return null;
const def = getAudioFxDef(node.type);
+23
View File
@@ -832,6 +832,18 @@ export interface HfAudioFxNode {
* effect type and its bands stay ordinary filters underneath.
*/
fromEq?: string;
/**
* How much of this node's preset is applied, 0..1 — the wet/dry blend the
* graph wraps its run in.
*
* On every node of the run rather than beside the chain, because the chain has
* nowhere else to put it: `HfAudioFxChain` is a version and a list of nodes,
* and a preset is defined by which nodes carry its tag. The graph reads it off
* the first node of each run. Absent means fully applied, which is what every
* chain written before this means.
*/
presetAmount?: number;
/**
* Set on the gain stage the leveller writes, so re-running replaces it rather
* than stacking a second one — the same contract `fromCarve` has.
@@ -890,6 +902,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
label?: unknown;
fromEq?: unknown;
fromLeveller?: unknown;
presetAmount?: unknown;
};
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
@@ -907,6 +920,11 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
...(typeof node.label === "string" && node.label ? { label: node.label } : {}),
...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}),
...(node.fromLeveller === true ? { fromLeveller: true as const } : {}),
// Clamped on the way in: the blend is two gains in opposition, and a value
// outside 0..1 makes the dry leg negative rather than simply loud.
...(typeof node.presetAmount === "number" && Number.isFinite(node.presetAmount)
? { presetAmount: Math.min(1, Math.max(0, node.presetAmount)) }
: {}),
enabled: node.enabled !== false,
params: normalizeAudioFxParams(
node.type,
@@ -934,6 +952,11 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string {
...(node.label ? { label: node.label } : {}),
...(node.fromEq ? { fromEq: node.fromEq } : {}),
...(node.fromLeveller === true ? { fromLeveller: true } : {}),
// Omitted when fully applied, so an untouched preset does not grow a field
// in every chain that carries one.
...(typeof node.presetAmount === "number" && node.presetAmount !== 1
? { presetAmount: node.presetAmount }
: {}),
...(node.enabled === false ? { enabled: false } : {}),
params: normalizeAudioFxParams(node.type, node.params),
})),
+39
View File
@@ -120,6 +120,45 @@ describe("the catalogue is internally valid", () => {
}
});
it("round-trips how much of the preset is applied", () => {
// `presetAmount` is what the switch writes and what a lane ramps, so losing
// it on reload silently turns every part-applied or switched-off preset back
// on — the same failure `fromPreset` had, one field along. The INVARIANT: a
// new HfAudioFxNode field goes in BOTH parseAudioFxChain and
// serializeAudioFxChain.
const preset = HF_AUDIO_FX_PRESETS[0];
if (!preset) throw new Error("empty catalogue");
for (const amount of [0, 0.4, 1]) {
const chain = applyAudioFxPreset(empty(), preset);
const withAmount = {
...chain,
nodes: chain.nodes.map((n) => ({ ...n, presetAmount: amount })),
};
const back = parseAudioFxChain(serializeAudioFxChain(withAmount));
// 1 is the absent case — a fully applied preset should not grow a field in
// every chain that carries one — and reads back as fully applied.
const expected = amount === 1 ? undefined : amount;
expect(
back.nodes.map((n) => n.presetAmount),
`amount ${amount} did not survive`,
).toEqual(chain.nodes.map(() => expected));
}
});
it("refuses an amount outside the blend it drives", () => {
// Two gains in opposition: past 1 the dry leg goes negative rather than the
// preset simply getting louder.
const preset = HF_AUDIO_FX_PRESETS[0];
if (!preset) throw new Error("empty catalogue");
const chain = applyAudioFxPreset(empty(), preset);
const raw = JSON.parse(serializeAudioFxChain(chain)) as {
nodes: { presetAmount?: number }[];
};
raw.nodes = raw.nodes.map((n) => ({ ...n, presetAmount: 4 }));
const back = parseAudioFxChain(JSON.stringify(raw));
for (const node of back.nodes) expect(node.presetAmount).toBe(1);
});
it("names every node for the job it is doing", () => {
for (const p of HF_AUDIO_FX_PRESETS) {
for (const node of p.nodes) {
+3 -1
View File
@@ -179,7 +179,9 @@ export function attachElementFxChain(
const scheduleFor = (next: HfAudioFxChain, at: AutomationTiming | null): void => {
automated =
at && handle ? scheduleChainAutomation(readAutomation(el, next), next, handle.nodes, at) : [];
at && handle
? scheduleChainAutomation(readAutomation(el, next), next, handle.nodes, at, handle.presets)
: [];
};
// The reference frame every later reschedule measures from. Mutable because a
@@ -103,11 +103,13 @@ async function render(
// time is offline time — the envelope needs no offset here. Same scheduler as
// preview, which is what makes the two agree.
if (parsedAutomation) {
scheduleChainAutomation(parsedAutomation, chain, fx.nodes, {
scheduledAt: 0,
elapsed: 0,
rate: 1,
});
scheduleChainAutomation(
parsedAutomation,
chain,
fx.nodes,
{ scheduledAt: 0, elapsed: 0, rate: 1 },
fx.presets,
);
}
source.connect(fx.input);
@@ -34,6 +34,8 @@ import {
} from "@hyperframes/core/audio-carve";
import {
fxAutomationTarget,
parseAutomationTarget,
presetAutomationTarget,
sampleAutomationLane,
type HfAutomation,
type HfAutomationLane,
@@ -192,6 +194,29 @@ export function AudioFxGroup({
writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey)));
};
/**
* Automate a whole preset's amount — the wet/dry blend around its run.
*
* Seeded where the preset already sits, so switching to a lane never changes
* the sound; the author then draws the ramp in the timeline. Same contract as
* automating one parameter, one level up.
*/
const automatePreset = (presetId: string, amount: number): void => {
writeAutomation(withSeededLane(automation, presetAutomationTarget(presetId), amount));
};
const removePresetAutomation = (presetId: string): void => {
writeAutomation(withoutLane(automation, presetAutomationTarget(presetId)));
};
/** Presets a lane already drives, so the panel shows a readout not a slider. */
const automatedPresets = new Set(
automation.lanes
.map((lane) => parseAutomationTarget(lane.target))
.filter((t): t is { kind: "preset"; presetId: string } => t?.kind === "preset")
.map((t) => t.presetId),
);
/**
* Turn carve on or off.
*
@@ -796,6 +821,9 @@ export function AudioFxGroup({
onLevel={() => void runLeveller()}
onRemoveLevel={removeLeveller}
levelled={chain.nodes.some((n) => n.fromLeveller)}
onAutomatePreset={automatePreset}
onRemovePresetAutomation={removePresetAutomation}
automatedPresets={automatedPresets}
onAuditionLevel={(on) => void auditionLevel(on)}
auditioningLevel={auditioningLevel}
carvedAgainstBy={carvedAgainstBy}
@@ -88,6 +88,9 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
onAutomateParam={overrides.onAutomateParam}
onRemoveParamAutomation={overrides.onRemoveParamAutomation}
onRemoveNodeAutomation={overrides.onRemoveNodeAutomation}
onAutomatePreset={overrides.onAutomatePreset}
onRemovePresetAutomation={overrides.onRemovePresetAutomation}
automatedPresets={overrides.automatedPresets}
onLevel={overrides.onLevel}
onRemoveLevel={overrides.onRemoveLevel}
levelled={overrides.levelled}
@@ -414,6 +417,22 @@ describe("FxSection chain", () => {
});
describe("a preset is one thing to switch off or take away", () => {
/**
* The run's own Amount row — not a member module's.
*
* It is a direct child of the bracket; a member's rows are nested inside its
* own card, which is what makes the distinction structural rather than
* positional.
*/
const amountRow = (host: HTMLElement): HTMLElement | null => {
const bracket = host.querySelector("[data-fx-preset='telephone']");
// A direct-child walk rather than `:scope >`, which happy-dom's matcher
// does not support — it returns nothing rather than erroring, which reads
// as "the control is missing".
return (Array.from(bracket?.children ?? []).find((c) => c.classList.contains("hf-fx-row")) ??
null) as HTMLElement | null;
};
/** A telephone preset applied, plus one hand-built effect beside it. */
const applied = (): HfAudioFxChain => {
const preset = getAudioFxPreset("telephone");
@@ -430,7 +449,7 @@ describe("FxSection chain", () => {
};
};
it("bypasses every node it wrote, in one gesture", () => {
it("switches the whole preset off in one gesture", () => {
// Reaching into five modules and toggling each is exactly the bookkeeping
// the bracket exists to remove.
const { host, onChainChange } = mount({ chain: applied() });
@@ -438,17 +457,37 @@ describe("FxSection chain", () => {
click(run?.querySelector(".hf-fx-preset-run-toggle"));
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
expect(next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => !n.enabled)).toBe(
true,
);
// Amount, not `enabled`: the switch and the lane are the same value, so
// Off is one end of the control a ramp moves along rather than a second
// way of silencing the preset. Writing `enabled` would take the nodes out
// of the graph, which a lane cannot do part-way.
const members = next.nodes.filter((n) => n.fromPreset === "telephone");
expect(members.every((n) => n.presetAmount === 0)).toBe(true);
// Still in the graph, so the settings survive and it can come back.
expect(members.every((n) => n.enabled !== false)).toBe(true);
// And leaves what the author added themselves alone.
expect(next.nodes.find((n) => n.id === "own")?.enabled).toBe(true);
expect(next.nodes.find((n) => n.id === "own")?.presetAmount).toBeUndefined();
});
it("puts the whole preset half in", () => {
// The point of the blend: a preset is not only on or off, and the same
// value a lane ramps is one an author can just set.
const { host, onChainChange } = mount({ chain: applied() });
const input = amountRow(host)?.querySelector<HTMLInputElement>(".hf-fx-number");
if (!input) throw new Error("no amount control");
typeInto(input, "0.4");
act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
const next = onChainChange.mock.calls.at(-1)?.[0] as HfAudioFxChain;
expect(
next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => n.presetAmount === 0.4),
).toBe(true);
});
it("switches back on rather than deleting, so the settings survive", () => {
const off = applied();
off.nodes = off.nodes.map((n) =>
n.fromPreset === "telephone" ? { ...n, enabled: false } : n,
n.fromPreset === "telephone" ? { ...n, presetAmount: 0 } : n,
);
const { host, onChainChange } = mount({ chain: off });
const toggle = host
@@ -459,17 +498,18 @@ describe("FxSection chain", () => {
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
const back = next.nodes.filter((n) => n.fromPreset === "telephone");
expect(back.every((n) => n.enabled)).toBe(true);
expect(back.every((n) => n.presetAmount === 1)).toBe(true);
// Nothing was thrown away.
expect(back).toHaveLength(off.nodes.filter((n) => n.fromPreset === "telephone").length);
});
it("reads as on while any of it is still running", () => {
// "Some of it is bypassed" is not a state an author set — it is one they
// arrived at, and the switch has to offer to stop it rather than claim it
// has already stopped.
it("reads as on while any of it is still applied", () => {
// Anything above zero is a preset that is doing something, and the switch
// has to offer to stop it rather than claim it has already stopped.
const partial = applied();
partial.nodes = partial.nodes.map((n, i) => (i === 0 ? { ...n, enabled: false } : n));
partial.nodes = partial.nodes.map((n) =>
n.fromPreset === "telephone" ? { ...n, presetAmount: 0.2 } : n,
);
const { host } = mount({ chain: partial });
expect(
host
@@ -479,6 +519,31 @@ describe("FxSection chain", () => {
).toBe("true");
});
it("hands the whole preset to a lane, seeded where it already sits", () => {
// The reason a preset needs its own target at all: its nodes share no
// automatable parameter, and its worklet effects expose none. One lane on
// the blend is what lets a preset ramp in over time.
const onAutomatePreset = vi.fn();
const half = applied();
half.nodes = half.nodes.map((n) =>
n.fromPreset === "telephone" ? { ...n, presetAmount: 0.6 } : n,
);
const { host } = mount({ chain: half, onAutomatePreset });
click(amountRow(host)?.querySelector(".hf-fx-automate"));
// Seeded where it sits, so switching to a lane never changes the sound.
expect(onAutomatePreset).toHaveBeenCalledWith("telephone", 0.6);
});
it("shows an automated preset as driven rather than offering a slider", () => {
const { host } = mount({
chain: applied(),
automatedPresets: new Set(["telephone"]),
});
const row = amountRow(host);
expect(row?.hasAttribute("data-automated")).toBe(true);
expect(row?.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(true);
});
it("takes the preset back out whole, with its lanes", () => {
const onRemoveNodeAutomation = vi.fn();
const { host, onChainChange } = mount({ chain: applied(), onRemoveNodeAutomation });
@@ -14,6 +14,7 @@ import {
type HfAudioFxChain,
type HfAudioFxGroup,
type HfAudioFxNode,
type HfAudioFxParam,
type HfAudioFxParamValues,
} from "@hyperframes/core/audio-fx";
import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
@@ -33,6 +34,7 @@ import {
HF_AUDIO_FX_JOB_TYPES,
type HfAudioFxJob,
} from "@hyperframes/core/audio-fx-jobs";
import { FxParamRow } from "./propertyPanelFxControls.js";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -48,6 +50,25 @@ const GROUP_LABEL: Record<HfAudioFxGroup, string> = {
time: "Time",
};
/**
* The one control over a whole preset: how much of it is applied.
*
* Not in the effect registry — a preset is not an effect — so the row is
* fabricated the same way the derived one-knob control is, and rendered by the
* ordinary controls.
*/
const PRESET_AMOUNT_PARAM: HfAudioFxParam = {
kind: "number",
key: "amount",
label: "Amount",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 1,
hint: "How much of this preset is applied. Automate it to bring the whole preset in or out over time.",
};
export interface FxSectionProps {
chain: HfAudioFxChain;
/** Targets this track already automates, as `fx.<nodeId>.<param>` strings. */
@@ -67,6 +88,12 @@ export interface FxSectionProps {
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
/** Delete every lane belonging to a node that is being removed. */
onRemoveNodeAutomation?(nodeId: string): void;
/** Add a lane for a whole preset's amount, seeded where it sits now. */
onAutomatePreset?(presetId: string, amount: number): void;
/** Delete that lane. */
onRemovePresetAutomation?(presetId: string): void;
/** Presets whose amount a lane already drives. */
automatedPresets?: ReadonlySet<string>;
/** Measure this track and write the levelling lane. Absent when unavailable. */
onLevel?(): void;
/** Take the levelling stage and its lane back out. */
@@ -126,7 +153,11 @@ export function FxSection({
levelled,
onAuditionLevel,
auditioningLevel,
onAutomatePreset,
onRemovePresetAutomation,
automatedPresets,
}: FxSectionProps) {
const presetAutomated = automatedPresets ?? new Set<string>();
// Falls back to the persisting write when no preview handler is supplied, which
// keeps the control working rather than going dead.
const previewCarve = onCarvePreview ?? onCarveChange;
@@ -315,19 +346,28 @@ export function FxSection({
);
/**
* Bypass or restore every node a preset wrote, as one gesture.
* How much of a preset is applied, 0..1.
*
* A preset is one thing the author added, so it has to be one thing they can
* switch off — reaching into five modules and toggling each is the bookkeeping
* the bracket exists to remove. Off is a bypass, not a delete: the settings
* survive, which is what makes it worth trying rather than committing to.
* The switch and the lane are the same value, not two ways of silencing a
* preset: `presetAmount` drives the wet/dry blend the graph wraps the run in,
* so Off is amount 0 and a lane ramping 0 → 1 is the same control moving
* continuously. Writing `enabled` instead would take the nodes out of the
* graph, which a lane cannot do part-way and cannot do without a rebuild.
*
* On every node of the run because that is where the chain can hold it — see
* `HfAudioFxNode.presetAmount`.
*/
const toggleRun = useCallback(
(items: { node: HfAudioFxNode; i: number }[], on: boolean) => {
const setRunAmount = useCallback(
(items: { node: HfAudioFxNode; i: number }[], amount: number, persist = true) => {
const slots = new Set(items.map((item) => item.i));
mutate(chain.nodes.map((n, i) => (slots.has(i) ? { ...n, enabled: on } : n)));
const next = {
...chain,
nodes: chain.nodes.map((n, i) => (slots.has(i) ? { ...n, presetAmount: amount } : n)),
};
if (persist) mutate(next.nodes);
else onChainPreview?.(next);
},
[chain.nodes, mutate],
[chain, mutate, onChainPreview],
);
/**
@@ -539,7 +579,11 @@ export function FxSection({
// On unless every node in it is bypassed: one switched back on means
// the preset is doing something, and the switch has to offer to stop
// it rather than claiming it has already stopped.
const runOn = run.items.some(({ node }) => node.enabled !== false);
// How much of it is applied. Any node of the run carries it, and the
// first is the one the graph reads.
const amount = run.items[0]?.node.presetAmount;
const runAmount = typeof amount === "number" ? amount : 1;
const runOn = runAmount > 0;
return (
<div
key={`preset-${run.preset}-${run.items[0]?.i}`}
@@ -558,9 +602,9 @@ export function FxSection({
type="button"
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
aria-pressed={runOn}
title={runOn ? `Bypass ${preset.label}` : `Switch ${preset.label} back on`}
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
disabled={disabled}
onClick={() => toggleRun(run.items, !runOn)}
onClick={() => setRunAmount(run.items, runOn ? 0 : 1)}
>
{runOn ? "On" : "Off"}
</button>
@@ -574,6 +618,26 @@ export function FxSection({
&times;
</button>
</div>
{/* The same value the switch sets, so an author can put the
preset half in — and the lane below ramps it continuously. */}
<FxParamRow
param={PRESET_AMOUNT_PARAM}
value={runAmount}
disabled={disabled || presetAutomated.has(run.preset ?? "")}
automated={presetAutomated.has(run.preset ?? "")}
onChange={(_k, v) => setRunAmount(run.items, Number(v), false)}
onCommit={(_k, v) => setRunAmount(run.items, Number(v))}
onAutomate={
run.preset && onAutomatePreset && !presetAutomated.has(run.preset)
? () => onAutomatePreset(run.preset ?? "", runAmount)
: undefined
}
onRemoveAutomation={
run.preset && onRemovePresetAutomation && presetAutomated.has(run.preset)
? () => onRemovePresetAutomation(run.preset ?? "")
: undefined
}
/>
{rows}
</div>
);