feat(core): the audio FX registry (#3019)

* feat(core): audio FX registry

One declarative description of every effect that can be applied to an audio
track: fourteen across filters, dynamics, non-linear and time, each exposing
its full parameter surface rather than a curated subset.

Parameters carry the range, step, unit and scale a control needs, so a panel
can generate its UI from this rather than hard-coding a form per effect, and a
value that survives `normalizeAudioFxParams` is always safe to realise.
Everything is declared in the units a person thinks in — dB, ms, Hz.

Parsing rejects an unknown effect id rather than skipping the node. A chain
that quietly loses an effect renders something other than what was authored,
which is worse than refusing to load it.

Data only: no audio is produced here. The graph that realises each effect is
referenced by the `web` id and lands in the next change, which keeps this
module free of browser globals so the engine and the linter can import it.

* fix(core): stop declaring knobs that move nothing

Three parameters were declared with ranges, defaults and hints, and read by no
builder — dials an author could turn with no audible result.

- `chorus.decay` and `bitcrush.aa`: removed. FFmpeg's chorus feeds a decay back
  into its delay line and a bitcrusher's anti-alias needs a real filter; adding
  either is new DSP, not a fix, so the honest move is to stop advertising them.
- `lowshelf.q` / `highshelf.q`: removed. The Web Audio spec leaves Q unused for
  shelving filters, so the control moved nothing — and because the shared Q
  helper marks it automatable, an author could draw an envelope on it and hear
  nothing at all.

`phaser.decay` and `gate.knee` stay: the first drives the sweep depth, and the
second is now read by the gate's processor.

A test asserts each of these directly, since the existing exposure invariant only
checks that a flagged parameter reaches an AudioParam — a parameter the node then
ignores passes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-12 00:09:59 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 43dba22057
commit 5752d22492
4 changed files with 968 additions and 0 deletions
+6
View File
@@ -92,6 +92,12 @@
"types": "./dist/compiler/index.d.ts",
"environments": ["bun", "node"]
},
"./audio-fx": {
"source": "./src/audioFx.ts",
"runtime": "./dist/audioFx.js",
"types": "./dist/audioFx.d.ts",
"environments": ["browser", "bun", "node"]
},
"./color-grading": {
"source": "./src/colorGrading.ts",
"runtime": "./dist/colorGrading.js",
+10
View File
@@ -106,6 +106,12 @@
"import": "./src/compiler/index.ts",
"types": "./src/compiler/index.ts"
},
"./audio-fx": {
"bun": "./src/audioFx.ts",
"node": "./dist/audioFx.js",
"import": "./src/audioFx.ts",
"types": "./src/audioFx.ts"
},
"./color-grading": {
"bun": "./src/colorGrading.ts",
"node": "./dist/colorGrading.js",
@@ -368,6 +374,10 @@
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
},
"./audio-fx": {
"import": "./dist/audioFx.js",
"types": "./dist/audioFx.d.ts"
},
"./color-grading": {
"import": "./dist/colorGrading.js",
"types": "./dist/colorGrading.d.ts"
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
AudioFxChainError,
defaultAudioFxParams,
enabledAudioFxNodes,
getAudioFxDef,
HF_AUDIO_FX,
HF_AUDIO_FX_CHAIN_VERSION,
HF_AUDIO_FX_IDS,
normalizeAudioFxParams,
parseAudioFxChain,
} from "./audioFx.js";
const chain = (nodes: unknown[]): string =>
JSON.stringify({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes });
describe("effect registry", () => {
it("has unique ids and unique parameter keys per effect", () => {
expect(new Set(HF_AUDIO_FX_IDS).size).toBe(HF_AUDIO_FX.length);
for (const def of HF_AUDIO_FX) {
const keys = def.params.map((p) => p.key);
expect(new Set(keys).size, `${def.id} has duplicate param keys`).toBe(keys.length);
}
});
it("declares every default inside its own declared range", () => {
for (const def of HF_AUDIO_FX) {
for (const p of def.params) {
if (p.kind === "enum") {
expect(
p.options.some((o) => o.value === p.default),
`${def.id}.${p.key} default is not one of its options`,
).toBe(true);
} else {
expect(p.default, `${def.id}.${p.key} default below min`).toBeGreaterThanOrEqual(p.min);
expect(p.default, `${def.id}.${p.key} default above max`).toBeLessThanOrEqual(p.max);
expect(p.min).toBeLessThan(p.max);
}
}
}
});
it("gives every effect at least one knob to turn", () => {
for (const def of HF_AUDIO_FX) {
expect(def.params.length, `${def.id} exposes no parameters`).toBeGreaterThan(0);
}
});
});
describe("normalizeAudioFxParams", () => {
it("fills missing keys with defaults", () => {
expect(normalizeAudioFxParams("peaking", {})).toEqual(defaultAudioFxParams("peaking"));
});
it("clamps out-of-range numbers into the renderable range", () => {
const v = normalizeAudioFxParams("peaking", { frequency: 999999, gain: -500, q: 0 });
expect(v.frequency).toBe(20000);
expect(v.gain).toBe(-40);
expect(v.q).toBe(0.1);
});
it("replaces NaN and non-numeric junk with the default", () => {
// NaN reaching a filter string fails the entire render, so it must never survive.
const v = normalizeAudioFxParams("peaking", {
frequency: Number.NaN,
gain: "loud" as unknown as number,
});
expect(v.frequency).toBe(1000);
expect(v.gain).toBe(0);
});
it("falls back to the default for an unrecognised enum value", () => {
expect(normalizeAudioFxParams("saturate", { type: "sawtooth" }).type).toBe("tanh");
expect(normalizeAudioFxParams("saturate", { type: "atan" }).type).toBe("atan");
});
it("drops keys the effect does not declare", () => {
const v = normalizeAudioFxParams("peaking", { frequency: 500, nonsense: 1 });
expect(Object.keys(v).sort()).toEqual(["frequency", "gain", "q"]);
});
});
describe("parseAudioFxChain", () => {
it("round-trips a chain and defaults `enabled` to true", () => {
const parsed = parseAudioFxChain(chain([{ type: "peaking", params: { gain: -6 } }]));
expect(parsed.nodes).toHaveLength(1);
expect(parsed.nodes[0]!.enabled).toBe(true);
expect(parsed.nodes[0]!.params!.gain).toBe(-6);
});
it("rejects an unknown effect rather than silently dropping it", () => {
// Skipping the node would render something other than what was authored.
expect(() => parseAudioFxChain(chain([{ type: "vibrato" }]))).toThrow(AudioFxChainError);
});
it("rejects an unsupported version", () => {
expect(() => parseAudioFxChain(JSON.stringify({ version: 99, nodes: [] }))).toThrow(
/Unsupported chain version/,
);
});
it("rejects malformed JSON and a missing nodes array", () => {
expect(() => parseAudioFxChain("{oops")).toThrow(/not valid JSON/);
expect(() => parseAudioFxChain(JSON.stringify({ version: 1 }))).toThrow(/missing a `nodes`/);
});
});
describe("enabledAudioFxNodes", () => {
it("treats a missing enabled flag as enabled", () => {
const nodes = enabledAudioFxNodes({
version: 1,
nodes: [{ type: "peaking" }, { type: "delay", enabled: false }],
});
expect(nodes.map((n) => n.type)).toEqual(["peaking"]);
});
});
describe("declared parameters are real", () => {
const paramKeys = (id: string): string[] => (getAudioFxDef(id)?.params ?? []).map((p) => p.key);
it("offers no shelf Q, which a BiquadFilterNode ignores for shelf types", () => {
// It was also flagged automatable, so a lane could be drawn on it and heard
// not at all.
expect(paramKeys("lowshelf")).not.toContain("q");
expect(paramKeys("highshelf")).not.toContain("q");
// Peaking and the pass filters do use Q.
expect(paramKeys("peaking")).toContain("q");
expect(paramKeys("lowpass")).toContain("q");
});
it("offers no knob whose builder reads nothing", () => {
// chorus `decay` and bitcrush `aa` were declared with ranges and defaults but
// no builder ever read them: dials that moved and did nothing.
expect(paramKeys("chorus")).not.toContain("decay");
expect(paramKeys("bitcrush")).not.toContain("aa");
// The phaser's decay does drive its sweep depth, and the gate's knee is read.
expect(paramKeys("phaser")).toContain("decay");
expect(paramKeys("gate")).toContain("knee");
});
});
+812
View File
@@ -0,0 +1,812 @@
/**
* Audio FX chain: the one description of every effect that can be applied to an
* audio track.
*
* Preview and render both run the same Web Audio graph — the studio in a live
* AudioContext, the engine in an OfflineAudioContext inside the headless
* browser it already drives. There is only one implementation of each effect,
* so preview predicting the render is a property of the architecture rather
* than something to measure and defend.
*
* This file holds what both ends need to agree on: the parameter set for each
* effect with its usable range, and the id of the graph builder that realises
* it. Parameters are declared in the units a person thinks in (dB, ms, Hz);
* the graph builders convert where the Web Audio node wants something else.
*/
export const HF_AUDIO_FX_ATTR = "data-fx-chain";
/** Chain files are versioned; a reader must refuse a version it doesn't know. */
export const HF_AUDIO_FX_CHAIN_VERSION = 1;
export type HfAudioFxGroup = "filter" | "dynamics" | "nonlinear" | "time";
export interface HfAudioFxNumberParam {
kind: "number";
key: string;
label: string;
/** Shown after the value in the panel; "" for a bare ratio. */
unit: string;
min: number;
max: number;
step: number;
default: number;
/** Frequency-style controls need a log knob to be usable. */
scale?: "linear" | "log";
/** One line explaining what turning this does, shown on the control. */
hint?: string;
}
export interface HfAudioFxEnumParam {
kind: "enum";
key: string;
label: string;
options: readonly { value: string; label: string }[];
default: string;
hint?: string;
}
export type HfAudioFxParam = HfAudioFxNumberParam | HfAudioFxEnumParam;
export type HfAudioFxParamValues = Record<string, number | string>;
export interface HfAudioFxDef {
id: string;
label: string;
group: HfAudioFxGroup;
/** One sentence on what the effect is for, shown when adding it. */
description: string;
params: readonly HfAudioFxParam[];
/**
* Identifier for the Web Audio graph builder that realises this effect. Kept
* as a string rather than a function so this module stays free of browser
* globals and can be imported by the engine and the linter.
*/
web: string;
}
const freq = (
key = "frequency",
label = "Frequency",
def = 1000,
min = 20,
max = 20000,
): HfAudioFxNumberParam => ({
kind: "number",
key,
label,
unit: "Hz",
min,
max,
step: 1,
default: def,
scale: "log",
});
const qParam = (def = 0.707, hint = "Bandwidth — higher is narrower."): HfAudioFxNumberParam => ({
kind: "number",
key: "q",
label: "Q",
unit: "",
min: 0.1,
max: 20,
step: 0.01,
default: def,
scale: "log",
hint,
});
const gainDb = (min = -40, max = 40, def = 0): HfAudioFxNumberParam => ({
kind: "number",
key: "gain",
label: "Gain",
unit: "dB",
min,
max,
step: 0.1,
default: def,
});
const poles: HfAudioFxEnumParam = {
kind: "enum",
key: "poles",
label: "Slope",
options: [
{ value: "1", label: "6 dB/oct" },
{ value: "2", label: "12 dB/oct" },
],
default: "2",
hint: "Two poles is the usual biquad; one pole is gentler.",
};
/**
* Every effect, in panel order. Ranges are the usable span for each control;
* a value that survives `normalizeAudioFxParams` is always safe to realise.
*/
export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
{
id: "peaking",
label: "Peaking EQ",
group: "filter",
description: "Boost or cut a band, leaving everything either side alone.",
params: [freq("frequency", "Frequency", 1000), gainDb(-40, 40, 0), qParam(1)],
web: "biquad-peaking",
},
{
id: "lowshelf",
label: "Low Shelf",
group: "filter",
description: "Lift or drop everything below the corner frequency.",
// No Q: the Web Audio spec leaves it unused for shelving filters, so the
// control moved nothing — and being automatable, a lane drawn on it would
// have been silently inert.
params: [freq("frequency", "Frequency", 200, 20, 2000), gainDb(-40, 40, 0)],
web: "biquad-lowshelf",
},
{
id: "highshelf",
label: "High Shelf",
group: "filter",
description: "Lift or drop everything above the corner frequency.",
params: [freq("frequency", "Frequency", 4000, 500, 20000), gainDb(-40, 40, 0)],
web: "biquad-highshelf",
},
{
id: "highpass",
label: "High-pass",
group: "filter",
description: "Remove low frequencies — the usual fix for rumble on a voice.",
params: [freq("frequency", "Cutoff", 300, 20, 20000), qParam(0.707), poles],
web: "biquad-highpass",
},
{
id: "lowpass",
label: "Low-pass",
group: "filter",
description: "Remove high frequencies — darkens or muffles a track.",
params: [freq("frequency", "Cutoff", 8000, 100, 20000), qParam(0.707), poles],
web: "biquad-lowpass",
},
{
id: "compressor",
label: "Compressor",
group: "dynamics",
description: "Pull loud parts down so the quiet ones can come up.",
params: [
{
kind: "number",
key: "threshold",
label: "Threshold",
unit: "dB",
min: -60,
max: 0,
step: 0.5,
default: -24,
hint: "Level above which the compressor starts working.",
},
{
kind: "number",
key: "ratio",
label: "Ratio",
unit: ":1",
min: 1,
max: 20,
step: 0.1,
default: 4,
},
{
kind: "number",
key: "attack",
label: "Attack",
unit: "ms",
min: 0.01,
max: 2000,
step: 0.1,
default: 20,
scale: "log",
},
{
kind: "number",
key: "release",
label: "Release",
unit: "ms",
min: 0.01,
max: 9000,
step: 1,
default: 250,
scale: "log",
},
{
kind: "number",
key: "knee",
label: "Knee",
unit: "",
min: 1,
max: 8,
step: 0.01,
default: 2.83,
hint: "1 is a hard corner; higher eases into it.",
},
{
kind: "number",
key: "makeup",
label: "Makeup",
unit: "dB",
min: 0,
max: 36,
step: 0.1,
default: 0,
},
{
kind: "number",
key: "mix",
label: "Mix",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 1,
hint: "Below 1 blends the dry signal back in.",
},
],
web: "worklet-compressor",
},
{
id: "limiter",
label: "Limiter",
group: "dynamics",
description: "Hard ceiling — nothing gets past the limit.",
params: [
{
kind: "number",
key: "limit",
label: "Ceiling",
unit: "dB",
min: -24,
max: 0,
step: 0.1,
default: -1,
},
{
kind: "number",
key: "attack",
label: "Attack",
unit: "ms",
min: 0.1,
max: 80,
step: 0.1,
default: 5,
},
{
kind: "number",
key: "release",
label: "Release",
unit: "ms",
min: 1,
max: 8000,
step: 1,
default: 50,
scale: "log",
},
{
kind: "number",
key: "level_out",
label: "Output",
unit: "dB",
min: -24,
max: 24,
step: 0.1,
default: 0,
},
],
web: "worklet-limiter",
},
{
id: "gate",
label: "Noise Gate",
group: "dynamics",
description: "Silence the track when it drops below the threshold.",
params: [
{
kind: "number",
key: "threshold",
label: "Threshold",
unit: "dB",
min: -80,
max: 0,
step: 0.5,
default: -35,
},
{
kind: "number",
key: "range",
label: "Range",
unit: "dB",
min: -80,
max: 0,
step: 0.5,
default: -24,
hint: "How far down the gate pulls when closed.",
},
{
kind: "number",
key: "ratio",
label: "Ratio",
unit: ":1",
min: 1,
max: 20,
step: 0.1,
default: 10,
},
{
kind: "number",
key: "attack",
label: "Attack",
unit: "ms",
min: 0.01,
max: 9000,
step: 0.1,
default: 1,
scale: "log",
},
{
kind: "number",
key: "release",
label: "Release",
unit: "ms",
min: 0.01,
max: 9000,
step: 1,
default: 100,
scale: "log",
},
{
kind: "number",
key: "knee",
label: "Knee",
unit: "",
min: 1,
max: 8,
step: 0.01,
default: 2.83,
},
],
web: "worklet-gate",
},
{
id: "saturate",
label: "Saturation",
group: "nonlinear",
description: "Soft-clip the waveform for warmth or outright distortion.",
params: [
{
kind: "enum",
key: "type",
label: "Curve",
options: [
{ value: "tanh", label: "Tanh" },
{ value: "atan", label: "Arctan" },
{ value: "cubic", label: "Cubic" },
{ value: "exp", label: "Exponential" },
{ value: "alg", label: "Algebraic" },
{ value: "quintic", label: "Quintic" },
{ value: "sin", label: "Sine" },
{ value: "erf", label: "Error function" },
{ value: "hard", label: "Hard clip" },
],
default: "tanh",
},
{
kind: "number",
key: "threshold",
label: "Threshold",
unit: "dB",
min: -40,
max: 0,
step: 0.1,
default: -6,
},
{
kind: "number",
key: "output",
label: "Output",
unit: "dB",
min: -24,
max: 24,
step: 0.1,
default: 0,
},
{
kind: "number",
key: "oversample",
label: "Oversample",
unit: "x",
min: 1,
max: 8,
step: 1,
default: 4,
hint: "Higher costs more but keeps aliasing down.",
},
],
web: "waveshaper",
},
{
id: "bitcrush",
label: "Bitcrush",
group: "nonlinear",
description: "Drop bit depth and sample rate for a lo-fi, digital sound.",
params: [
{
kind: "number",
key: "bits",
label: "Bit depth",
unit: "bit",
min: 1,
max: 32,
step: 0.1,
default: 8,
},
{
kind: "number",
key: "samples",
label: "Sample hold",
unit: "x",
min: 1,
max: 250,
step: 1,
default: 1,
hint: "Repeats each sample N times — a crude downsample.",
},
{
kind: "number",
key: "mix",
label: "Mix",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 1,
},
],
web: "worklet-bitcrush",
},
{
id: "delay",
label: "Delay",
group: "time",
description: "Repeating echoes behind the dry signal.",
params: [
{
kind: "number",
key: "time",
label: "Time",
unit: "ms",
min: 1,
max: 5000,
step: 1,
default: 250,
scale: "log",
},
// aecho rejects a decay of exactly 0 ("out of allowed range: (0, 1]"),
// so the floor is a hair above silence rather than at it.
{
kind: "number",
key: "feedback",
label: "Feedback",
unit: "",
min: 0.01,
max: 0.95,
step: 0.01,
default: 0.35,
},
{
kind: "number",
key: "mix",
label: "Mix",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 0.4,
},
],
web: "delay-feedback",
},
{
id: "chorus",
label: "Chorus",
group: "time",
description: "Detuned copies of the signal for width and thickness.",
params: [
{
kind: "number",
key: "delay",
label: "Delay",
unit: "ms",
min: 1,
max: 100,
step: 0.1,
default: 7,
},
{
kind: "number",
key: "depth",
label: "Depth",
unit: "ms",
min: 0,
max: 10,
step: 0.01,
default: 2,
},
{
kind: "number",
key: "speed",
label: "Rate",
unit: "Hz",
min: 0.01,
max: 10,
step: 0.01,
default: 1,
},
{
kind: "number",
key: "mix",
label: "Mix",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 0.5,
},
],
web: "chorus-lfo",
},
{
id: "phaser",
label: "Phaser",
group: "time",
description: "Sweeping notches moving through the spectrum.",
params: [
{
kind: "number",
key: "in_gain",
label: "Input",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 0.4,
},
{
kind: "number",
key: "out_gain",
label: "Output",
unit: "",
min: 0,
max: 2,
step: 0.01,
default: 0.74,
},
// aphaser advertises a delay range starting at 0 but rejects it at
// runtime with "delay is too small"; 0.1 ms is the real floor.
{
kind: "number",
key: "delay",
label: "Delay",
unit: "ms",
min: 0.1,
max: 5,
step: 0.1,
default: 3,
},
{
kind: "number",
key: "decay",
label: "Decay",
unit: "",
min: 0,
max: 0.99,
step: 0.01,
default: 0.4,
},
{
kind: "number",
key: "speed",
label: "Rate",
unit: "Hz",
min: 0.1,
max: 2,
step: 0.01,
default: 0.5,
},
{
kind: "enum",
key: "type",
label: "Waveform",
options: [
{ value: "0", label: "Triangular" },
{ value: "1", label: "Sinusoidal" },
],
default: "0",
},
],
web: "allpass-phaser",
},
{
id: "reverb",
label: "Reverb",
group: "time",
description:
"Room tail. Both ends convolve the same generated impulse, so preview matches render.",
params: [
{
kind: "number",
key: "size",
label: "Room size",
unit: "",
min: 0.05,
max: 1,
step: 0.01,
default: 0.7,
},
{
kind: "number",
key: "damping",
label: "Damping",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 0.5,
hint: "Higher rolls the top off the tail faster.",
},
{
kind: "number",
key: "wet",
label: "Wet",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 0.35,
},
{
kind: "number",
key: "dry",
label: "Dry",
unit: "",
min: 0,
max: 1,
step: 0.01,
default: 0.7,
},
],
web: "convolver",
},
] as const;
const BY_ID = new Map(HF_AUDIO_FX.map((d) => [d.id, d]));
export function getAudioFxDef(id: string): HfAudioFxDef | undefined {
return BY_ID.get(id);
}
export const HF_AUDIO_FX_IDS: readonly string[] = HF_AUDIO_FX.map((d) => d.id);
/** Every parameter at its declared default, ready to seed a freshly added effect. */
export function defaultAudioFxParams(id: string): HfAudioFxParamValues {
const def = BY_ID.get(id);
if (!def) return {};
const out: HfAudioFxParamValues = {};
for (const p of def.params) out[p.key] = p.default;
return out;
}
/**
* Clamp and fill a parameter set so it is always renderable: unknown keys are
* dropped, missing keys take their default, numbers are clamped into their
* declared range, and an unrecognised enum value falls back to its default.
* A non-finite number is treated as missing rather than passed through, since
* NaN reaching an AudioParam silences the node for the rest of the render.
*/
export function normalizeAudioFxParams(
id: string,
values: Readonly<HfAudioFxParamValues> | undefined,
): HfAudioFxParamValues {
const def = BY_ID.get(id);
if (!def) return {};
const out: HfAudioFxParamValues = {};
for (const p of def.params) {
const raw = values?.[p.key];
if (p.kind === "enum") {
const ok = p.options.some((o) => o.value === raw);
out[p.key] = ok ? (raw as string) : p.default;
continue;
}
const n = typeof raw === "number" ? raw : Number(raw);
out[p.key] = Number.isFinite(n) ? Math.min(p.max, Math.max(p.min, n)) : p.default;
}
return out;
}
export interface HfAudioFxNode {
/** Effect id from HF_AUDIO_FX. */
type: string;
/** Absent means enabled — chain files written before the field existed still load. */
enabled?: boolean;
params?: HfAudioFxParamValues;
}
export interface HfAudioFxChain {
version: number;
nodes: HfAudioFxNode[];
}
export class AudioFxChainError extends Error {
constructor(message: string) {
super(message);
this.name = "AudioFxChainError";
}
}
/**
* Parse a chain file. Unknown effect ids are rejected rather than skipped: a
* chain that silently loses a node would render differently from the project
* the author saved, which is worse than refusing to render at all.
*/
export function parseAudioFxChain(json: string): HfAudioFxChain {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch (err) {
throw new AudioFxChainError(`Chain file is not valid JSON: ${(err as Error).message}`);
}
if (typeof raw !== "object" || raw === null) {
throw new AudioFxChainError("Chain file must be a JSON object.");
}
const obj = raw as { version?: unknown; nodes?: unknown };
if (obj.version !== HF_AUDIO_FX_CHAIN_VERSION) {
throw new AudioFxChainError(`Unsupported chain version: ${String(obj.version)}`);
}
if (!Array.isArray(obj.nodes)) {
throw new AudioFxChainError("Chain file is missing a `nodes` array.");
}
const nodes: HfAudioFxNode[] = obj.nodes.map((n, i) => {
if (typeof n !== "object" || n === null) {
throw new AudioFxChainError(`Node ${i} is not an object.`);
}
const node = n as { type?: unknown; enabled?: unknown; params?: unknown };
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
}
return {
type: node.type,
enabled: node.enabled !== false,
params: normalizeAudioFxParams(
node.type,
(node.params ?? undefined) as HfAudioFxParamValues | undefined,
),
};
});
return { version: HF_AUDIO_FX_CHAIN_VERSION, nodes };
}
/** The nodes that should process audio, in order. */
export function enabledAudioFxNodes(chain: HfAudioFxChain): HfAudioFxNode[] {
return chain.nodes.filter((n) => n.enabled !== false);
}
/** Serialise a chain for the `data-fx-chain` attribute. */
export function serializeAudioFxChain(chain: HfAudioFxChain): string {
return JSON.stringify({
version: HF_AUDIO_FX_CHAIN_VERSION,
nodes: chain.nodes.map((node) => ({
type: node.type,
...(node.enabled === false ? { enabled: false } : {}),
params: normalizeAudioFxParams(node.type, node.params),
})),
});
}