Skip to content

Commit 5752d22

Browse files
vanceingallsclaude
andauthored
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>
1 parent 43dba22 commit 5752d22

4 files changed

Lines changed: 968 additions & 0 deletions

File tree

‎packages/core/package-subpaths.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@
9292
"types": "./dist/compiler/index.d.ts",
9393
"environments": ["bun", "node"]
9494
},
95+
"./audio-fx": {
96+
"source": "./src/audioFx.ts",
97+
"runtime": "./dist/audioFx.js",
98+
"types": "./dist/audioFx.d.ts",
99+
"environments": ["browser", "bun", "node"]
100+
},
95101
"./color-grading": {
96102
"source": "./src/colorGrading.ts",
97103
"runtime": "./dist/colorGrading.js",

‎packages/core/package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@
106106
"import": "./src/compiler/index.ts",
107107
"types": "./src/compiler/index.ts"
108108
},
109+
"./audio-fx": {
110+
"bun": "./src/audioFx.ts",
111+
"node": "./dist/audioFx.js",
112+
"import": "./src/audioFx.ts",
113+
"types": "./src/audioFx.ts"
114+
},
109115
"./color-grading": {
110116
"bun": "./src/colorGrading.ts",
111117
"node": "./dist/colorGrading.js",
@@ -368,6 +374,10 @@
368374
"import": "./dist/compiler/index.js",
369375
"types": "./dist/compiler/index.d.ts"
370376
},
377+
"./audio-fx": {
378+
"import": "./dist/audioFx.js",
379+
"types": "./dist/audioFx.d.ts"
380+
},
371381
"./color-grading": {
372382
"import": "./dist/colorGrading.js",
373383
"types": "./dist/colorGrading.d.ts"

‎packages/core/src/audioFx.test.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
AudioFxChainError,
4+
defaultAudioFxParams,
5+
enabledAudioFxNodes,
6+
getAudioFxDef,
7+
HF_AUDIO_FX,
8+
HF_AUDIO_FX_CHAIN_VERSION,
9+
HF_AUDIO_FX_IDS,
10+
normalizeAudioFxParams,
11+
parseAudioFxChain,
12+
} from "./audioFx.js";
13+
14+
const chain = (nodes: unknown[]): string =>
15+
JSON.stringify({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes });
16+
17+
describe("effect registry", () => {
18+
it("has unique ids and unique parameter keys per effect", () => {
19+
expect(new Set(HF_AUDIO_FX_IDS).size).toBe(HF_AUDIO_FX.length);
20+
for (const def of HF_AUDIO_FX) {
21+
const keys = def.params.map((p) => p.key);
22+
expect(new Set(keys).size, `${def.id} has duplicate param keys`).toBe(keys.length);
23+
}
24+
});
25+
26+
it("declares every default inside its own declared range", () => {
27+
for (const def of HF_AUDIO_FX) {
28+
for (const p of def.params) {
29+
if (p.kind === "enum") {
30+
expect(
31+
p.options.some((o) => o.value === p.default),
32+
`${def.id}.${p.key} default is not one of its options`,
33+
).toBe(true);
34+
} else {
35+
expect(p.default, `${def.id}.${p.key} default below min`).toBeGreaterThanOrEqual(p.min);
36+
expect(p.default, `${def.id}.${p.key} default above max`).toBeLessThanOrEqual(p.max);
37+
expect(p.min).toBeLessThan(p.max);
38+
}
39+
}
40+
}
41+
});
42+
43+
it("gives every effect at least one knob to turn", () => {
44+
for (const def of HF_AUDIO_FX) {
45+
expect(def.params.length, `${def.id} exposes no parameters`).toBeGreaterThan(0);
46+
}
47+
});
48+
});
49+
50+
describe("normalizeAudioFxParams", () => {
51+
it("fills missing keys with defaults", () => {
52+
expect(normalizeAudioFxParams("peaking", {})).toEqual(defaultAudioFxParams("peaking"));
53+
});
54+
55+
it("clamps out-of-range numbers into the renderable range", () => {
56+
const v = normalizeAudioFxParams("peaking", { frequency: 999999, gain: -500, q: 0 });
57+
expect(v.frequency).toBe(20000);
58+
expect(v.gain).toBe(-40);
59+
expect(v.q).toBe(0.1);
60+
});
61+
62+
it("replaces NaN and non-numeric junk with the default", () => {
63+
// NaN reaching a filter string fails the entire render, so it must never survive.
64+
const v = normalizeAudioFxParams("peaking", {
65+
frequency: Number.NaN,
66+
gain: "loud" as unknown as number,
67+
});
68+
expect(v.frequency).toBe(1000);
69+
expect(v.gain).toBe(0);
70+
});
71+
72+
it("falls back to the default for an unrecognised enum value", () => {
73+
expect(normalizeAudioFxParams("saturate", { type: "sawtooth" }).type).toBe("tanh");
74+
expect(normalizeAudioFxParams("saturate", { type: "atan" }).type).toBe("atan");
75+
});
76+
77+
it("drops keys the effect does not declare", () => {
78+
const v = normalizeAudioFxParams("peaking", { frequency: 500, nonsense: 1 });
79+
expect(Object.keys(v).sort()).toEqual(["frequency", "gain", "q"]);
80+
});
81+
});
82+
83+
describe("parseAudioFxChain", () => {
84+
it("round-trips a chain and defaults `enabled` to true", () => {
85+
const parsed = parseAudioFxChain(chain([{ type: "peaking", params: { gain: -6 } }]));
86+
expect(parsed.nodes).toHaveLength(1);
87+
expect(parsed.nodes[0]!.enabled).toBe(true);
88+
expect(parsed.nodes[0]!.params!.gain).toBe(-6);
89+
});
90+
91+
it("rejects an unknown effect rather than silently dropping it", () => {
92+
// Skipping the node would render something other than what was authored.
93+
expect(() => parseAudioFxChain(chain([{ type: "vibrato" }]))).toThrow(AudioFxChainError);
94+
});
95+
96+
it("rejects an unsupported version", () => {
97+
expect(() => parseAudioFxChain(JSON.stringify({ version: 99, nodes: [] }))).toThrow(
98+
/Unsupported chain version/,
99+
);
100+
});
101+
102+
it("rejects malformed JSON and a missing nodes array", () => {
103+
expect(() => parseAudioFxChain("{oops")).toThrow(/not valid JSON/);
104+
expect(() => parseAudioFxChain(JSON.stringify({ version: 1 }))).toThrow(/missing a `nodes`/);
105+
});
106+
});
107+
108+
describe("enabledAudioFxNodes", () => {
109+
it("treats a missing enabled flag as enabled", () => {
110+
const nodes = enabledAudioFxNodes({
111+
version: 1,
112+
nodes: [{ type: "peaking" }, { type: "delay", enabled: false }],
113+
});
114+
expect(nodes.map((n) => n.type)).toEqual(["peaking"]);
115+
});
116+
});
117+
118+
describe("declared parameters are real", () => {
119+
const paramKeys = (id: string): string[] => (getAudioFxDef(id)?.params ?? []).map((p) => p.key);
120+
121+
it("offers no shelf Q, which a BiquadFilterNode ignores for shelf types", () => {
122+
// It was also flagged automatable, so a lane could be drawn on it and heard
123+
// not at all.
124+
expect(paramKeys("lowshelf")).not.toContain("q");
125+
expect(paramKeys("highshelf")).not.toContain("q");
126+
// Peaking and the pass filters do use Q.
127+
expect(paramKeys("peaking")).toContain("q");
128+
expect(paramKeys("lowpass")).toContain("q");
129+
});
130+
131+
it("offers no knob whose builder reads nothing", () => {
132+
// chorus `decay` and bitcrush `aa` were declared with ranges and defaults but
133+
// no builder ever read them: dials that moved and did nothing.
134+
expect(paramKeys("chorus")).not.toContain("decay");
135+
expect(paramKeys("bitcrush")).not.toContain("aa");
136+
// The phaser's decay does drive its sweep depth, and the gate's knee is read.
137+
expect(paramKeys("phaser")).toContain("decay");
138+
expect(paramKeys("gate")).toContain("knee");
139+
});
140+
});

0 commit comments

Comments
 (0)