feat(core): add the audio FX preset catalogue

Twenty named starting points across four shelves — voice, repair, character,
space — so an author who wants "Telephone" does not have to know it is two
stacked filter pairs, a honk band, a de-mud band and a soft clip.

A preset is DATA. Applying one writes ordinary nodes into `data-fx-chain`, the
same nodes hand-building would produce, so there is no second code path beside
the rack, nothing new in the render, and no failure mode the chain does not
already have. Only the parameters a preset means are listed; the rest come from
`normalizeAudioFxParams`, which keeps each entry readable as an intent.

`fromPreset` joins `fromCarve` on the node — the id rather than a flag, because
a chain can carry more than one preset and each has to find its own nodes.
Applying appends (stacking Telephone onto a cleaned voice is a real thing to
want), re-applying replaces that preset's own nodes IN PLACE so it keeps its
position in the signal order, and hand-added effects are never touched.

The repair shelf is named for what it does. None of it is noise reduction —
that needs spectral work this effect set does not have — so "Quiet Between
Phrases" says it closes the pauses and leaves room tone under speech, rather
than implying a denoiser the author only discovers is absent after trusting it.

The tests check the DATA, not just the machinery: every effect real, every
parameter declared, every value inside its own range, every enum a real option,
every chain round-tripping through the attribute, and every preset that lifts
level bounded by a limiter that is actually last. Falsified against four
mutations — a gain past its maximum, an enum the effect does not offer, an
undeclared parameter, and a limiter moved off the end — each caught with the
preset and parameter named.

