mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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
@@ -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