diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index a67c9bf098..936fb0520c 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -98,6 +98,12 @@ "types": "./dist/audioFx.d.ts", "environments": ["browser", "bun", "node"] }, + "./audio-fx-presets": { + "source": "./src/audioFxPresets.ts", + "runtime": "./dist/audioFxPresets.js", + "types": "./dist/audioFxPresets.d.ts", + "environments": ["browser", "bun", "node"] + }, "./audio-fx-tail": { "source": "./src/audio/audioFxTail.ts", "runtime": "./dist/audio/audioFxTail.js", diff --git a/packages/core/package.json b/packages/core/package.json index b87777821a..daecd009e1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -112,6 +112,12 @@ "import": "./src/audioFx.ts", "types": "./src/audioFx.ts" }, + "./audio-fx-presets": { + "bun": "./src/audioFxPresets.ts", + "node": "./dist/audioFxPresets.js", + "import": "./src/audioFxPresets.ts", + "types": "./src/audioFxPresets.ts" + }, "./audio-fx-tail": { "bun": "./src/audio/audioFxTail.ts", "node": "./dist/audio/audioFxTail.js", @@ -402,6 +408,10 @@ "import": "./dist/audioFx.js", "types": "./dist/audioFx.d.ts" }, + "./audio-fx-presets": { + "import": "./dist/audioFxPresets.js", + "types": "./dist/audioFxPresets.d.ts" + }, "./audio-fx-tail": { "import": "./dist/audio/audioFxTail.js", "types": "./dist/audio/audioFxTail.d.ts" 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; +} diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx new file mode 100644 index 0000000000..f841968a8b --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx @@ -0,0 +1,61 @@ +/** + * The preset shelf for the FX rack. + * + * Its own module rather than another block inside the section: the section is + * already the largest file in the panel, and this surface is going to grow — + * search, per-item descriptions and a preview of the chain each preset draws + * are all queued behind it. + */ + +import { + audioFxPresetsByFamily, + HF_AUDIO_FX_PRESET_FAMILIES, + type HfAudioFxPresetFamily, +} from "@hyperframes/core/audio-fx-presets"; + +/** + * Shelf names in the author's language, which is deliberately not the effect + * registry's grouping. `group` says what an effect *is* (filter, dynamics); + * somebody reaching for Telephone is shopping for what they *want*, and + * Telephone is filters and saturation — nobody looks for it under either. + */ +const FAMILY_LABEL: Record = { + voice: "Voice", + repair: "Fix", + character: "Character", + space: "Space", +}; + +export interface FxPresetMenuProps { + onPick(id: string): void; +} + +export function FxPresetMenu({ onPick }: FxPresetMenuProps) { + return ( +
+ {HF_AUDIO_FX_PRESET_FAMILIES.map((family) => ( +
+ + {FAMILY_LABEL[family]} + + {audioFxPresetsByFamily(family).map((preset) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 5b3906ab13..6428f05c67 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -214,6 +214,44 @@ describe("FxSection chain", () => { expect(openFrequency().value).toBe("1600"); }); + it("applies a preset as ordinary nodes, tagged with where they came from", () => { + const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } }); + click(byText(host, "button", "Presets")); + click(byText(host, "button", "Telephone")); + + const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain; + // The band, its honk and de-mud shaping, and the soft clip — a chain the + // author can now see and edit, not an opaque "telephone" setting. + expect(next.nodes.map((n) => n.type)).toEqual([ + "highpass", + "highpass", + "lowpass", + "lowpass", + "peaking", + "peaking", + "saturate", + ]); + expect(next.nodes.every((n) => n.fromPreset === "telephone")).toBe(true); + // Every node needs an id or its parameters can never be automated. + expect(new Set(next.nodes.map((n) => n.id)).size).toBe(next.nodes.length); + }); + + it("adds a preset to what is already there rather than replacing it", () => { + const existing: HfAudioFxChain = { + version: 1, + nodes: [ + { type: "reverb", id: "mine", enabled: true, params: defaultAudioFxParams("reverb") }, + ], + }; + const { host, onChainChange } = mount({ chain: existing }); + click(byText(host, "button", "Presets")); + click(byText(host, "button", "Cut Rumble")); + + const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain; + expect(next.nodes.map((n) => n.id)).toContain("mine"); + expect(next.nodes.map((n) => n.type)).toEqual(["reverb", "highpass"]); + }); + it("cannot move the ends past themselves", () => { const { host } = mount({ chain: chainOf("peaking", "reverb") }); const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]'); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index 33fda26891..1c51a84300 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -22,8 +22,10 @@ import { type HfAudioFxParamValues, } from "@hyperframes/core/audio-fx"; import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve"; +import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets"; import { fxAutomationTarget } from "@hyperframes/core/audio-automation"; import { FxParams, FxParamRow } from "./propertyPanelFxControls.js"; +import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js"; // Shared with the timeline's lane labels: a band is named by its frequency in // both places, and two formatters would drift. import { formatHz } from "../../player/components/automationLaneData"; @@ -686,6 +688,7 @@ export function FxSection({ const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null); const [adding, setAdding] = useState(false); + const [picking, setPicking] = useState(false); const [openNode, setOpenNode] = useState(0); const grouped = useMemo( @@ -708,6 +711,23 @@ export function FxSection({ [chain, onChainPreview], ); + const applyPreset = useCallback( + (id: string) => { + const preset = getAudioFxPreset(id); + if (!preset) return; + // Appends. Stacking a character preset onto an already-cleaned voice is a + // real thing to want, and replacing silently would throw work away — so + // the destructive option is a separate gesture, not the default one. + const next = applyAudioFxPreset(chain, preset); + mutate(next.nodes); + // Land on the first node the preset wrote, so the author can hear what + // arrived and immediately see what it is made of. + setOpenNode(next.nodes.findIndex((n) => n.fromPreset === preset.id)); + setPicking(false); + }, + [chain, mutate], + ); + const addEffect = useCallback( (type: string) => { mutate([ @@ -843,15 +863,29 @@ export function FxSection({ ))} - ) : ( - + ) : null} + + {picking ? : null} + + {adding || picking ? null : ( +
+ + +
)} ); diff --git a/plans/audio-fx-ux/README.md b/plans/audio-fx-ux/README.md new file mode 100644 index 0000000000..4fa6c8a7cd --- /dev/null +++ b/plans/audio-fx-ux/README.md @@ -0,0 +1,180 @@ +# The casual author's view of the FX rack + +The schematic direction won because it _adds information_ — signal order, +routing, what is driven versus set. But information a casual author cannot read +is decoration, and the rack speaks entirely in Hz, dB and ratios. So the drawing +stays and the **language changes**. + +`copy.mts` is the design work: a plain-language layer over every effect in the +registry. `build-preview.mts` renders the review page from it **plus the real +registry and preset catalogue**, and **fails** if any effect, parameter or +preset lacks copy — so the page cannot quietly omit something that ships. + +```bash +bun plans/audio-fx-ux/build-preview.mts /tmp/rack-ux.html +``` + +## The three rules + +1. **Two faces.** Every module opens plain: a name that says the outcome, one + line about what it is for, and one control. The real parameters are one click + away and never in the way. Nothing is hidden — it is ordered. +2. **One knob that matters.** A compressor has seven controls and an author + wants one. Multi-knob modules get a single derived control, exactly as + `carveProfile(strength)` already turns one number into six. +3. **Name the outcome, not the mechanism.** "Remove Rumble", not "High-pass". + The DSP name stays in the corner of the module, so the vocabulary is taught + rather than withheld — an author who learns "high-pass" here can carry it to + any other tool. + +## The shared vocabulary + +Frequencies mean nothing to somebody who has not been taught them. `BANDS` names +the ranges in the words the same person would use unprompted — rumble, weight, +mud, middle, presence, edge, air — and every filter shows where it acts on that +one ruler. Naming them once makes the whole rack legible. + +## What laying it all out exposed + +**A preset can use the same module twice for different jobs.** "Clean Voice" +runs _Shape One Range_ at node 02 (cutting mud at 250 Hz) and again at node 04 +(adding clarity at 3 kHz). Read down the rack, an author sees the same words +twice and cannot tell them apart. + +So one plain name per _effect_ is not enough: a preset's node needs its own +**role label** — "Reduce Mud", "Add Clarity" — which means copy belongs on the +preset node as well as on the effect. This is invisible in a catalogue of cards +and obvious the moment every preset is drawn as the chain it actually builds. + +## Family lettering, carried over from the first round + +The identity device from the first rack pass — different type per family — was +lost when the direction moved to schematic, which lettered everything in the +same condensed caps. It is back, inside the schematic skeleton rather than +instead of it. You can tell what KIND of module you are looking at with the +label out of focus, before the word registers. + +| Family | Treatment | Why | +| --------- | ------------------------------------ | --------------------------------------------------------------- | +| Filter | condensed caps, wide tracking, light | measuring instruments | +| Dynamics | condensed caps, tight, heavy | grips the signal | +| Nonlinear | **italic serif** | the only generative family — it should not look like the others | +| Time | condensed caps, very wide, thin | atmosphere, not control | +| Smart | monospace, medium | it measures; it reads as a readout | + +Two faces, as budgeted. The condensed sans carries four families apart by +weight, case, tracking and size; the serif is spent on the single family that +behaves differently from the rest. + +Alongside it, a **tint step per module inside its family** — derived from +position in the registry, so adding an effect never re-colours its siblings by +hand. Two filters are visibly different modules without reading as two +different families. + +The `Broadcast` preset is the test case: seven nodes across three families in +one rack, and each one is identifiable before it is read. + +## The collapsed state is a sentence + +Collapsed is the most-seen state by a distance: a rack of six modules is six +collapsed lines and nothing else. So `SUMMARY` writes each one as a phrase about +what is happening to the sound — "Cutting everything below 80 Hz", "Evening out +— moderate", "A medium room, lightly" — rather than the parameter that happens +to be first. Numbers stay in, because they are what makes it checkable, but they +arrive inside a sentence. An author should be able to read their own mix top to +bottom. + +Rendering all fifteen at their defaults immediately caught one: a freshly added +Peaking EQ sits at 0 dB, and "Lifting 1 kHz by 0 dB" describes a non-event as +though it were a setting — while being the FIRST thing an author reads after +adding one. It now says "Sitting on 1 kHz, doing nothing yet". + +## Trap: do not use String.raw here + +Bun escapes every non-ASCII character in a raw template literal into literal +`\uXXXX` text, so em-dashes, curly quotes and any glyph in a CSS `content` +property print as their escape sequence on the page. This cost three rounds of +chasing what looked like three unrelated rendering bugs. The template is a plain +literal; keep it that way, and use HTML entities for typographic characters. + +## The hole in the single-knob rule: picking the range + +`Shape One Range` has three controls — where, how much, how wide — and the +copy nominated _how much_ as the one that matters. That is incoherent, and it +took someone asking to see it: boosting an unspecified frequency means nothing. +**The range is the first decision, not the second.** + +Two ways out: + +**A — two controls.** Keep the module generic and make _where_ a word from the +shared vocabulary rather than a frequency field. Honest, and the ruler does the +teaching, but it is still two decisions and the first is jargon in a friendly +coat. + +**B — the range IS the module.** The add menu offers _jobs_ — Reduce Mud, Add +Clarity, Tame Harshness — each a peaking node with its frequency already +chosen. Picking the module is picking the range, so one knob is honest rather +than a simplification hiding the real choice. + +**B is the answer**, and it is the same insight as the EQ: an author does not +want a parametric equaliser, they want to fix a thing. It also dissolves the +duplicate-name problem at the root rather than papering it with a role label — +`Clean Voice` reads _Remove Rumble · Reduce Mud · Even Out Loudness · Add +Clarity · Peak Ceiling_, and nothing repeats. + +Option A is not wasted: its band picker is exactly the right control for moving +the frequency under **Details**, for the author who wants to. + +This changes the catalogue, not just the copy: the presets should reference +named jobs, and `EFFECT_COPY.peaking` stops being one entry. + +## Proposed: a multi-band EQ ("Tone") + +The clearest failure this exercise surfaced is a rack holding two _Shape One +Range_ modules doing different jobs. A multi-band EQ is the answer, and it is a +better one than a role label because an author already understands it: bass, +middle, treble is the most widely used audio control there is. + +**Its bands can be the shared vocabulary.** Three bands are Bass / Middle / +Treble; five open up to Bass / Warmth / Middle / Clarity / Air. So using the EQ +teaches the words the rest of the rack relies on, instead of the vocabulary +living only on a ruler somebody has to read. + +**Built like the carve, not like a new effect.** Carve already owns several +tagged nodes and presents as one module (`fromCarve`, filtered out of the +hand-built list). An EQ does the same with `fromEq`: three bands are a low +shelf, a peaking and a high shelf — all effects that already ship. Nothing new +in the render, nothing new in the graph, and the nodes stay ordinary, so an +author who opens the details finds exactly the filters they could have added by +hand. + +The registry's parameter model is flat key/value, so an `eq` effect _type_ with +N bands would need array-shaped params it does not support. The composite-module +route avoids that entirely and is the pattern this codebase already proved. + +Faders rather than sliders, because a row of vertical faders around a centre +detent is what an equaliser looks like to everyone who has met one. Collapsed, +it reads like every other module: "Bass +3, Middle −2, Treble +2", or "Flat" +when nothing has been touched. + +## What still needs deciding + +- Does the plain name **replace** the DSP name or sit beside it? Replacing is + friendlier but strands what the author learns. +- Should the **menus** be organised by complaint ("my voice sounds boomy") + rather than by effect family? The rack itself must stay in signal order, + because order is audible — but the menus have no such constraint, and the + preset section of the preview is written that way to show the difference. +- How much should **hover audition**? Hearing a preset before committing is the + single strongest affordance here. Cheap for static presets; a measuring script + has to analyse first and cannot preview instantly. + +## Status + +`copy.mts` is a proposal, not shipped code. When it lands it wants to be +`packages/core/src/audioFxCopy.ts` beside the registry, with the completeness +check as a test rather than a build step. + +The `PROFILES` figures — what one knob derives at gentle/middle/strong — are +proposed values, not measured ones. They want the same before/after listen the +clip-before-duck fix got.