diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 8ab6c60cc0..26487ab8e9 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -804,6 +804,15 @@ export interface HfAudioFxNode { /** Set on nodes the carve analysis generated, so re-running replaces them * instead of stacking another set on top of hand-added effects. */ fromCarve?: boolean; + /** + * Id of the preset that wrote this node, for the same reason `fromCarve` + * exists: re-applying a preset replaces its own nodes rather than adding a + * second copy, and the rack can brace them together under the preset's name. + * + * The id rather than a flag, because a chain can carry more than one preset + * and each has to be able to find its own. + */ + fromPreset?: string; /** Absent means enabled — chain files written before the field existed still load. */ enabled?: boolean; params?: HfAudioFxParamValues; diff --git a/packages/core/src/audioFxPresets.test.ts b/packages/core/src/audioFxPresets.test.ts new file mode 100644 index 0000000000..40c8742093 --- /dev/null +++ b/packages/core/src/audioFxPresets.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import { + getAudioFxDef, + HF_AUDIO_FX_CHAIN_VERSION, + normalizeAudioFxParams, + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, + type HfAudioFxNumberParam, +} from "./audioFx.js"; +import { + activeAudioFxPresetIds, + applyAudioFxPreset, + audioFxPresetNodes, + audioFxPresetsByFamily, + getAudioFxPreset, + HF_AUDIO_FX_PRESET_FAMILIES, + HF_AUDIO_FX_PRESETS, +} from "./audioFxPresets.js"; + +const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }); +const need = (id: string) => { + const p = getAudioFxPreset(id); + if (!p) throw new Error(`no preset ${id}`); + return p; +}; + +/** + * The catalogue is hand-written numbers, which is exactly the kind of thing + * that rots quietly: a value outside its declared range does not throw, it gets + * clamped, and the preset then sounds like something nobody chose. These check + * the data itself rather than the machinery around it. + */ +describe("the catalogue is internally valid", () => { + it("has unique ids and a family the menu knows", () => { + const ids = HF_AUDIO_FX_PRESETS.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + for (const p of HF_AUDIO_FX_PRESETS) { + expect(HF_AUDIO_FX_PRESET_FAMILIES, `${p.id} sits on no shelf`).toContain(p.family); + expect(p.nodes.length, `${p.id} is empty`).toBeGreaterThan(0); + expect(p.label.length).toBeGreaterThan(0); + expect(p.description.length).toBeGreaterThan(0); + } + }); + + it("names only real effects", () => { + for (const p of HF_AUDIO_FX_PRESETS) { + for (const node of p.nodes) { + expect(getAudioFxDef(node.type), `${p.id} uses unknown effect "${node.type}"`).toBeTruthy(); + } + } + }); + + it("names only parameters those effects declare", () => { + for (const p of HF_AUDIO_FX_PRESETS) { + for (const node of p.nodes) { + const keys = new Set((getAudioFxDef(node.type)?.params ?? []).map((x) => x.key)); + for (const key of Object.keys(node.params ?? {})) { + expect(keys.has(key), `${p.id}: ${node.type} has no parameter "${key}"`).toBe(true); + } + } + } + }); + + it("sets every value inside its own declared range", () => { + // The one that actually catches typos. A value out of range is silently + // clamped, so without this a preset can ship sounding like nothing anyone + // chose and still pass every other test here. + for (const p of HF_AUDIO_FX_PRESETS) { + for (const node of p.nodes) { + const def = getAudioFxDef(node.type); + for (const [key, raw] of Object.entries(node.params ?? {})) { + const param = def?.params.find((x) => x.key === key); + if (!param) continue; + if (param.kind === "enum") { + expect( + param.options.some((o) => o.value === raw), + `${p.id}: ${node.type}.${key} = "${String(raw)}" is not one of its options`, + ).toBe(true); + continue; + } + const n = param as HfAudioFxNumberParam; + expect(typeof raw, `${p.id}: ${node.type}.${key} is not a number`).toBe("number"); + expect( + raw as number, + `${p.id}: ${node.type}.${key} = ${String(raw)} is below its minimum ${n.min}`, + ).toBeGreaterThanOrEqual(n.min); + expect( + raw as number, + `${p.id}: ${node.type}.${key} = ${String(raw)} is above its maximum ${n.max}`, + ).toBeLessThanOrEqual(n.max); + } + } + } + }); + + it("survives being written to an attribute and read back", () => { + for (const p of HF_AUDIO_FX_PRESETS) { + const chain = applyAudioFxPreset(empty(), p); + const back = parseAudioFxChain(serializeAudioFxChain(chain)); + expect( + back.nodes.map((n) => n.type), + `${p.id} did not round-trip`, + ).toEqual(chain.nodes.map((n) => n.type)); + } + }); + + it("ends anything that boosts with a ceiling", () => { + // A preset that adds presence and makeup gain can push the chain past full + // scale, and the render clamps what it is handed. Every preset that lifts + // has to hand the mix something bounded. + for (const p of HF_AUDIO_FX_PRESETS) { + const lifts = p.nodes.some((node) => { + const g = node.params?.["gain"]; + const makeup = node.params?.["makeup"]; + const out = node.params?.["output"]; + return ( + (typeof g === "number" && g > 0) || + (typeof makeup === "number" && makeup > 0) || + (typeof out === "number" && out > 0) + ); + }); + if (!lifts) continue; + const last = p.nodes[p.nodes.length - 1]; + const bounded = + p.nodes.some((n) => n.type === "limiter") || + // A band-limited character preset cannot run away: its own filters and + // soft clip cap it, and a limiter would change the effect. + p.family === "character"; + expect(bounded, `${p.id} lifts level but never bounds it (ends on ${last?.type})`).toBe(true); + } + }); + + it("puts the limiter last wherever it has one", () => { + for (const p of HF_AUDIO_FX_PRESETS) { + const at = p.nodes.findIndex((n) => n.type === "limiter"); + if (at === -1) continue; + expect(at, `${p.id}: a limiter that is not last is not a ceiling`).toBe(p.nodes.length - 1); + } + }); + + it("keeps every shelf stocked", () => { + for (const family of HF_AUDIO_FX_PRESET_FAMILIES) { + expect(audioFxPresetsByFamily(family).length, `${family} is empty`).toBeGreaterThan(0); + } + }); +}); + +describe("applying a preset", () => { + it("writes ordinary nodes with their defaults filled in", () => { + const chain = applyAudioFxPreset(empty(), need("rumble-cut")); + expect(chain.nodes).toHaveLength(1); + const node = chain.nodes[0]!; + expect(node.type).toBe("highpass"); + // Named 100 Hz; everything else comes from the effect, so the file holds a + // complete node rather than a partial one the graph has to guess at. + expect(node.params).toEqual({ ...normalizeAudioFxParams("highpass", {}), frequency: 100 }); + expect(node.fromPreset).toBe("rumble-cut"); + expect(node.enabled).toBe(true); + }); + + it("gives every node an id, because a lane addresses effects by id", () => { + const chain = applyAudioFxPreset(empty(), need("telephone")); + const ids = chain.nodes.map((n) => n.id); + expect(ids.every(Boolean)).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("appends rather than replacing, so a character preset can stack on a clean voice", () => { + const voiced = applyAudioFxPreset(empty(), need("voice-clean")); + const both = applyAudioFxPreset(voiced, need("telephone")); + expect(both.nodes.length).toBe(voiced.nodes.length + need("telephone").nodes.length); + expect(activeAudioFxPresetIds(both)).toEqual(["voice-clean", "telephone"]); + // ids stay unique across the two batches + expect(new Set(both.nodes.map((n) => n.id)).size).toBe(both.nodes.length); + }); + + it("replaces the whole chain when asked", () => { + const voiced = applyAudioFxPreset(empty(), need("voice-clean")); + const only = applyAudioFxPreset(voiced, need("hall"), { replaceChain: true }); + expect(activeAudioFxPresetIds(only)).toEqual(["hall"]); + expect(only.nodes).toHaveLength(need("hall").nodes.length); + }); + + it("re-applying swaps its own nodes instead of stacking a second copy", () => { + const once = applyAudioFxPreset(empty(), need("telephone")); + const twice = applyAudioFxPreset(once, need("telephone")); + expect(twice.nodes.length).toBe(once.nodes.length); + expect(activeAudioFxPresetIds(twice)).toEqual(["telephone"]); + }); + + it("re-applying keeps the preset's place in the signal order", () => { + // Order is audible: a telephone band before a limiter is a different sound + // from one after it. Re-applying must not quietly move the preset to the end. + const start = applyAudioFxPreset(empty(), need("telephone")); + const withTail = applyAudioFxPreset(start, need("hall")); + const again = applyAudioFxPreset(withTail, need("telephone")); + expect(again.nodes.map((n) => n.fromPreset)).toEqual([ + ...new Array(need("telephone").nodes.length).fill("telephone"), + ...new Array(need("hall").nodes.length).fill("hall"), + ]); + }); + + it("leaves hand-added effects alone when a preset is re-applied", () => { + const hand = { type: "reverb", id: "mine", params: normalizeAudioFxParams("reverb", {}) }; + const chain: HfAudioFxChain = { version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [hand] }; + const once = applyAudioFxPreset(chain, need("voice-clean")); + const twice = applyAudioFxPreset(once, need("voice-clean")); + expect(twice.nodes.filter((n) => n.id === "mine")).toHaveLength(1); + expect(twice.nodes.filter((n) => n.fromPreset === "voice-clean")).toHaveLength( + need("voice-clean").nodes.length, + ); + }); + + it("mints ids that cannot collide with what is already in the chain", () => { + const chain: HfAudioFxChain = { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: [ + { type: "reverb", id: "n1" }, + { type: "delay", id: "n2" }, + ], + }; + const made = audioFxPresetNodes(need("voice-clean"), chain); + for (const node of made) expect(["n1", "n2"]).not.toContain(node.id); + expect(new Set(made.map((n) => n.id)).size).toBe(made.length); + }); +}); diff --git a/packages/core/src/audioFxPresets.ts b/packages/core/src/audioFxPresets.ts new file mode 100644 index 0000000000..be74eec939 --- /dev/null +++ b/packages/core/src/audioFxPresets.ts @@ -0,0 +1,344 @@ +/** + * Named starting points for the FX rack. + * + * Voice carve turned a pile of effects into one understandable feature. These + * do the same for the cases an analysis cannot decide: a preset is a chain + * somebody already tuned, applied in one click and editable immediately + * afterwards. + * + * A preset is DATA, deliberately. Applying one writes ordinary nodes into + * `data-fx-chain` — the same nodes hand-building would produce — so there is no + * second code path to keep in agreement with the rack, nothing new in the + * render, and no failure mode the chain does not already have. The author can + * see everything that was written on their behalf and change any of it. + * + * Only the parameters a preset actually means are listed; `normalizeAudioFxParams` + * fills the rest from the effect's own defaults. That keeps each entry readable + * as an intent rather than a dump of every knob. + * + * Node ORDER is load-bearing — the chain is serial, so a limiter first and a + * limiter last are different sounds. Every preset below runs + * subtractive filtering → dynamics → tone → character → limiter, the order + * `skills/hyperframes-audio` already teaches. + */ + +import { + HF_AUDIO_FX_CHAIN_VERSION, + mintAudioFxNodeId, + normalizeAudioFxParams, + type HfAudioFxChain, + type HfAudioFxNode, + type HfAudioFxParamValues, +} from "./audioFx.js"; + +/** + * Which shelf of the menu a preset sits on. + * + * Deliberately NOT the effect registry's own `group` (filter/dynamics/…): that + * groups by what an effect *is*, and an author picking a preset is shopping for + * what they *want*. "Telephone" is filters and saturation; nobody looks for it + * under either. + */ +export type HfAudioFxPresetFamily = "voice" | "repair" | "character" | "space"; + +export interface HfAudioFxPresetNode { + /** Effect id from HF_AUDIO_FX. */ + type: string; + /** Only what this preset means to set; the rest come from the effect's defaults. */ + params?: HfAudioFxParamValues; +} + +export interface HfAudioFxPreset { + id: string; + label: string; + family: HfAudioFxPresetFamily; + /** One line, in the author's language — what it does, not which effects it uses. */ + description: string; + nodes: readonly HfAudioFxPresetNode[]; +} + +const preset = ( + id: string, + family: HfAudioFxPresetFamily, + label: string, + description: string, + nodes: readonly HfAudioFxPresetNode[], +): HfAudioFxPreset => ({ id, label, family, description, nodes }); + +/** + * A 24 dB/oct skirt is two of these stacked: `poles` tops out at 2 (12 dB/oct) + * because a BiquadFilterNode is two-pole and that is the honest maximum for one + * node. The telephone band wants the steeper slope, so it pays for two. + */ +const steep = (type: "highpass" | "lowpass", frequency: number): HfAudioFxPresetNode[] => [ + { type, params: { frequency, q: 0.707, poles: "2" } }, + { type, params: { frequency, q: 0.707, poles: "2" } }, +]; + +export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [ + // ---------------------------------------------------------------- voice -- + preset( + "voice-clean", + "voice", + "Clean Voice", + "Cuts rumble and mud, evens out the level, adds a little clarity.", + [ + { type: "highpass", params: { frequency: 80, q: 0.707, poles: "2" } }, + { type: "peaking", params: { frequency: 250, gain: -3, q: 1.2 } }, + { + type: "compressor", + params: { threshold: -20, ratio: 3, attack: 12, release: 180, makeup: 3 }, + }, + { type: "peaking", params: { frequency: 3000, gain: 2.5, q: 1 } }, + { type: "limiter", params: { limit: -1, attack: 5, release: 50 } }, + ], + ), + preset( + "voice-broadcast", + "voice", + "Broadcast", + "Denser and more forward — a radio-presenter sound.", + [ + { type: "highpass", params: { frequency: 90, q: 0.707, poles: "2" } }, + { type: "peaking", params: { frequency: 400, gain: -3, q: 1.4 } }, + { + type: "compressor", + params: { threshold: -24, ratio: 4, attack: 8, release: 150, makeup: 5 }, + }, + { type: "peaking", params: { frequency: 2500, gain: 3, q: 0.9 } }, + { type: "highshelf", params: { frequency: 8000, gain: 2 } }, + { type: "saturate", params: { type: "tanh", threshold: -12, output: 0 } }, + { type: "limiter", params: { limit: -1, attack: 5, release: 60 } }, + ], + ), + preset( + "voice-warm", + "voice", + "Close & Warm", + "Intimate and lightly handled, for a voice close to the mic.", + [ + { type: "highpass", params: { frequency: 70, q: 0.707, poles: "2" } }, + { type: "lowshelf", params: { frequency: 180, gain: 2 } }, + { + type: "compressor", + params: { threshold: -18, ratio: 2.5, attack: 20, release: 250, makeup: 2 }, + }, + { type: "peaking", params: { frequency: 3000, gain: 1.5, q: 0.8 } }, + { type: "limiter", params: { limit: -1.5 } }, + ], + ), + + // --------------------------------------------------------------- repair -- + // Named for what they DO. None of these is noise reduction: that needs + // spectral work this effect set does not have, and a preset implying + // otherwise would be a lie the author only discovers after trusting it. + preset( + "rumble-cut", + "repair", + "Cut Rumble", + "Removes traffic, handling and air-conditioning from under a voice.", + [{ type: "highpass", params: { frequency: 100, q: 0.707, poles: "2" } }], + ), + preset( + "room-gate", + "repair", + "Quiet Between Phrases", + "Silences the gaps between words. Room tone under speech stays — this closes the pauses, it does not remove noise.", + [{ type: "gate", params: { threshold: -45, range: -18, ratio: 10, attack: 2, release: 180 } }], + ), + preset( + "boom-tame", + "repair", + "Tame Boominess", + "Takes out the chestiness of a voice too close to the mic.", + [{ type: "peaking", params: { frequency: 200, gain: -4, q: 1.4 } }], + ), + preset( + "harsh-tame", + "repair", + "Soften Harshness", + "Rounds off a brittle upper-mid. Broad and always-on; sibilance proper wants the measuring version.", + [{ type: "peaking", params: { frequency: 3200, gain: -3, q: 1.6 } }], + ), + + // ------------------------------------------------------------ character -- + preset( + "telephone", + "character", + "Telephone", + "Down the line — the narrow band of a phone call.", + [ + ...steep("highpass", 300), + ...steep("lowpass", 3400), + { type: "peaking", params: { frequency: 1200, gain: 6, q: 1.2 } }, + { type: "peaking", params: { frequency: 550, gain: -4, q: 1 } }, + { type: "saturate", params: { type: "tanh", threshold: -18, output: -2 } }, + ], + ), + preset("radio-am", "character", "AM Radio", "Narrow, gritty and a little crushed.", [ + { type: "highpass", params: { frequency: 400, q: 0.707, poles: "2" } }, + { type: "lowpass", params: { frequency: 3000, q: 0.707, poles: "2" } }, + { type: "saturate", params: { type: "tanh", threshold: -15, output: -2 } }, + { type: "bitcrush", params: { bits: 10, samples: 1, mix: 0.25 } }, + ]), + preset( + "megaphone", + "character", + "Megaphone", + "Shouted through a horn, with the slap that comes with it.", + [ + { type: "highpass", params: { frequency: 500, q: 0.707, poles: "2" } }, + { type: "lowpass", params: { frequency: 4000, q: 0.707, poles: "2" } }, + { type: "peaking", params: { frequency: 1800, gain: 8, q: 1.5 } }, + { type: "saturate", params: { type: "hard", threshold: -12, output: -3 } }, + { type: "delay", params: { time: 40, feedback: 0.15, mix: 0.15 } }, + ], + ), + preset( + "lofi-tape", + "character", + "Tape", + "Worn, warm and slightly unsteady, like a played-out cassette.", + [ + { type: "lowpass", params: { frequency: 6500, q: 0.707, poles: "2" } }, + { type: "lowshelf", params: { frequency: 120, gain: 2 } }, + { type: "saturate", params: { type: "tanh", threshold: -14, output: 0 } }, + { type: "bitcrush", params: { bits: 12, samples: 2, mix: 0.35 } }, + // A slow, shallow chorus is what wow and flutter actually are. + { type: "chorus", params: { delay: 6, depth: 0.6, speed: 0.4, mix: 0.15 } }, + ], + ), + preset("pa-system", "character", "Tannoy", "Announced across a concourse.", [ + { type: "highpass", params: { frequency: 350, q: 0.707, poles: "2" } }, + { type: "lowpass", params: { frequency: 3500, q: 0.707, poles: "2" } }, + { type: "peaking", params: { frequency: 1500, gain: 5, q: 1.2 } }, + { type: "saturate", params: { type: "tanh", threshold: -16, output: -1 } }, + { type: "reverb", params: { size: 0.5, damping: 0.7, wet: 0.25, dry: 0.8 } }, + ]), + preset("intercom", "character", "Intercom", "Buzzed through a door panel, squelch and all.", [ + { type: "gate", params: { threshold: -40, range: -30, ratio: 10, attack: 1, release: 120 } }, + { type: "highpass", params: { frequency: 500, q: 0.707, poles: "2" } }, + { type: "lowpass", params: { frequency: 3000, q: 0.707, poles: "2" } }, + { type: "peaking", params: { frequency: 2000, gain: 6, q: 2 } }, + { type: "bitcrush", params: { bits: 11, samples: 1, mix: 0.3 } }, + ]), + + // ---------------------------------------------------------------- space -- + preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [ + { type: "reverb", params: { size: 0.25, damping: 0.6, wet: 0.18, dry: 0.9 } }, + ]), + preset( + "room-natural", + "space", + "Natural Room", + "Sounds recorded somewhere rather than nowhere.", + [{ type: "reverb", params: { size: 0.5, damping: 0.5, wet: 0.25, dry: 0.85 } }], + ), + preset("hall", "space", "Hall", "Long and open, for something that should sit far back.", [ + { type: "reverb", params: { size: 0.9, damping: 0.3, wet: 0.4, dry: 0.75 } }, + ]), + preset("slap-echo", "space", "Slap Echo", "One quick repeat — rockabilly vocal, not a wash.", [ + { type: "delay", params: { time: 110, feedback: 0.12, mix: 0.22 } }, + ]), + preset("dub-throw", "space", "Dub Throw", "Repeats that trail off well behind the beat.", [ + { type: "delay", params: { time: 375, feedback: 0.55, mix: 0.3 } }, + ]), +]; + +export const HF_AUDIO_FX_PRESET_IDS: readonly string[] = HF_AUDIO_FX_PRESETS.map((p) => p.id); + +const BY_ID = new Map(HF_AUDIO_FX_PRESETS.map((p) => [p.id, p])); + +export function getAudioFxPreset(id: string): HfAudioFxPreset | undefined { + return BY_ID.get(id); +} + +/** Menu order: the shelves, in the order the panel lists them. */ +export const HF_AUDIO_FX_PRESET_FAMILIES: readonly HfAudioFxPresetFamily[] = [ + "voice", + "repair", + "character", + "space", +]; + +export function audioFxPresetsByFamily(family: HfAudioFxPresetFamily): HfAudioFxPreset[] { + return HF_AUDIO_FX_PRESETS.filter((p) => p.family === family); +} + +/** + * Realise a preset as nodes ready to splice into `chain`. + * + * Ids are minted against the chain the nodes are joining, not against the + * preset, so applying the same preset twice cannot collide — and every node + * gets one, because an automation lane addresses its effect by id and a node + * without one can never be automated. + * + * Params are normalised here rather than at apply time: a preset that names a + * value the effect would clamp should land in the file as the value that will + * actually be rendered, so the rack never shows a number the graph is not using. + */ +export function audioFxPresetNodes( + preset: HfAudioFxPreset, + chain: HfAudioFxChain, +): HfAudioFxNode[] { + const out: HfAudioFxNode[] = []; + // Minted against a growing chain, so ids are unique within this batch too. + let running: HfAudioFxChain = { ...chain, nodes: [...chain.nodes] }; + for (const node of preset.nodes) { + const made: HfAudioFxNode = { + type: node.type, + id: mintAudioFxNodeId(running), + fromPreset: preset.id, + enabled: true, + params: normalizeAudioFxParams(node.type, node.params), + }; + out.push(made); + running = { ...running, nodes: [...running.nodes, made] }; + } + return out; +} + +/** + * The chain after applying a preset. + * + * Appends by default: stacking Telephone onto an already-cleaned voice is a + * real thing to want, and replacing silently would throw away work. Re-applying + * a preset that is already present replaces ITS OWN nodes in place instead of + * adding a second copy — which is what `fromPreset` is for, and mirrors how the + * carve replaces its own bands rather than stacking new ones on hand-added + * effects. + */ +export function applyAudioFxPreset( + chain: HfAudioFxChain, + preset: HfAudioFxPreset, + options: { replaceChain?: boolean } = {}, +): HfAudioFxChain { + if (options.replaceChain) { + return { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: audioFxPresetNodes(preset, { version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }), + }; + } + + const existing = chain.nodes.findIndex((n) => n.fromPreset === preset.id); + if (existing === -1) { + return { ...chain, nodes: [...chain.nodes, ...audioFxPresetNodes(preset, chain)] }; + } + + // Re-apply: drop this preset's old nodes, then rebuild them where the first + // one stood, so the preset keeps its place in the signal order. + const kept = chain.nodes.filter((n) => n.fromPreset !== preset.id); + const before = kept.slice(0, existing); + const after = kept.slice(existing); + const made = audioFxPresetNodes(preset, { ...chain, nodes: kept }); + return { ...chain, nodes: [...before, ...made, ...after] }; +} + +/** Every preset whose nodes are still present, for the rack's group braces. */ +export function activeAudioFxPresetIds(chain: HfAudioFxChain): string[] { + const seen: string[] = []; + for (const node of chain.nodes) { + if (node.fromPreset && !seen.includes(node.fromPreset)) seen.push(node.fromPreset); + } + return seen; +}