Core 1670 -> 1688.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-09 12:05:17 -07:00
co-authored by Claude Opus 5
parent 19e0ed7ad7
commit a533d16775
3 changed files with 580 additions and 0 deletions
+9
View File
@@ -804,6 +804,15 @@ export interface HfAudioFxNode {
/** Set on nodes the carve analysis generated, so re-running replaces them /** Set on nodes the carve analysis generated, so re-running replaces them
* instead of stacking another set on top of hand-added effects. */ * instead of stacking another set on top of hand-added effects. */
fromCarve?: boolean; fromCarve?: boolean;
/**
* Id of the preset that wrote this node, for the same reason `fromCarve`
* exists: re-applying a preset replaces its own nodes rather than adding a
* second copy, and the rack can brace them together under the preset's name.
*
* The id rather than a flag, because a chain can carry more than one preset
* and each has to be able to find its own.
*/
fromPreset?: string;
/** Absent means enabled — chain files written before the field existed still load. */ /** Absent means enabled — chain files written before the field existed still load. */
enabled?: boolean; enabled?: boolean;
params?: HfAudioFxParamValues; params?: HfAudioFxParamValues;
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, it } from "vitest";
import {
getAudioFxDef,
HF_AUDIO_FX_CHAIN_VERSION,
normalizeAudioFxParams,
parseAudioFxChain,
serializeAudioFxChain,
type HfAudioFxChain,
type HfAudioFxNumberParam,
} from "./audioFx.js";
import {
activeAudioFxPresetIds,
applyAudioFxPreset,
audioFxPresetNodes,
audioFxPresetsByFamily,
getAudioFxPreset,
HF_AUDIO_FX_PRESET_FAMILIES,
HF_AUDIO_FX_PRESETS,
} from "./audioFxPresets.js";
const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] });
const need = (id: string) => {
const p = getAudioFxPreset(id);
if (!p) throw new Error(`no preset ${id}`);
return p;
};
/**
* The catalogue is hand-written numbers, which is exactly the kind of thing
* that rots quietly: a value outside its declared range does not throw, it gets
* clamped, and the preset then sounds like something nobody chose. These check
* the data itself rather than the machinery around it.
*/
describe("the catalogue is internally valid", () => {
it("has unique ids and a family the menu knows", () => {
const ids = HF_AUDIO_FX_PRESETS.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
for (const p of HF_AUDIO_FX_PRESETS) {
expect(HF_AUDIO_FX_PRESET_FAMILIES, `${p.id} sits on no shelf`).toContain(p.family);
expect(p.nodes.length, `${p.id} is empty`).toBeGreaterThan(0);
expect(p.label.length).toBeGreaterThan(0);
expect(p.description.length).toBeGreaterThan(0);
}
});
it("names only real effects", () => {
for (const p of HF_AUDIO_FX_PRESETS) {
for (const node of p.nodes) {
expect(getAudioFxDef(node.type), `${p.id} uses unknown effect "${node.type}"`).toBeTruthy();
}
}
});
it("names only parameters those effects declare", () => {
for (const p of HF_AUDIO_FX_PRESETS) {
for (const node of p.nodes) {
const keys = new Set((getAudioFxDef(node.type)?.params ?? []).map((x) => x.key));
for (const key of Object.keys(node.params ?? {})) {
expect(keys.has(key), `${p.id}: ${node.type} has no parameter "${key}"`).toBe(true);
}
}
}
});
it("sets every value inside its own declared range", () => {
// The one that actually catches typos. A value out of range is silently
// clamped, so without this a preset can ship sounding like nothing anyone
// chose and still pass every other test here.
for (const p of HF_AUDIO_FX_PRESETS) {
for (const node of p.nodes) {
const def = getAudioFxDef(node.type);
for (const [key, raw] of Object.entries(node.params ?? {})) {
const param = def?.params.find((x) => x.key === key);
if (!param) continue;
if (param.kind === "enum") {
expect(
param.options.some((o) => o.value === raw),
`${p.id}: ${node.type}.${key} = "${String(raw)}" is not one of its options`,
).toBe(true);
continue;
}
const n = param as HfAudioFxNumberParam;
expect(typeof raw, `${p.id}: ${node.type}.${key} is not a number`).toBe("number");
expect(
raw as number,
`${p.id}: ${node.type}.${key} = ${String(raw)} is below its minimum ${n.min}`,
).toBeGreaterThanOrEqual(n.min);
expect(
raw as number,
`${p.id}: ${node.type}.${key} = ${String(raw)} is above its maximum ${n.max}`,
).toBeLessThanOrEqual(n.max);
}
}
}
});
it("survives being written to an attribute and read back", () => {
for (const p of HF_AUDIO_FX_PRESETS) {
const chain = applyAudioFxPreset(empty(), p);
const back = parseAudioFxChain(serializeAudioFxChain(chain));
expect(
back.nodes.map((n) => n.type),
`${p.id} did not round-trip`,
).toEqual(chain.nodes.map((n) => n.type));
}
});
it("ends anything that boosts with a ceiling", () => {
// A preset that adds presence and makeup gain can push the chain past full
// scale, and the render clamps what it is handed. Every preset that lifts
// has to hand the mix something bounded.
for (const p of HF_AUDIO_FX_PRESETS) {
const lifts = p.nodes.some((node) => {
const g = node.params?.["gain"];
const makeup = node.params?.["makeup"];
const out = node.params?.["output"];
return (
(typeof g === "number" && g > 0) ||
(typeof makeup === "number" && makeup > 0) ||
(typeof out === "number" && out > 0)
);
});
if (!lifts) continue;
const last = p.nodes[p.nodes.length - 1];
const bounded =
p.nodes.some((n) => n.type === "limiter") ||
// A band-limited character preset cannot run away: its own filters and
// soft clip cap it, and a limiter would change the effect.
p.family === "character";
expect(bounded, `${p.id} lifts level but never bounds it (ends on ${last?.type})`).toBe(true);
}
});
it("puts the limiter last wherever it has one", () => {
for (const p of HF_AUDIO_FX_PRESETS) {
const at = p.nodes.findIndex((n) => n.type === "limiter");
if (at === -1) continue;
expect(at, `${p.id}: a limiter that is not last is not a ceiling`).toBe(p.nodes.length - 1);
}
});
it("keeps every shelf stocked", () => {
for (const family of HF_AUDIO_FX_PRESET_FAMILIES) {
expect(audioFxPresetsByFamily(family).length, `${family} is empty`).toBeGreaterThan(0);
}
});
});
describe("applying a preset", () => {
it("writes ordinary nodes with their defaults filled in", () => {
const chain = applyAudioFxPreset(empty(), need("rumble-cut"));
expect(chain.nodes).toHaveLength(1);
const node = chain.nodes[0]!;
expect(node.type).toBe("highpass");
// Named 100 Hz; everything else comes from the effect, so the file holds a
// complete node rather than a partial one the graph has to guess at.
expect(node.params).toEqual({ ...normalizeAudioFxParams("highpass", {}), frequency: 100 });
expect(node.fromPreset).toBe("rumble-cut");
expect(node.enabled).toBe(true);
});
it("gives every node an id, because a lane addresses effects by id", () => {
const chain = applyAudioFxPreset(empty(), need("telephone"));
const ids = chain.nodes.map((n) => n.id);
expect(ids.every(Boolean)).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});
it("appends rather than replacing, so a character preset can stack on a clean voice", () => {
const voiced = applyAudioFxPreset(empty(), need("voice-clean"));
const both = applyAudioFxPreset(voiced, need("telephone"));
expect(both.nodes.length).toBe(voiced.nodes.length + need("telephone").nodes.length);
expect(activeAudioFxPresetIds(both)).toEqual(["voice-clean", "telephone"]);
// ids stay unique across the two batches
expect(new Set(both.nodes.map((n) => n.id)).size).toBe(both.nodes.length);
});
it("replaces the whole chain when asked", () => {
const voiced = applyAudioFxPreset(empty(), need("voice-clean"));
const only = applyAudioFxPreset(voiced, need("hall"), { replaceChain: true });
expect(activeAudioFxPresetIds(only)).toEqual(["hall"]);
expect(only.nodes).toHaveLength(need("hall").nodes.length);
});
it("re-applying swaps its own nodes instead of stacking a second copy", () => {
const once = applyAudioFxPreset(empty(), need("telephone"));
const twice = applyAudioFxPreset(once, need("telephone"));
expect(twice.nodes.length).toBe(once.nodes.length);
expect(activeAudioFxPresetIds(twice)).toEqual(["telephone"]);
});
it("re-applying keeps the preset's place in the signal order", () => {
// Order is audible: a telephone band before a limiter is a different sound
// from one after it. Re-applying must not quietly move the preset to the end.
const start = applyAudioFxPreset(empty(), need("telephone"));
const withTail = applyAudioFxPreset(start, need("hall"));
const again = applyAudioFxPreset(withTail, need("telephone"));
expect(again.nodes.map((n) => n.fromPreset)).toEqual([
...new Array(need("telephone").nodes.length).fill("telephone"),
...new Array(need("hall").nodes.length).fill("hall"),
]);
});
it("leaves hand-added effects alone when a preset is re-applied", () => {
const hand = { type: "reverb", id: "mine", params: normalizeAudioFxParams("reverb", {}) };
const chain: HfAudioFxChain = { version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [hand] };
const once = applyAudioFxPreset(chain, need("voice-clean"));
const twice = applyAudioFxPreset(once, need("voice-clean"));
expect(twice.nodes.filter((n) => n.id === "mine")).toHaveLength(1);
expect(twice.nodes.filter((n) => n.fromPreset === "voice-clean")).toHaveLength(
need("voice-clean").nodes.length,
);
});
it("mints ids that cannot collide with what is already in the chain", () => {
const chain: HfAudioFxChain = {
version: HF_AUDIO_FX_CHAIN_VERSION,
nodes: [
{ type: "reverb", id: "n1" },
{ type: "delay", id: "n2" },
],
};
const made = audioFxPresetNodes(need("voice-clean"), chain);
for (const node of made) expect(["n1", "n2"]).not.toContain(node.id);
expect(new Set(made.map((n) => n.id)).size).toBe(made.length);
});
});
+344
View File
@@ -0,0 +1,344 @@
/**
* Named starting points for the FX rack.
*
* Voice carve turned a pile of effects into one understandable feature. These
* do the same for the cases an analysis cannot decide: a preset is a chain
* somebody already tuned, applied in one click and editable immediately
* afterwards.
*
* A preset is DATA, deliberately. Applying one writes ordinary nodes into
* `data-fx-chain` — the same nodes hand-building would produce — so there is no
* second code path to keep in agreement with the rack, nothing new in the
* render, and no failure mode the chain does not already have. The author can
* see everything that was written on their behalf and change any of it.
*
* Only the parameters a preset actually means are listed; `normalizeAudioFxParams`
* fills the rest from the effect's own defaults. That keeps each entry readable
* as an intent rather than a dump of every knob.
*
* Node ORDER is load-bearing — the chain is serial, so a limiter first and a
* limiter last are different sounds. Every preset below runs
* subtractive filtering → dynamics → tone → character → limiter, the order
* `skills/hyperframes-audio` already teaches.
*/
import {
HF_AUDIO_FX_CHAIN_VERSION,
mintAudioFxNodeId,
normalizeAudioFxParams,
type HfAudioFxChain,
type HfAudioFxNode,
type HfAudioFxParamValues,
} from "./audioFx.js";
/**
* Which shelf of the menu a preset sits on.
*
* Deliberately NOT the effect registry's own `group` (filter/dynamics/…): that
* groups by what an effect *is*, and an author picking a preset is shopping for
* what they *want*. "Telephone" is filters and saturation; nobody looks for it
* under either.
*/
export type HfAudioFxPresetFamily = "voice" | "repair" | "character" | "space";
export interface HfAudioFxPresetNode {
/** Effect id from HF_AUDIO_FX. */
type: string;
/** Only what this preset means to set; the rest come from the effect's defaults. */
params?: HfAudioFxParamValues;
}
export interface HfAudioFxPreset {
id: string;
label: string;
family: HfAudioFxPresetFamily;
/** One line, in the author's language — what it does, not which effects it uses. */
description: string;
nodes: readonly HfAudioFxPresetNode[];
}
const preset = (
id: string,
family: HfAudioFxPresetFamily,
label: string,
description: string,
nodes: readonly HfAudioFxPresetNode[],
): HfAudioFxPreset => ({ id, label, family, description, nodes });
/**
* A 24 dB/oct skirt is two of these stacked: `poles` tops out at 2 (12 dB/oct)
* because a BiquadFilterNode is two-pole and that is the honest maximum for one
* node. The telephone band wants the steeper slope, so it pays for two.
*/
const steep = (type: "highpass" | "lowpass", frequency: number): HfAudioFxPresetNode[] => [
{ type, params: { frequency, q: 0.707, poles: "2" } },
{ type, params: { frequency, q: 0.707, poles: "2" } },
];
export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
// ---------------------------------------------------------------- voice --
preset(
"voice-clean",
"voice",
"Clean Voice",
"Cuts rumble and mud, evens out the level, adds a little clarity.",
[
{ type: "highpass", params: { frequency: 80, q: 0.707, poles: "2" } },
{ type: "peaking", params: { frequency: 250, gain: -3, q: 1.2 } },
{
type: "compressor",
params: { threshold: -20, ratio: 3, attack: 12, release: 180, makeup: 3 },
},
{ type: "peaking", params: { frequency: 3000, gain: 2.5, q: 1 } },
{ type: "limiter", params: { limit: -1, attack: 5, release: 50 } },
],
),
preset(
"voice-broadcast",
"voice",
"Broadcast",
"Denser and more forward — a radio-presenter sound.",
[
{ type: "highpass", params: { frequency: 90, q: 0.707, poles: "2" } },
{ type: "peaking", params: { frequency: 400, gain: -3, q: 1.4 } },
{
type: "compressor",
params: { threshold: -24, ratio: 4, attack: 8, release: 150, makeup: 5 },
},
{ type: "peaking", params: { frequency: 2500, gain: 3, q: 0.9 } },
{ type: "highshelf", params: { frequency: 8000, gain: 2 } },
{ type: "saturate", params: { type: "tanh", threshold: -12, output: 0 } },
{ type: "limiter", params: { limit: -1, attack: 5, release: 60 } },
],
),
preset(
"voice-warm",
"voice",
"Close & Warm",
"Intimate and lightly handled, for a voice close to the mic.",
[
{ type: "highpass", params: { frequency: 70, q: 0.707, poles: "2" } },
{ type: "lowshelf", params: { frequency: 180, gain: 2 } },
{
type: "compressor",
params: { threshold: -18, ratio: 2.5, attack: 20, release: 250, makeup: 2 },
},
{ type: "peaking", params: { frequency: 3000, gain: 1.5, q: 0.8 } },
{ type: "limiter", params: { limit: -1.5 } },
],
),
// --------------------------------------------------------------- repair --
// Named for what they DO. None of these is noise reduction: that needs
// spectral work this effect set does not have, and a preset implying
// otherwise would be a lie the author only discovers after trusting it.
preset(
"rumble-cut",
"repair",
"Cut Rumble",
"Removes traffic, handling and air-conditioning from under a voice.",
[{ type: "highpass", params: { frequency: 100, q: 0.707, poles: "2" } }],
),
preset(
"room-gate",
"repair",
"Quiet Between Phrases",
"Silences the gaps between words. Room tone under speech stays — this closes the pauses, it does not remove noise.",
[{ type: "gate", params: { threshold: -45, range: -18, ratio: 10, attack: 2, release: 180 } }],
),
preset(
"boom-tame",
"repair",
"Tame Boominess",
"Takes out the chestiness of a voice too close to the mic.",
[{ type: "peaking", params: { frequency: 200, gain: -4, q: 1.4 } }],
),
preset(
"harsh-tame",
"repair",
"Soften Harshness",
"Rounds off a brittle upper-mid. Broad and always-on; sibilance proper wants the measuring version.",
[{ type: "peaking", params: { frequency: 3200, gain: -3, q: 1.6 } }],
),
// ------------------------------------------------------------ character --
preset(
"telephone",
"character",
"Telephone",
"Down the line — the narrow band of a phone call.",
[
...steep("highpass", 300),
...steep("lowpass", 3400),
{ type: "peaking", params: { frequency: 1200, gain: 6, q: 1.2 } },
{ type: "peaking", params: { frequency: 550, gain: -4, q: 1 } },
{ type: "saturate", params: { type: "tanh", threshold: -18, output: -2 } },
],
),
preset("radio-am", "character", "AM Radio", "Narrow, gritty and a little crushed.", [
{ type: "highpass", params: { frequency: 400, q: 0.707, poles: "2" } },
{ type: "lowpass", params: { frequency: 3000, q: 0.707, poles: "2" } },
{ type: "saturate", params: { type: "tanh", threshold: -15, output: -2 } },
{ type: "bitcrush", params: { bits: 10, samples: 1, mix: 0.25 } },
]),
preset(
"megaphone",
"character",
"Megaphone",
"Shouted through a horn, with the slap that comes with it.",
[
{ type: "highpass", params: { frequency: 500, q: 0.707, poles: "2" } },
{ type: "lowpass", params: { frequency: 4000, q: 0.707, poles: "2" } },
{ type: "peaking", params: { frequency: 1800, gain: 8, q: 1.5 } },
{ type: "saturate", params: { type: "hard", threshold: -12, output: -3 } },
{ type: "delay", params: { time: 40, feedback: 0.15, mix: 0.15 } },
],
),
preset(
"lofi-tape",
"character",
"Tape",
"Worn, warm and slightly unsteady, like a played-out cassette.",
[
{ type: "lowpass", params: { frequency: 6500, q: 0.707, poles: "2" } },
{ type: "lowshelf", params: { frequency: 120, gain: 2 } },
{ type: "saturate", params: { type: "tanh", threshold: -14, output: 0 } },
{ type: "bitcrush", params: { bits: 12, samples: 2, mix: 0.35 } },
// A slow, shallow chorus is what wow and flutter actually are.
{ type: "chorus", params: { delay: 6, depth: 0.6, speed: 0.4, mix: 0.15 } },
],
),
preset("pa-system", "character", "Tannoy", "Announced across a concourse.", [
{ type: "highpass", params: { frequency: 350, q: 0.707, poles: "2" } },
{ type: "lowpass", params: { frequency: 3500, q: 0.707, poles: "2" } },
{ type: "peaking", params: { frequency: 1500, gain: 5, q: 1.2 } },
{ type: "saturate", params: { type: "tanh", threshold: -16, output: -1 } },
{ type: "reverb", params: { size: 0.5, damping: 0.7, wet: 0.25, dry: 0.8 } },
]),
preset("intercom", "character", "Intercom", "Buzzed through a door panel, squelch and all.", [
{ type: "gate", params: { threshold: -40, range: -30, ratio: 10, attack: 1, release: 120 } },
{ type: "highpass", params: { frequency: 500, q: 0.707, poles: "2" } },
{ type: "lowpass", params: { frequency: 3000, q: 0.707, poles: "2" } },
{ type: "peaking", params: { frequency: 2000, gain: 6, q: 2 } },
{ type: "bitcrush", params: { bits: 11, samples: 1, mix: 0.3 } },
]),
// ---------------------------------------------------------------- space --
preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [
{ type: "reverb", params: { size: 0.25, damping: 0.6, wet: 0.18, dry: 0.9 } },
]),
preset(
"room-natural",
"space",
"Natural Room",
"Sounds recorded somewhere rather than nowhere.",
[{ type: "reverb", params: { size: 0.5, damping: 0.5, wet: 0.25, dry: 0.85 } }],
),
preset("hall", "space", "Hall", "Long and open, for something that should sit far back.", [
{ type: "reverb", params: { size: 0.9, damping: 0.3, wet: 0.4, dry: 0.75 } },
]),
preset("slap-echo", "space", "Slap Echo", "One quick repeat — rockabilly vocal, not a wash.", [
{ type: "delay", params: { time: 110, feedback: 0.12, mix: 0.22 } },
]),
preset("dub-throw", "space", "Dub Throw", "Repeats that trail off well behind the beat.", [
{ type: "delay", params: { time: 375, feedback: 0.55, mix: 0.3 } },
]),
];
export const HF_AUDIO_FX_PRESET_IDS: readonly string[] = HF_AUDIO_FX_PRESETS.map((p) => p.id);
const BY_ID = new Map(HF_AUDIO_FX_PRESETS.map((p) => [p.id, p]));
export function getAudioFxPreset(id: string): HfAudioFxPreset | undefined {
return BY_ID.get(id);
}
/** Menu order: the shelves, in the order the panel lists them. */
export const HF_AUDIO_FX_PRESET_FAMILIES: readonly HfAudioFxPresetFamily[] = [
"voice",
"repair",
"character",
"space",
];
export function audioFxPresetsByFamily(family: HfAudioFxPresetFamily): HfAudioFxPreset[] {
return HF_AUDIO_FX_PRESETS.filter((p) => p.family === family);
}
/**
* Realise a preset as nodes ready to splice into `chain`.
*
* Ids are minted against the chain the nodes are joining, not against the
* preset, so applying the same preset twice cannot collide — and every node
* gets one, because an automation lane addresses its effect by id and a node
* without one can never be automated.
*
* Params are normalised here rather than at apply time: a preset that names a
* value the effect would clamp should land in the file as the value that will
* actually be rendered, so the rack never shows a number the graph is not using.
*/
export function audioFxPresetNodes(
preset: HfAudioFxPreset,
chain: HfAudioFxChain,
): HfAudioFxNode[] {
const out: HfAudioFxNode[] = [];
// Minted against a growing chain, so ids are unique within this batch too.
let running: HfAudioFxChain = { ...chain, nodes: [...chain.nodes] };
for (const node of preset.nodes) {
const made: HfAudioFxNode = {
type: node.type,
id: mintAudioFxNodeId(running),
fromPreset: preset.id,
enabled: true,
params: normalizeAudioFxParams(node.type, node.params),
};
out.push(made);
running = { ...running, nodes: [...running.nodes, made] };
}
return out;
}
/**
* The chain after applying a preset.
*
* Appends by default: stacking Telephone onto an already-cleaned voice is a
* real thing to want, and replacing silently would throw away work. Re-applying
* a preset that is already present replaces ITS OWN nodes in place instead of
* adding a second copy — which is what `fromPreset` is for, and mirrors how the
* carve replaces its own bands rather than stacking new ones on hand-added
* effects.
*/
export function applyAudioFxPreset(
chain: HfAudioFxChain,
preset: HfAudioFxPreset,
options: { replaceChain?: boolean } = {},
): HfAudioFxChain {
if (options.replaceChain) {
return {
version: HF_AUDIO_FX_CHAIN_VERSION,
nodes: audioFxPresetNodes(preset, { version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }),
};
}
const existing = chain.nodes.findIndex((n) => n.fromPreset === preset.id);
if (existing === -1) {
return { ...chain, nodes: [...chain.nodes, ...audioFxPresetNodes(preset, chain)] };
}
// Re-apply: drop this preset's old nodes, then rebuild them where the first
// one stood, so the preset keeps its place in the signal order.
const kept = chain.nodes.filter((n) => n.fromPreset !== preset.id);
const before = kept.slice(0, existing);
const after = kept.slice(existing);
const made = audioFxPresetNodes(preset, { ...chain, nodes: kept });
return { ...chain, nodes: [...before, ...made, ...after] };
}
/** Every preset whose nodes are still present, for the rack's group braces. */
export function activeAudioFxPresetIds(chain: HfAudioFxChain): string[] {
const seen: string[] = [];
for (const node of chain.nodes) {
if (node.fromPreset && !seen.includes(node.fromPreset)) seen.push(node.fromPreset);
}
return seen;
}