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
+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");
});
});