mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 5
parent
43dba22057
commit
5752d22492
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
})),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user