diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 115fc1cbc9..7b95daf4d1 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -92,6 +92,24 @@ "types": "./dist/audioFx.d.ts", "environments": ["browser", "bun", "node"] }, + "./audio-fx-eq": { + "source": "./src/audioFxEq.ts", + "runtime": "./dist/audioFxEq.js", + "types": "./dist/audioFxEq.d.ts", + "environments": ["browser", "bun", "node"] + }, + "./audio-leveller": { + "source": "./src/audioLeveller.ts", + "runtime": "./dist/audioLeveller.js", + "types": "./dist/audioLeveller.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 29f6a2a87d..7411f8d184 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -106,6 +106,24 @@ "import": "./src/audioFx.ts", "types": "./src/audioFx.ts" }, + "./audio-fx-eq": { + "bun": "./src/audioFxEq.ts", + "node": "./dist/audioFxEq.js", + "import": "./src/audioFxEq.ts", + "types": "./src/audioFxEq.ts" + }, + "./audio-leveller": { + "bun": "./src/audioLeveller.ts", + "node": "./dist/audioLeveller.js", + "import": "./src/audioLeveller.ts", + "types": "./src/audioLeveller.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", @@ -392,6 +410,18 @@ "import": "./dist/audioFx.js", "types": "./dist/audioFx.d.ts" }, + "./audio-fx-eq": { + "import": "./dist/audioFxEq.js", + "types": "./dist/audioFxEq.d.ts" + }, + "./audio-leveller": { + "import": "./dist/audioLeveller.js", + "types": "./dist/audioLeveller.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..1c9f08c0b4 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -804,6 +804,39 @@ 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; + /** + * What the rack calls this node, when the effect's own name is not specific + * enough to be useful. + * + * A peaking filter is "Shape One Range" wherever it appears, so a chain that + * cuts mud at 250 Hz and lifts clarity at 3 kHz shows the same words twice + * and an author cannot tell the two apart. A preset names each node for the + * JOB it is doing instead — "Reduce Mud", "Add Clarity" — and the rack reads + * as a list of things that were done rather than a list of filter types. + */ + label?: string; + /** + * Id of the multi-band EQ that owns this node, when it is one of its bands. + * + * Same device as `fromCarve`: the module gathers its own nodes out of the + * chain and presents them as one control surface, so an EQ needs no new + * effect type and its bands stay ordinary filters underneath. + */ + fromEq?: string; + /** + * Set on the gain stage the leveller writes, so re-running replaces it rather + * than stacking a second one — the same contract `fromCarve` has. + */ + fromLeveller?: boolean; /** Absent means enabled — chain files written before the field existed still load. */ enabled?: boolean; params?: HfAudioFxParamValues; @@ -853,6 +886,10 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { enabled?: unknown; params?: unknown; fromCarve?: unknown; + fromPreset?: unknown; + label?: unknown; + fromEq?: unknown; + fromLeveller?: unknown; }; if (typeof node.type !== "string" || !BY_ID.has(node.type)) { throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`); @@ -861,6 +898,15 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { type: node.type, ...(typeof node.id === "string" && node.id ? { id: node.id } : {}), ...(node.fromCarve === true ? { fromCarve: true as const } : {}), + // Both survive the round trip or a preset stops being able to find its + // own nodes after a reload: re-applying would stack a second copy and + // the rack would lose the grouping it braces them with. + ...(typeof node.fromPreset === "string" && node.fromPreset + ? { fromPreset: node.fromPreset } + : {}), + ...(typeof node.label === "string" && node.label ? { label: node.label } : {}), + ...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}), + ...(node.fromLeveller === true ? { fromLeveller: true as const } : {}), enabled: node.enabled !== false, params: normalizeAudioFxParams( node.type, @@ -884,6 +930,10 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string { type: node.type, ...(node.id ? { id: node.id } : {}), ...(node.fromCarve === true ? { fromCarve: true } : {}), + ...(node.fromPreset ? { fromPreset: node.fromPreset } : {}), + ...(node.label ? { label: node.label } : {}), + ...(node.fromEq ? { fromEq: node.fromEq } : {}), + ...(node.fromLeveller === true ? { fromLeveller: true } : {}), ...(node.enabled === false ? { enabled: false } : {}), params: normalizeAudioFxParams(node.type, node.params), })), diff --git a/packages/core/src/audioFxEq.test.ts b/packages/core/src/audioFxEq.test.ts new file mode 100644 index 0000000000..ff7b52d938 --- /dev/null +++ b/packages/core/src/audioFxEq.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + HF_AUDIO_FX_CHAIN_VERSION, + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, +} from "./audioFx.js"; +import { + addAudioEq, + audioEqIds, + audioEqSummary, + HF_AUDIO_EQ_3, + HF_AUDIO_EQ_5, + HF_AUDIO_EQ_RANGE_DB, + readAudioEqBands, + removeAudioEq, + setAudioEqBandGain, +} from "./audioFxEq.js"; + +const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }); + +describe("adding an EQ", () => { + it("writes one ordinary filter per band, in band order", () => { + const { chain } = addAudioEq(empty()); + expect(chain.nodes.map((n) => n.type)).toEqual(["lowshelf", "peaking", "highshelf"]); + // Ordinary nodes: an author who opens the details finds filters they could + // have added by hand, not an opaque "eq" the graph has to special-case. + expect(chain.nodes.every((n) => n.enabled)).toBe(true); + expect(chain.nodes.map((n) => n.label)).toEqual(["Bass", "Middle", "Treble"]); + }); + + it("gives every band an id, because a lane addresses effects by id", () => { + const { chain } = addAudioEq(empty(), HF_AUDIO_EQ_5); + const ids = chain.nodes.map((n) => n.id); + expect(ids.every(Boolean)).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("starts flat, so adding one changes nothing until a fader moves", () => { + const { chain, eqId } = addAudioEq(empty()); + for (const band of readAudioEqBands(chain, eqId)) expect(band.gain).toBe(0); + expect(audioEqSummary(readAudioEqBands(chain, eqId))).toMatch(/^Flat/); + }); + + it("keeps two EQs apart", () => { + const first = addAudioEq(empty()); + const second = addAudioEq(first.chain, HF_AUDIO_EQ_5); + expect(second.eqId).not.toBe(first.eqId); + expect(audioEqIds(second.chain)).toEqual([first.eqId, second.eqId]); + expect(readAudioEqBands(second.chain, first.eqId)).toHaveLength(3); + expect(readAudioEqBands(second.chain, second.eqId)).toHaveLength(5); + expect(new Set(second.chain.nodes.map((n) => n.id)).size).toBe(second.chain.nodes.length); + }); + + it("leaves effects that were already there alone", () => { + const before: HfAudioFxChain = { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: [{ type: "reverb", id: "mine", enabled: true }], + }; + const { chain } = addAudioEq(before); + expect(chain.nodes[0]?.id).toBe("mine"); + expect(chain.nodes).toHaveLength(4); + }); +}); + +describe("moving a fader", () => { + it("changes only that band", () => { + const { chain, eqId } = addAudioEq(empty()); + const next = setAudioEqBandGain(chain, eqId, "Bass", 4.5); + const bands = readAudioEqBands(next, eqId); + expect(bands.find((b) => b.name === "Bass")?.gain).toBe(4.5); + expect(bands.find((b) => b.name === "Middle")?.gain).toBe(0); + expect(bands.find((b) => b.name === "Treble")?.gain).toBe(0); + }); + + it("leaves the band's frequency and width alone", () => { + // The fader is one control. Moving it must not quietly re-seed the rest of + // the band, or an author who set a frequency by hand loses it on the next drag. + const { chain, eqId } = addAudioEq(empty(), HF_AUDIO_EQ_5); + const before = readAudioEqBands(chain, eqId).find((b) => b.name === "Clarity")!; + const next = setAudioEqBandGain(chain, eqId, "Clarity", -3); + const after = readAudioEqBands(next, eqId).find((b) => b.name === "Clarity")!; + expect(after.frequency).toBe(before.frequency); + expect(after.q).toBe(before.q); + expect(after.gain).toBe(-3); + }); + + it("holds the fader to a tone control's range, not a repair tool's", () => { + // The filters themselves allow ±40 dB. A tone control that can bury a + // track under 40 dB of bass is not a tone control. + const { chain, eqId } = addAudioEq(empty()); + const hot = setAudioEqBandGain(chain, eqId, "Bass", 40); + const cold = setAudioEqBandGain(chain, eqId, "Bass", -40); + expect(readAudioEqBands(hot, eqId)[0]?.gain).toBe(HF_AUDIO_EQ_RANGE_DB); + expect(readAudioEqBands(cold, eqId)[0]?.gain).toBe(-HF_AUDIO_EQ_RANGE_DB); + }); + + it("ignores a band name that is not in this EQ", () => { + const { chain, eqId } = addAudioEq(empty()); + const next = setAudioEqBandGain(chain, eqId, "Nonsense", 6); + expect(readAudioEqBands(next, eqId).every((b) => b.gain === 0)).toBe(true); + }); +}); + +describe("the chain is the truth", () => { + it("reads a frequency the author moved by hand", () => { + // The nodes are authoritative, not a cached band list: opening the details + // and moving a frequency has to show up on the fader's own band. + const { chain, eqId } = addAudioEq(empty()); + const edited: HfAudioFxChain = { + ...chain, + nodes: chain.nodes.map((n) => + n.label === "Middle" ? { ...n, params: { ...n.params, frequency: 700 } } : n, + ), + }; + expect(readAudioEqBands(edited, eqId).find((b) => b.name === "Middle")?.frequency).toBe(700); + }); + + it("survives being written to an attribute and read back", () => { + const { chain, eqId } = addAudioEq(empty(), HF_AUDIO_EQ_5); + const moved = setAudioEqBandGain(chain, eqId, "Air", 2.5); + const back = parseAudioFxChain(serializeAudioFxChain(moved)); + // Without fromEq surviving, the module cannot find its own bands after a + // reload and the EQ silently becomes five loose filters. + expect(audioEqIds(back)).toEqual([eqId]); + expect(readAudioEqBands(back, eqId).map((b) => b.name)).toEqual([ + "Bass", + "Warmth", + "Middle", + "Clarity", + "Air", + ]); + expect(readAudioEqBands(back, eqId).find((b) => b.name === "Air")?.gain).toBe(2.5); + }); + + it("removes a whole EQ without touching anything else", () => { + const before: HfAudioFxChain = { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: [{ type: "reverb", id: "mine", enabled: true }], + }; + const { chain, eqId } = addAudioEq(before); + const gone = removeAudioEq(chain, eqId); + expect(gone.nodes.map((n) => n.id)).toEqual(["mine"]); + }); +}); + +describe("what it says when closed", () => { + it("names only the bands that were moved", () => { + const { chain, eqId } = addAudioEq(empty()); + const next = setAudioEqBandGain(setAudioEqBandGain(chain, eqId, "Bass", 3), eqId, "Treble", -2); + expect(audioEqSummary(readAudioEqBands(next, eqId))).toBe("Bass +3, Treble −2"); + }); + + it("says so when nothing has been touched", () => { + expect(audioEqSummary(HF_AUDIO_EQ_3)).toMatch(/^Flat/); + }); +}); diff --git a/packages/core/src/audioFxEq.ts b/packages/core/src/audioFxEq.ts new file mode 100644 index 0000000000..c1fbcea6aa --- /dev/null +++ b/packages/core/src/audioFxEq.ts @@ -0,0 +1,192 @@ +/** + * A multi-band EQ, as a composite over effects that already ship. + * + * Bass, middle and treble is the most widely understood audio control there is + * — everyone has used one — which makes it the right answer for an author who + * would never reach for a parametric filter. It also removes a real failure: + * without it, a chain shaping two ranges holds two peaking filters that look + * identical in the rack. + * + * Built the way the carve is: one module owning several tagged nodes, rather + * than a new effect type. Three bands ARE a low shelf, a peaking and a high + * shelf, so there is nothing new in the graph, nothing new in the render, and + * an author who opens the details finds exactly the filters they could have + * added by hand. + * + * That is also forced by the registry: parameters are a flat key/value record, + * so an `eq` effect *type* carrying N bands would need array-shaped params it + * has no way to express. + */ + +import { + HF_AUDIO_FX_CHAIN_VERSION, + mintAudioFxNodeId, + normalizeAudioFxParams, + type HfAudioFxChain, + type HfAudioFxNode, +} from "./audioFx.js"; + +/** A band is one node. `name` is what the fader is labelled, and its handle. */ +export interface HfAudioEqBand { + name: string; + /** Corner for a shelf, centre for a peak. */ + frequency: number; + /** The only value a fader moves. Everything else is fixed when the band is made. */ + gain: number; + q?: number; + kind: "lowshelf" | "peaking" | "highshelf"; +} + +/** + * How far a fader travels. The registry allows ±40 dB on these filters, which + * is a repair tool's range — a tone control that can bury a track under 40 dB + * of bass is not a tone control. ±12 is the span a hi-fi offers, and it is + * enough to fix a voice. + */ +export const HF_AUDIO_EQ_RANGE_DB = 12; + +/** Bass, middle, treble — the set nobody needs taught. */ +export const HF_AUDIO_EQ_3: readonly HfAudioEqBand[] = [ + { name: "Bass", frequency: 200, gain: 0, kind: "lowshelf" }, + { name: "Middle", frequency: 1000, gain: 0, q: 0.9, kind: "peaking" }, + { name: "Treble", frequency: 4000, gain: 0, kind: "highshelf" }, +]; + +/** + * Five bands, named from the shared vocabulary the rest of the rack uses — so + * reaching for the EQ is also how an author learns the words. + */ +export const HF_AUDIO_EQ_5: readonly HfAudioEqBand[] = [ + { name: "Bass", frequency: 160, gain: 0, kind: "lowshelf" }, + { name: "Warmth", frequency: 350, gain: 0, q: 1, kind: "peaking" }, + { name: "Middle", frequency: 1000, gain: 0, q: 0.9, kind: "peaking" }, + { name: "Clarity", frequency: 3000, gain: 0, q: 1, kind: "peaking" }, + { name: "Air", frequency: 9000, gain: 0, kind: "highshelf" }, +]; + +const clampGain = (db: number): number => + Number.isFinite(db) ? Math.max(-HF_AUDIO_EQ_RANGE_DB, Math.min(HF_AUDIO_EQ_RANGE_DB, db)) : 0; + +/** Realise bands as ordinary nodes, tagged so the module can find them again. */ +export function audioEqNodes( + bands: readonly HfAudioEqBand[], + eqId: string, + chain: HfAudioFxChain, +): HfAudioFxNode[] { + const out: HfAudioFxNode[] = []; + let running: HfAudioFxChain = { ...chain, nodes: [...chain.nodes] }; + for (const band of bands) { + const made: HfAudioFxNode = { + type: band.kind, + id: mintAudioFxNodeId(running), + fromEq: eqId, + // The band name IS the node's job name, so a rack showing the bands + // individually still reads as words rather than as three filter types. + label: band.name, + enabled: true, + params: normalizeAudioFxParams(band.kind, { + frequency: band.frequency, + gain: clampGain(band.gain), + ...(band.kind === "peaking" ? { q: band.q ?? 1 } : {}), + }), + }; + out.push(made); + running = { ...running, nodes: [...running.nodes, made] }; + } + return out; +} + +/** Add an EQ to a chain. Returns the chain and the id the module addresses it by. */ +export function addAudioEq( + chain: HfAudioFxChain, + bands: readonly HfAudioEqBand[] = HF_AUDIO_EQ_3, +): { chain: HfAudioFxChain; eqId: string } { + const taken = new Set(chain.nodes.map((n) => n.fromEq).filter(Boolean)); + let eqId = "eq1"; + for (let i = 1; taken.has(eqId); i += 1) eqId = `eq${i + 1}`; + return { + chain: { ...chain, nodes: [...chain.nodes, ...audioEqNodes(bands, eqId, chain)] }, + eqId, + }; +} + +/** Every EQ in a chain, in the order their first band appears. */ +export function audioEqIds(chain: HfAudioFxChain): string[] { + const seen: string[] = []; + for (const node of chain.nodes) { + if (node.fromEq && !seen.includes(node.fromEq)) seen.push(node.fromEq); + } + return seen; +} + +/** + * Read one EQ's bands back out of the chain. + * + * The nodes are the truth, not a cached band list: an author can open the + * details and move a frequency by hand, and the faders have to reflect that + * rather than silently overwrite it on the next drag. + */ +export function readAudioEqBands(chain: HfAudioFxChain, eqId: string): HfAudioEqBand[] { + const out: HfAudioEqBand[] = []; + for (const node of chain.nodes) { + if (node.fromEq !== eqId) continue; + if (node.type !== "lowshelf" && node.type !== "peaking" && node.type !== "highshelf") continue; + const params = node.params ?? {}; + const freq = params["frequency"]; + const gain = params["gain"]; + const q = params["q"]; + out.push({ + name: node.label ?? node.type, + frequency: typeof freq === "number" ? freq : 1000, + gain: typeof gain === "number" ? gain : 0, + ...(typeof q === "number" ? { q } : {}), + kind: node.type, + }); + } + return out; +} + +/** Move one fader. Everything else about the band is left alone. */ +export function setAudioEqBandGain( + chain: HfAudioFxChain, + eqId: string, + bandName: string, + gain: number, +): HfAudioFxChain { + return { + ...chain, + nodes: chain.nodes.map((node) => + node.fromEq === eqId && node.label === bandName + ? { + ...node, + params: normalizeAudioFxParams(node.type, { + ...(node.params ?? {}), + gain: clampGain(gain), + }), + } + : node, + ), + }; +} + +/** Remove a whole EQ, bands and all. */ +export function removeAudioEq(chain: HfAudioFxChain, eqId: string): HfAudioFxChain { + return { ...chain, nodes: chain.nodes.filter((n) => n.fromEq !== eqId) }; +} + +/** + * What the module says when it is closed. + * + * Named for the bands that were actually moved, so a rack of collapsed modules + * still reads as a sentence — and an untouched EQ says so rather than listing + * three zeroes. + */ +export function audioEqSummary(bands: readonly HfAudioEqBand[]): string { + const moved = bands.filter((b) => Math.abs(b.gain) >= 0.1); + if (moved.length === 0) return "Flat — nothing changed yet"; + return moved + .map((b) => `${b.name} ${b.gain > 0 ? "+" : "−"}${Math.abs(Number(b.gain.toFixed(1)))}`) + .join(", "); +} + +export const HF_AUDIO_EQ_CHAIN_VERSION = HF_AUDIO_FX_CHAIN_VERSION; diff --git a/packages/core/src/audioFxPresets.test.ts b/packages/core/src/audioFxPresets.test.ts new file mode 100644 index 0000000000..2f187ce814 --- /dev/null +++ b/packages/core/src/audioFxPresets.test.ts @@ -0,0 +1,272 @@ +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, TAGS AND ALL", () => { + // Comparing only types is what let `fromPreset` be silently dropped by the + // parser: every preset round-tripped its effects while losing the tag that + // lets it find its own nodes again. Compare what the rack actually needs. + for (const p of HF_AUDIO_FX_PRESETS) { + const chain = applyAudioFxPreset(empty(), p); + const back = parseAudioFxChain(serializeAudioFxChain(chain)); + expect( + back.nodes.map((x) => ({ + type: x.type, + id: x.id, + fromPreset: x.fromPreset, + label: x.label, + })), + `${p.id} did not round-trip`, + ).toEqual( + chain.nodes.map((x) => ({ + type: x.type, + id: x.id, + fromPreset: x.fromPreset, + label: x.label, + })), + ); + } + }); + + it("names every node for the job it is doing", () => { + for (const p of HF_AUDIO_FX_PRESETS) { + for (const node of p.nodes) { + expect(node.label, `${p.id}: a ${node.type} node has no job name`).toBeTruthy(); + } + } + }); + + it("never shows the same name twice in one chain", () => { + // The failure this exists to prevent: a rack reading "Shape One Range" + // twice, once cutting mud and once adding clarity, with nothing to tell + // them apart. Identical CONSECUTIVE nodes are exempt — a stacked pair is + // one stage built from two biquads, not two jobs. + for (const p of HF_AUDIO_FX_PRESETS) { + const seen = new Map(); + p.nodes.forEach((node, i) => { + const key = node.label ?? node.type; + const prev = seen.get(key); + const stackedPair = + prev === i - 1 && JSON.stringify(p.nodes[prev]?.params) === JSON.stringify(node.params); + expect( + prev === undefined || stackedPair, + `${p.id} shows "${key}" twice — an author cannot tell the two apart`, + ).toBe(true); + seen.set(key, i); + }); + } + }); + + 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.label).toBe("Cut Rumble"); + 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..4f49d45772 --- /dev/null +++ b/packages/core/src/audioFxPresets.ts @@ -0,0 +1,425 @@ +/** + * 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; + /** + * What the rack calls this node — the JOB it is doing, not its filter type. + * + * A peaking filter is "Shape One Range" wherever it appears, so a chain that + * cuts mud and then lifts clarity shows the same words twice and an author + * cannot follow it. Naming each node for its job is what lets a preset read + * as a list of things that were done. + */ + label?: 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, + label: string, +): HfAudioFxPresetNode[] => [ + { type, label, params: { frequency, q: 0.707, poles: "2" } }, + { type, label, 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", label: "Remove Rumble", params: { frequency: 80, q: 0.707, poles: "2" } }, + { type: "peaking", label: "Reduce Mud", params: { frequency: 250, gain: -3, q: 1.2 } }, + { + type: "compressor", + label: "Even Out Loudness", + params: { threshold: -20, ratio: 3, attack: 12, release: 180, makeup: 3 }, + }, + { type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 2.5, q: 1 } }, + { type: "limiter", label: "Peak Ceiling", params: { limit: -1, attack: 5, release: 50 } }, + ], + ), + preset( + "voice-broadcast", + "voice", + "Broadcast", + "Denser and more forward — a radio-presenter sound.", + [ + { type: "highpass", label: "Remove Rumble", params: { frequency: 90, q: 0.707, poles: "2" } }, + { type: "peaking", label: "Reduce Boxiness", params: { frequency: 400, gain: -3, q: 1.4 } }, + { + type: "compressor", + label: "Even Out Loudness", + params: { threshold: -24, ratio: 4, attack: 8, release: 150, makeup: 5 }, + }, + { type: "peaking", label: "Add Clarity", params: { frequency: 2500, gain: 3, q: 0.9 } }, + { type: "highshelf", label: "Add Air", params: { frequency: 8000, gain: 2 } }, + { type: "saturate", label: "Warmth", params: { type: "tanh", threshold: -12, output: 0 } }, + { type: "limiter", label: "Peak Ceiling", 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", label: "Remove Rumble", params: { frequency: 70, q: 0.707, poles: "2" } }, + { type: "lowshelf", label: "Add Weight", params: { frequency: 180, gain: 2 } }, + { + type: "compressor", + label: "Even Out Loudness", + params: { threshold: -18, ratio: 2.5, attack: 20, release: 250, makeup: 2 }, + }, + { type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 1.5, q: 0.8 } }, + { type: "limiter", label: "Peak Ceiling", 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", label: "Cut Rumble", 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", + label: "Silence the Gaps", + 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", label: "Tame Boominess", 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", label: "Soften Harshness", 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, "Strip the Bass"), + ...steep("lowpass", 3400, "Strip the Treble"), + { type: "peaking", label: "Phone Honk", params: { frequency: 1200, gain: 6, q: 1.2 } }, + { type: "peaking", label: "De-mud", params: { frequency: 550, gain: -4, q: 1 } }, + { + type: "saturate", + label: "Circuit Grit", + params: { type: "tanh", threshold: -18, output: -2 }, + }, + ], + ), + preset("radio-am", "character", "AM Radio", "Narrow, gritty and a little crushed.", [ + { type: "highpass", label: "Strip the Bass", params: { frequency: 400, q: 0.707, poles: "2" } }, + { + type: "lowpass", + label: "Strip the Treble", + params: { frequency: 3000, q: 0.707, poles: "2" }, + }, + { type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -15, output: -2 } }, + { type: "bitcrush", label: "Crunch", 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", + label: "Strip the Bass", + params: { frequency: 500, q: 0.707, poles: "2" }, + }, + { + type: "lowpass", + label: "Strip the Treble", + params: { frequency: 4000, q: 0.707, poles: "2" }, + }, + { type: "peaking", label: "Horn Honk", params: { frequency: 1800, gain: 8, q: 1.5 } }, + { + type: "saturate", + label: "Overdrive", + params: { type: "hard", threshold: -12, output: -3 }, + }, + { type: "delay", label: "Horn Slap", 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", label: "Tape Rolloff", params: { frequency: 6500, q: 0.707, poles: "2" } }, + { type: "lowshelf", label: "Add Weight", params: { frequency: 120, gain: 2 } }, + { + type: "saturate", + label: "Tape Warmth", + params: { type: "tanh", threshold: -14, output: 0 }, + }, + { type: "bitcrush", label: "Tape Noise", params: { bits: 12, samples: 2, mix: 0.35 } }, + // A slow, shallow chorus is what wow and flutter actually are. + { + type: "chorus", + label: "Wow & Flutter", + params: { delay: 6, depth: 0.6, speed: 0.4, mix: 0.15 }, + }, + ], + ), + preset("pa-system", "character", "Tannoy", "Announced across a concourse.", [ + { type: "highpass", label: "Strip the Bass", params: { frequency: 350, q: 0.707, poles: "2" } }, + { + type: "lowpass", + label: "Strip the Treble", + params: { frequency: 3500, q: 0.707, poles: "2" }, + }, + { type: "peaking", label: "Tannoy Honk", params: { frequency: 1500, gain: 5, q: 1.2 } }, + { + type: "saturate", + label: "Driver Grit", + params: { type: "tanh", threshold: -16, output: -1 }, + }, + { + type: "reverb", + label: "Concourse", + 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", + label: "Squelch", + params: { threshold: -40, range: -30, ratio: 10, attack: 1, release: 120 }, + }, + { type: "highpass", label: "Strip the Bass", params: { frequency: 500, q: 0.707, poles: "2" } }, + { + type: "lowpass", + label: "Strip the Treble", + params: { frequency: 3000, q: 0.707, poles: "2" }, + }, + { type: "peaking", label: "Panel Honk", params: { frequency: 2000, gain: 6, q: 2 } }, + { type: "bitcrush", label: "Crunch", params: { bits: 11, samples: 1, mix: 0.3 } }, + ]), + + // ---------------------------------------------------------------- space -- + preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [ + { + type: "reverb", + label: "Tight Room", + 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", + label: "Natural Room", + 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", label: "Hall", 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", label: "Slap Echo", 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", label: "Dub Throw", 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, + ...(node.label ? { label: node.label } : {}), + 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/core/src/audioLeveller.test.ts b/packages/core/src/audioLeveller.test.ts new file mode 100644 index 0000000000..dc85736b3e --- /dev/null +++ b/packages/core/src/audioLeveller.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; +import { + HF_AUDIO_FX_CHAIN_VERSION, + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, +} from "./audioFx.js"; +import { sampleAutomationLane } from "./audioAutomation.js"; +import { applyAudioFxPreset, getAudioFxPreset } from "./audioFxPresets.js"; +import { + analyseLevelling, + levellerProfile, + levellingResult, + levellingSummary, + removeLevelling, +} from "./audioLeveller.js"; + +const SR = 48000; +const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }); + +/** A tone whose amplitude changes per section, so the levelling has real work. */ +function uneven(sections: { seconds: number; amp: number }[]): Float32Array { + const total = sections.reduce((n, s) => n + Math.floor(SR * s.seconds), 0); + const out = new Float32Array(total); + let at = 0; + for (const section of sections) { + const n = Math.floor(SR * section.seconds); + for (let i = 0; i < n; i += 1) { + out[at + i] = section.amp * Math.sin((2 * Math.PI * 300 * (at + i)) / SR); + } + at += n; + } + return out; +} + +const at = (points: { t: number; v: number }[], t: number) => + sampleAutomationLane({ target: "fx.n1.gain", points }, t); + +describe("measuring", () => { + it("lifts a quiet passage and leaves the loud one alone", () => { + // Loud, then 18 dB down, then loud again. + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + { seconds: 3, amp: 0.5 }, + ]), + SR, + ); + expect(points.length).toBeGreaterThan(0); + // Mid-quiet-section, well past the attack. + expect(at(points, 5.5)).toBeGreaterThan(3); + // Mid-loud-section, well past the release. + expect(Math.abs(at(points, 2.5))).toBeLessThan(2); + }); + + it("finds nothing to do on a track that is already even", () => { + // A script that always writes something teaches an author it is doing + // nothing; saying "already even" is the useful answer. + expect(analyseLevelling(uneven([{ seconds: 6, amp: 0.4 }]), SR)).toEqual([]); + }); + + it("leaves room tone alone rather than lifting it into the mix", () => { + // Deliberately NOT digital silence: a real pause is quiet but finite, and + // that is the case the floor exists for. Absolute zero would be skipped by + // the isFinite check alone and prove nothing. + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.0008 }, + { seconds: 3, amp: 0.5 }, + ]), + SR, + 1, + ); + // ~56 dB down: a pause with a noise floor, not a quiet passage. Gain + // applied here is gain applied to the room. + expect(Math.abs(at(points, 5.5))).toBeLessThan(1.5); + }); + + it("targets a level the track reaches, not its loudest instant", () => { + // Mostly quiet with one loud burst. Against the PEAK the whole body of the + // track reads as "quiet" and gets hauled up; against a level the track + // actually sustains, the body is already the target and barely moves. + const points = analyseLevelling( + uneven([ + { seconds: 6, amp: 0.08 }, + { seconds: 1, amp: 0.8 }, + { seconds: 5, amp: 0.08 }, + ]), + SR, + 1, + ); + expect(Math.abs(at(points, 3))).toBeLessThan(3); + }); + + it("never asks for more correction than it can justify", () => { + // ~30 dB below the loud section: still well above the silence floor, so it + // IS a passage to lift — and at full strength the raw ask is over 25 dB, + // which is more gain than any quiet passage should be given. + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.6 }, + { seconds: 4, amp: 0.019 }, + ]), + SR, + 1, + ); + expect(points.some((p) => p.v > 6)).toBe(true); + for (const p of points) expect(Math.abs(p.v)).toBeLessThanOrEqual(12); + }); + + it("starts at the clip's start, so the lane does not slide in from nowhere", () => { + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + ); + expect(points[0]?.t).toBe(0); + }); + + it("handles an empty track without inventing a lane", () => { + expect(analyseLevelling(new Float32Array(0), SR)).toEqual([]); + expect(analyseLevelling(uneven([{ seconds: 1, amp: 0.4 }]), 0)).toEqual([]); + }); +}); + +describe("strength", () => { + it("corrects more the further it is turned up", () => { + const track = uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]); + const gentle = at(analyseLevelling(track, SR, 0.1), 5.5); + const strong = at(analyseLevelling(track, SR, 1), 5.5); + expect(strong).toBeGreaterThan(gentle); + }); + + it("still leaves some of the performance in at full strength", () => { + // Driving a track to a flat line removes the performance along with the + // inconsistency, so even 1.0 corrects most rather than all of it. + expect(levellerProfile(1).correction).toBeLessThan(1); + expect(levellerProfile(0).correction).toBeGreaterThan(0); + }); +}); + +describe("what it writes", () => { + it("rides a gain node, because a volume lane can only attenuate", () => { + // VOLUME_RANGE is 0..1 and normaliseEnvelope clamps into it, so a volume + // lane cannot lift a quiet passage at all. This is the whole reason the + // script writes a node instead of only a lane. + const result = levellingResult( + empty(), + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + ); + expect(result).not.toBeNull(); + const node = result!.chain.nodes[0]!; + expect(node.type).toBe("gain"); + expect(node.label).toBe("Even Out Levels"); + expect(node.params?.gain).toBe(0); + expect(result!.automation.lanes[0]!.target).toBe(`fx.${node.id}.gain`); + }); + + it("returns nothing when there is nothing to correct", () => { + expect(levellingResult(empty(), uneven([{ seconds: 6, amp: 0.4 }]), SR)).toBeNull(); + }); + + it("replaces its own stage instead of stacking a second one", () => { + const track = uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]); + const once = levellingResult(empty(), track, SR)!; + const twice = levellingResult(once.chain, track, SR)!; + expect(twice.chain.nodes.filter((n) => n.fromLeveller)).toHaveLength(1); + // Same node id, so the lane it already wrote still addresses the right stage. + expect(twice.chain.nodes[0]!.id).toBe(once.chain.nodes[0]!.id); + }); + + it("goes in FRONT of a trailing limiter, never after it", () => { + // The likely sequence: apply Clean Voice, then even out the levels. Clean + // Voice ends in a Peak Ceiling, and up to 12 dB of lift landing after that + // ceiling means loud material at -1 dBFS goes over full scale and the + // render shears it flat. A ceiling with something after it is not a ceiling. + const voiced = applyAudioFxPreset(empty(), getAudioFxPreset("voice-clean")!); + expect(voiced.nodes[voiced.nodes.length - 1]!.type).toBe("limiter"); + + const result = levellingResult( + voiced, + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + const types = result.chain.nodes.map((n) => n.type); + expect(types[types.length - 1]).toBe("limiter"); + expect(types[types.length - 2]).toBe("gain"); + expect(result.chain.nodes.find((n) => n.fromLeveller)).toBeTruthy(); + }); + + it("leaves hand-added effects alone", () => { + const chain: HfAudioFxChain = { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: [{ type: "reverb", id: "mine", enabled: true }], + }; + const result = levellingResult( + chain, + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + expect(result.chain.nodes.map((n) => n.id)).toContain("mine"); + expect(result.chain.nodes).toHaveLength(2); + }); + + it("survives the attribute round trip", () => { + const result = levellingResult( + empty(), + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + const back = parseAudioFxChain(serializeAudioFxChain(result.chain)); + // Without fromLeveller surviving, re-running stacks a second gain stage. + expect(back.nodes.filter((n) => n.fromLeveller)).toHaveLength(1); + expect(back.nodes[0]!.label).toBe("Even Out Levels"); + }); + + it("names the lane it takes away with it", () => { + const result = levellingResult( + empty(), + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + const removed = removeLevelling(result.chain); + expect(removed.chain.nodes).toHaveLength(0); + // An orphaned lane keeps driving a parameter that is no longer there. + expect(removed.removedTarget).toBe(result.automation.lanes[0]!.target); + }); +}); + +describe("what it says", () => { + it("describes the moves rather than listing numbers", () => { + expect( + levellingSummary([ + { t: 0, v: 0 }, + { t: 1, v: 4.2 }, + ]), + ).toMatch(/lifting quiet parts/); + expect(levellingSummary([])).toMatch(/Already even/); + }); +}); diff --git a/packages/core/src/audioLeveller.ts b/packages/core/src/audioLeveller.ts new file mode 100644 index 0000000000..8133e03ff9 --- /dev/null +++ b/packages/core/src/audioLeveller.ts @@ -0,0 +1,247 @@ +/** + * "Even Out Levels" — the first of the adaptive scripts. + * + * A preset cannot fix inconsistent loudness, because the right correction + * depends on the recording. So this measures the track and writes an automation + * lane that lifts the quiet passages toward the loud ones, exactly as the carve + * measures a voice and writes its bands. + * + * It is NOT loudness normalisation. Platform targets (-14 LUFS and friends) are + * ITU-R BS.1770 — K-weighted and gated — and this is plain windowed RMS, so + * calling it LUFS would be a claim the measurement does not support. It is + * named for what it does: evening out. + * + * ## Why the lane rides a `gain` node + * + * The obvious home is the track's volume lane, and that cannot work: volume is + * 0..1 and `normaliseEnvelope` clamps every keyframe into it, so a volume lane + * can only ever attenuate. Lifting a quiet passage needs a `gain` node, which + * spans -60..+12 dB — which is what the audio skill means when it calls `gain` + * "what an automation lane rides when a track has to move". + */ + +import { + fxAutomationTarget, + type HfAutomation, + type HfAutomationPoint, +} from "./audioAutomation.js"; +import { + HF_AUDIO_FX_CHAIN_VERSION, + mintAudioFxNodeId, + normalizeAudioFxParams, + type HfAudioFxChain, + type HfAudioFxNode, +} from "./audioFx.js"; + +/** One window per emitted point; the hop keeps the lane inside its budget. */ +const FRAME = 4096; +const POINT_BUDGET = 400; +/** Below this, a window is silence rather than a quiet passage worth lifting. */ +const FLOOR_BELOW_PEAK_DB = 42; +/** How far a correction may go. Beyond this, lifting a whisper only lifts the room. */ +const MAX_LIFT_DB = 12; +const MAX_CUT_DB = 12; +/** Ignore differences smaller than this — they are not audible and they add points. */ +const SNAP_DB = 0.4; +/** Attack/release in seconds, so a correction rides the phrase and not the syllable. */ +const ATTACK_S = 0.35; +const RELEASE_S = 0.9; + +export interface HfLevellerSettings { + /** 0 = leave it alone, 1 = as even as this can make it. */ + strength: number; +} + +export const DEFAULT_LEVELLER: HfLevellerSettings = { strength: 0.5 }; + +export interface HfLevellerProfile { + /** How much of the measured difference to correct. */ + correction: number; +} + +/** + * One knob to a profile, the shape `carveProfile` established. + * + * At full strength this still corrects only most of the difference: driving a + * track to a flat line removes the performance along with the inconsistency. + */ +export function levellerProfile(strength: number): HfLevellerProfile { + const s = Number.isFinite(strength) ? Math.min(1, Math.max(0, strength)) : 0.5; + return { correction: Number((0.25 + s * 0.6).toFixed(3)) }; +} + +/** Level in dB of one window, or -Infinity for silence. */ +function windowDb(samples: Float32Array, from: number, count: number): number { + let sum = 0; + let n = 0; + for (let i = from; i < from + count; i += 1) { + const s = samples[i]; + if (s === undefined) break; + sum += s * s; + n += 1; + } + if (n === 0) return Number.NEGATIVE_INFINITY; + const rms = Math.sqrt(sum / n); + return rms > 0 ? 20 * Math.log10(rms) : Number.NEGATIVE_INFINITY; +} + +/** + * Measure a track and return the gain moves that even it out, in dB against + * clip-local seconds. Empty when there is nothing worth correcting. + */ +export function analyseLevelling( + samples: Float32Array, + sampleRate: number, + strength = DEFAULT_LEVELLER.strength, +): HfAutomationPoint[] { + if (samples.length === 0 || sampleRate <= 0) return []; + const profile = levellerProfile(strength); + const hop = Math.max(FRAME, Math.ceil(samples.length / POINT_BUDGET)); + + const levels: number[] = []; + const times: number[] = []; + for (let start = 0; start < samples.length; start += hop) { + levels.push(windowDb(samples, start, FRAME)); + times.push((start + FRAME / 2) / sampleRate); + } + const speaking = levels.filter((d) => Number.isFinite(d)); + if (speaking.length === 0) return []; + + const peak = Math.max(...speaking); + const floor = peak - FLOOR_BELOW_PEAK_DB; + /** + * The target is a level the track ALREADY REACHES — the 80th percentile of + * its speaking windows — not an absolute one. + * + * Anchoring it to an absolute figure means an already-even track gets pulled + * bodily up or down to meet it, which is a volume change wearing a + * levelling label. Against a level the track reaches, its loud passages + * correct to roughly nothing and only the quiet ones move, which is what + * evening out means. It is a percentile rather than the peak so one loud + * word cannot set the target for the whole track. + */ + const sorted = [...speaking].sort((a, b) => a - b); + const target = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.8))] ?? peak; + + const attack = 1 - Math.exp(-(hop / sampleRate) / ATTACK_S); + const release = 1 - Math.exp(-(hop / sampleRate) / RELEASE_S); + + let applied = 0; + const raw: HfAutomationPoint[] = []; + levels.forEach((db, i) => { + // Silence is left alone. Lifting a pause only lifts the room with it. + const wanted = + Number.isFinite(db) && db > floor + ? Math.max(-MAX_CUT_DB, Math.min(MAX_LIFT_DB, (target - db) * profile.correction)) + : 0; + applied += (wanted > applied ? attack : release) * (wanted - applied); + const v = Math.abs(applied) < SNAP_DB ? 0 : Number(applied.toFixed(1)); + raw.push({ t: Number((times[i] ?? 0).toFixed(3)), v }); + }); + + // Keep only the moves. A run of equal values is held by keeping the last of + // the run, or interpolation would slide across a passage that is steady. + const points: HfAutomationPoint[] = []; + let lastKept = 0; + let keptIndex = -1; + raw.forEach((pt, i) => { + if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < SNAP_DB) return; + if (i > 0 && keptIndex !== i - 1) { + const prev = raw[i - 1]; + if (prev) points.push(prev); + } + points.push(pt); + lastKept = pt.v; + keptIndex = i; + }); + + // A lane of zeroes is not a correction, it is a lane that says nothing. An + // author who runs this on an even track should be told there was nothing to + // do, not handed an inert envelope to wonder about. + if (points.length === 0 || points.every((p) => p.v === 0)) return []; + // A lane's first point has to sit at the clip's start, or everything before + // it is drawn from wherever the first move happens to be. + if ((points[0]?.t ?? 0) > 0) points.unshift({ t: 0, v: points[0]?.v ?? 0 }); + return points; +} + +/** + * The chain and lane for "Even Out Levels". + * + * Returns nothing when the track needs no correcting — a script that always + * writes something teaches an author that it is doing nothing. + */ +export function levellingResult( + chain: HfAudioFxChain, + samples: Float32Array, + sampleRate: number, + strength = DEFAULT_LEVELLER.strength, +): { chain: HfAudioFxChain; automation: HfAutomation } | null { + const points = analyseLevelling(samples, sampleRate, strength); + if (points.length === 0) return null; + + const existing = chain.nodes.find((n) => n.fromLeveller); + const id = existing?.id ?? mintAudioFxNodeId(chain); + const node: HfAudioFxNode = { + type: "gain", + id, + fromLeveller: true, + label: "Even Out Levels", + enabled: true, + // Seeded at 0 dB: the lane is what moves it, and a non-zero seed would be + // heard for the instant before the first ramp is scheduled. + params: normalizeAudioFxParams("gain", { gain: 0 }), + }; + + /** + * In FRONT of a trailing limiter, not after it. + * + * The likely sequence is "apply Clean Voice, then even out the levels", and + * Clean Voice ends in a Peak Ceiling. Appending would put up to 12 dB of lift + * AFTER the ceiling that exists to bound the chain — so every quiet-to-loud + * transition leaves residual lift on loud material sitting at -1 dBFS, and + * the render shears it flat. A ceiling that something is added after is not + * a ceiling. + */ + const insertAt = + !existing && chain.nodes[chain.nodes.length - 1]?.type === "limiter" + ? chain.nodes.length - 1 + : chain.nodes.length; + + return { + chain: { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: existing + ? chain.nodes.map((n) => (n.fromLeveller ? node : n)) + : [...chain.nodes.slice(0, insertAt), node, ...chain.nodes.slice(insertAt)], + }, + automation: { + version: 1, + lanes: [{ target: fxAutomationTarget(id, "gain"), points }], + }, + }; +} + +/** Drop the leveller and say which lane went with it. */ +export function removeLevelling(chain: HfAudioFxChain): { + chain: HfAudioFxChain; + removedTarget: string | null; +} { + const node = chain.nodes.find((n) => n.fromLeveller); + return { + chain: { ...chain, nodes: chain.nodes.filter((n) => !n.fromLeveller) }, + removedTarget: node?.id ? fxAutomationTarget(node.id, "gain") : null, + }; +} + +/** What the module says when it is closed. */ +export function levellingSummary(points: readonly HfAutomationPoint[]): string { + const moves = points.filter((p) => p.v !== 0); + if (moves.length === 0) return "Already even — nothing to do"; + const lift = Math.max(...moves.map((p) => p.v)); + const cut = Math.min(...moves.map((p) => p.v)); + const parts: string[] = []; + if (lift > 0) parts.push(`lifting quiet parts up to ${lift.toFixed(1)} dB`); + if (cut < 0) parts.push(`holding loud parts down ${Math.abs(cut).toFixed(1)} dB`); + return parts.join(", "); +} diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 5d132620e9..b31020130c 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -41,6 +41,7 @@ import { import { automatedTargetsOf, automationAttrValue, + withLane, HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_AUTOMATION_DATA_KEY, readPanelAutomation, @@ -48,6 +49,7 @@ import { withoutLane, withSeededLane, } from "./propertyPanelAutomation"; +import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller"; import type { DomEditSelection } from "./domEditingTypes"; import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime"; import { usePlayerStore } from "../../player"; @@ -483,6 +485,63 @@ export function AudioFxGroup({ * on this one. The bands replace any previous carve output but leave * hand-added effects alone, so re-analysing does not discard other work. */ + /** + * Measure THIS track and write the levelling lane. + * + * Same shape as the carve below it — decode offline, lock the rack while it + * works, write once — but it listens to the track it is on rather than to a + * voice above it, so it needs no source picker. + */ + const runLeveller = async (): Promise => { + const el = element.element; + const src = el?.getAttribute("src"); + const doc = el?.ownerDocument; + if (!src || !doc) return; + setAnalysing(true); + try { + const Ctor = + window.OfflineAudioContext ?? + (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + if (!Ctor) return; + const res = await fetch(new URL(src, doc.baseURI).href); + const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData( + await res.arrayBuffer(), + ); + const result = levellingResult(chain, buffer.getChannelData(0), buffer.sampleRate); + if (!result) return; + await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain)); + // Merged by target, never written wholesale: the script describes its own + // lane only, and replacing the attribute would take the carve's lanes and + // the volume lane with it. + const lane = result.automation.lanes[0]; + if (lane) { + void onSetAttributeQuiet( + HF_AUDIO_AUTOMATION_ATTR, + automationAttrValue(withLane(automation, lane)) || null, + ); + } + } catch { + // A track whose audio cannot be fetched or decoded simply gets no + // levelling, the same way an unreadable carve source is skipped. + } finally { + setAnalysing(false); + } + }; + + const removeLeveller = (): void => { + const { chain: next, removedTarget } = removeLevelling(chain); + void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); + // The lane goes with the node. An orphan keeps driving a parameter that is + // no longer in the graph. + if (removedTarget) { + void onSetAttributeQuiet( + HF_AUDIO_AUTOMATION_ATTR, + automationAttrValue(withoutLane(automation, removedTarget)) || null, + ); + } + }; + const analyse = async (active: HfCarveSettings | null = carve): Promise => { if (!active?.sources.length) return; const doc = element.element?.ownerDocument; @@ -660,6 +719,9 @@ export function AudioFxGroup({ onCarveChange={(next) => void setCarve(next)} onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))} sourceOptions={sourceOptions} + onLevel={() => void runLeveller()} + onRemoveLevel={removeLeveller} + levelled={chain.nodes.some((n) => n.fromLeveller)} carvedAgainstBy={carvedAgainstBy} analysing={analysing} /> diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.test.ts b/packages/studio/src/components/editor/propertyPanelAutomation.test.ts new file mode 100644 index 0000000000..f88bbbdcd2 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAutomation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { HfAutomation } from "@hyperframes/core/audio-automation"; +import { automationAttrValue, withLane, withoutLane } from "./propertyPanelAutomation.js"; + +const carved = (): HfAutomation => ({ + version: 1, + lanes: [ + { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] }, + { target: "fx.n2.gain", points: [{ t: 0, v: -9 }] }, + { target: "volume", points: [{ t: 0, v: 0.8 }] }, + ], +}); + +/** + * A script hands back a whole `HfAutomation` that describes only its OWN lane. + * Writing that to the attribute would take everything else with it — the + * carve's per-band lanes and the track's volume lane — which is silent, total, + * and only noticed later when the mix has quietly lost its ducking. + */ +describe("withLane", () => { + it("keeps every lane it was not asked about", () => { + const next = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] }); + expect(next.lanes.map((l) => l.target).sort()).toEqual([ + "fx.n1.gain", + "fx.n2.gain", + "fx.n9.gain", + "volume", + ]); + expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.8 }]); + }); + + it("replaces a lane rather than adding a second one for the same target", () => { + // Re-running a script must not leave two lanes fighting over one parameter. + const once = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] }); + const twice = withLane(once, { target: "fx.n9.gain", points: [{ t: 0, v: 5 }] }); + expect(twice.lanes.filter((l) => l.target === "fx.n9.gain")).toHaveLength(1); + expect(twice.lanes.find((l) => l.target === "fx.n9.gain")?.points).toEqual([{ t: 0, v: 5 }]); + expect(twice.lanes).toHaveLength(4); + }); + + it("does not mutate what it was given", () => { + const before = carved(); + withLane(before, { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] }); + expect(before.lanes).toHaveLength(3); + }); +}); + +describe("withoutLane", () => { + it("takes one lane and leaves the rest", () => { + // A node removed without its lane leaves an orphan driving a parameter that + // is no longer in the graph. + const next = withoutLane(carved(), "fx.n1.gain"); + expect(next.lanes.map((l) => l.target)).toEqual(["fx.n2.gain", "volume"]); + }); + + it("empties the attribute when the last lane goes", () => { + const one: HfAutomation = { version: 1, lanes: [{ target: "fx.n1.gain", points: [] }] }; + expect(automationAttrValue(withoutLane(one, "fx.n1.gain"))).toBe(""); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.ts b/packages/studio/src/components/editor/propertyPanelAutomation.ts index 3469ce29fa..a163766844 100644 --- a/packages/studio/src/components/editor/propertyPanelAutomation.ts +++ b/packages/studio/src/components/editor/propertyPanelAutomation.ts @@ -6,6 +6,7 @@ * attribute the same way. */ +import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; import { HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_AUTOMATION_DATA_KEY, @@ -73,6 +74,21 @@ export function withoutLane(automation: HfAutomation, target: string): HfAutomat return { version: 1, lanes: automation.lanes.filter((lane) => lane.target !== target) }; } +/** + * Replace one lane, leaving every other lane alone. + * + * A script that hands back a whole `HfAutomation` describes only its OWN lane. + * Writing that wholesale would take the carve's lanes and the volume lane with + * it, so what the script produces has to be merged in by target rather than + * swapped for what is already there. + */ +export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation { + return { + version: 1, + lanes: [...automation.lanes.filter((l) => l.target !== lane.target), lane], + }; +} + /** The attribute value for an automation set; empty when nothing is automated. */ export function automationAttrValue(automation: HfAutomation): string { return automation.lanes.length > 0 ? serializeAutomation(automation) : ""; diff --git a/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx new file mode 100644 index 0000000000..36629bca22 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx @@ -0,0 +1,191 @@ +/** + * The Tone module: a multi-band EQ as one control surface over several nodes. + * + * Faders rather than the rack's usual horizontal sliders, because a row of them + * around a centre detent is what an equaliser looks like to everybody who has + * met one. Recognising the control is most of the value — an author who has + * never opened a mixer has still used bass, middle and treble. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + audioEqSummary, + HF_AUDIO_EQ_RANGE_DB, + type HfAudioEqBand, +} from "@hyperframes/core/audio-fx-eq"; + +export interface FxEqModuleProps { + eqId: string; + bands: HfAudioEqBand[]; + open: boolean; + disabled?: boolean; + onToggleOpen(): void; + /** Dragging: heard immediately, not persisted. */ + onPreview(bandName: string, gain: number): void; + /** Release: the write that persists. */ + onCommit(bandName: string, gain: number): void; + onRemove(): void; +} + +/** Fader travel as a percentage from the top, with 0 dB at the centre. */ +function offsetFor(gain: number): number { + const clamped = Math.max(-HF_AUDIO_EQ_RANGE_DB, Math.min(HF_AUDIO_EQ_RANGE_DB, gain)); + return 50 - (clamped / (HF_AUDIO_EQ_RANGE_DB * 2)) * 100; +} + +const shown = (gain: number): string => { + const v = Number(gain.toFixed(1)); + return v > 0 ? `+${v}` : String(v); +}; + +function Fader({ + band, + disabled, + onPreview, + onCommit, +}: { + band: HfAudioEqBand; + disabled?: boolean; + onPreview(gain: number): void; + onCommit(gain: number): void; +}) { + /** + * Held locally for the length of the gesture. + * + * The module is driven by the chain, and dragging only PREVIEWS — it does + * not write — so a purely controlled input re-renders back to the old value + * on the first move and the fader snaps out from under the pointer. Same + * split the rack's other controls already make. + */ + const [local, setLocal] = useState(band.gain); + const [dragging, setDragging] = useState(false); + useEffect(() => { + if (!dragging) setLocal(band.gain); + }, [band.gain, dragging]); + + const value = dragging ? local : band.gain; + const pct = offsetFor(value); + const moved = Math.abs(value) >= 0.05; + + const move = (next: number) => { + setDragging(true); + setLocal(next); + onPreview(next); + }; + const settle = () => { + if (!dragging) return; + setDragging(false); + onCommit(local); + }; + + // A range input rotated into a fader: it keeps keyboard control, focus and + // the platform's own pointer handling, which a div with pointer events would + // all have to reimplement badly. + return ( +
+
+ + = 0 ? { top: `${pct}%`, bottom: "50%" } : { top: "50%", bottom: `${100 - pct}%` } + } + /> + move(Number(e.target.value))} + onPointerUp={settle} + onKeyUp={settle} + onBlur={settle} + /> +
+ + {band.name} + + + {moved ? shown(value) : "0"} + +
+ ); +} + +export function FxEqModule({ + bands, + open, + disabled, + onToggleOpen, + onPreview, + onCommit, + onRemove, +}: FxEqModuleProps) { + const preview = useCallback((name: string, gain: number) => onPreview(name, gain), [onPreview]); + const commit = useCallback((name: string, gain: number) => onCommit(name, gain), [onCommit]); + + return ( +
+
+ + {bands.length}-band + +
+ + {open ? ( +
+
+ {bands.map((band) => ( + preview(band.name, g)} + onCommit={(g) => commit(band.name, g)} + /> + ))} +
+
+ CUT + BOOST +
+
+ ) : ( + // Closed, it reads like every other module: a sentence about the sound + // rather than a list of values. +

+ {audioEqSummary(bands)} +

+ )} +
+ ); +} 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 c6a35307f7..cdbb760306 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -69,6 +69,9 @@ function mount(overrides: Partial[0]> = {}) { automatedTargets={overrides.automatedTargets} onAutomateParam={overrides.onAutomateParam} onRemoveParamAutomation={overrides.onRemoveParamAutomation} + onLevel={overrides.onLevel} + onRemoveLevel={overrides.onRemoveLevel} + levelled={overrides.levelled} />, ); return { host, onChainChange, onChainPreview, onCarveChange }; @@ -212,6 +215,196 @@ 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("shows a preset node by the job it is doing, not its filter type", () => { + const { host } = mount({ + chain: { + version: 1, + nodes: [ + { + type: "peaking", + id: "a", + label: "Reduce Mud", + enabled: true, + params: defaultAudioFxParams("peaking"), + }, + { + type: "peaking", + id: "b", + label: "Add Clarity", + enabled: true, + params: defaultAudioFxParams("peaking"), + }, + ], + } as HfAudioFxChain, + }); + const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) => + e.textContent?.trim(), + ); + // Without the label both rows read "Peaking EQ" and an author cannot tell + // which one is cutting and which is lifting. + expect(names).toContain("Reduce Mud"); + expect(names).toContain("Add Clarity"); + expect(names).not.toContain("Peaking EQ"); + }); + + it("adds a Tone EQ as three ordinary filters on one control surface", () => { + const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } }); + click(host.querySelector(".hf-fx-add")); + click(byText(host, ".hf-fx-add-composite", "Tone (EQ)")); + + const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain; + expect(next.nodes.map((n) => n.type)).toEqual(["lowshelf", "peaking", "highshelf"]); + expect(next.nodes.map((n) => n.label)).toEqual(["Bass", "Middle", "Treble"]); + expect(next.nodes.every((n) => n.fromEq === "eq1")).toBe(true); + }); + + it("shows the EQ as one module, not as its individual bands", () => { + // The bands belong to the Tone module. Listing them again in the rack would + // put the same filter on screen twice with two ways to edit it. + const { host } = mount({ + chain: { + version: 1, + nodes: [ + { + type: "lowshelf", + id: "a", + fromEq: "eq1", + label: "Bass", + enabled: true, + params: defaultAudioFxParams("lowshelf"), + }, + { + type: "peaking", + id: "b", + fromEq: "eq1", + label: "Middle", + enabled: true, + params: defaultAudioFxParams("peaking"), + }, + { + type: "highshelf", + id: "c", + fromEq: "eq1", + label: "Treble", + enabled: true, + params: defaultAudioFxParams("highshelf"), + }, + ], + } as HfAudioFxChain, + }); + expect(host.querySelectorAll(".hf-fx-eq-module")).toHaveLength(1); + const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) => + e.textContent?.trim(), + ); + expect(names).toContain("Tone"); + expect(names).not.toContain("Bass"); + // Closed, it says what it is doing rather than listing three zeroes. + expect(host.querySelector(".hf-fx-eq-summary")?.textContent).toMatch(/^Flat/); + }); + + it("moves one band without persisting until the fader is released", () => { + const { host, onChainChange, onChainPreview } = mount({ + chain: { + version: 1, + nodes: [ + { + type: "lowshelf", + id: "a", + fromEq: "eq1", + label: "Bass", + enabled: true, + params: defaultAudioFxParams("lowshelf"), + }, + { + type: "peaking", + id: "b", + fromEq: "eq1", + label: "Middle", + enabled: true, + params: defaultAudioFxParams("peaking"), + }, + { + type: "highshelf", + id: "c", + fromEq: "eq1", + label: "Treble", + enabled: true, + params: defaultAudioFxParams("highshelf"), + }, + ], + } as HfAudioFxChain, + }); + // The carve module leads the rack, so its header is the first one — open + // the EQ's own. + click(host.querySelector(".hf-fx-eq-module .hf-fx-node-name")); + const fader = host.querySelectorAll(".hf-fx-eq-fader")[0]!; + expect(fader, "the EQ did not open").toBeTruthy(); + typeInto(fader, "4"); + // Heard, not written — a persisting write per drag event reloads the + // composition and restarts the audio. + expect(onChainPreview).toHaveBeenCalled(); + expect(onChainChange).not.toHaveBeenCalled(); + + act(() => fader.dispatchEvent(new Event("pointerup", { bubbles: true }))); + const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain; + expect(next.nodes.find((n) => n.label === "Bass")!.params!.gain).toBe(4); + expect(next.nodes.find((n) => n.label === "Middle")!.params!.gain).toBe(0); + }); + + it("offers levelling, and offers to take it away once it is there", () => { + const onLevel = vi.fn(); + const onRemoveLevel = vi.fn(); + const { host } = mount({ onLevel, onRemoveLevel }); + click(host.querySelector(".hf-fx-add")); + click(byText(host, ".hf-fx-add-composite", "Even Out Levels")); + expect(onLevel).toHaveBeenCalledTimes(1); + + const already = mount({ onLevel, onRemoveLevel, levelled: true }); + click(already.host.querySelector(".hf-fx-add")); + // The same control, because adding a second levelling stage is never what + // an author means by pressing it twice. + click(byText(already.host, ".hf-fx-add-composite", "Remove levelling")); + expect(onRemoveLevel).toHaveBeenCalledTimes(1); + }); + 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..0c7c4fe134 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -22,8 +22,18 @@ 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 { + addAudioEq, + audioEqIds, + readAudioEqBands, + removeAudioEq, + setAudioEqBandGain, +} from "@hyperframes/core/audio-fx-eq"; import { fxAutomationTarget } from "@hyperframes/core/audio-automation"; import { FxParams, FxParamRow } from "./propertyPanelFxControls.js"; +import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js"; +import { FxEqModule } from "./propertyPanelFxEqModule.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"; @@ -589,7 +599,9 @@ function FxNodeRow({ data-fx-node={node.type} > 0 || carve !== null); const [adding, setAdding] = useState(false); + const [picking, setPicking] = useState(false); const [openNode, setOpenNode] = useState(0); const grouped = useMemo( @@ -708,6 +730,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([ @@ -747,10 +786,47 @@ export function FxSection({ const carveNodes = useMemo(() => chain.nodes.filter((n) => n.fromCarve), [chain.nodes]); /** Everything the author added, with the chain index every edit addresses. */ const handBuilt = useMemo( - () => chain.nodes.map((node, i) => ({ node, i })).filter(({ node }) => !node.fromCarve), + () => + chain.nodes + .map((node, i) => ({ node, i })) + // Carve and EQ bands belong to their own modules; showing them here too + // would put the same filter on screen twice with two ways to edit it. + .filter(({ node }) => !node.fromCarve && !node.fromEq), [chain.nodes], ); + const eqIds = useMemo(() => audioEqIds(chain), [chain]); + const [openEq, setOpenEq] = useState(null); + + const addEq = useCallback(() => { + const { chain: next, eqId } = addAudioEq(chain); + mutate(next.nodes); + setOpenEq(eqId); + setAdding(false); + }, [chain, mutate]); + + // Dragging a fader is heard immediately and written once on release, the same + // split every other control in the rack uses. + const previewEqBand = useCallback( + (eqId: string, band: string, gain: number) => + onChainPreview?.(setAudioEqBandGain(chain, eqId, band, gain)), + [chain, onChainPreview], + ); + const commitEqBand = useCallback( + (eqId: string, band: string, gain: number) => + mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes), + [chain, mutate], + ); + const removeEq = useCallback( + (eqId: string) => { + for (const node of chain.nodes) { + if (node.fromEq === eqId && node.id) onRemoveNodeAutomation?.(node.id); + } + mutate(removeAudioEq(chain, eqId).nodes); + }, + [chain, mutate, onRemoveNodeAutomation], + ); + const moveNode = useCallback( (index: number, delta: number) => { const target = index + delta; @@ -787,7 +863,20 @@ export function FxSection({ onCarvePreview={previewCarve} /> ) : null} - {handBuilt.length === 0 ? ( + {eqIds.map((eqId) => ( + setOpenEq((was) => (was === eqId ? null : eqId))} + onPreview={(band, gain) => previewEqBand(eqId, band, gain)} + onCommit={(band, gain) => commitEqBand(eqId, band, gain)} + onRemove={() => removeEq(eqId)} + /> + ))} + {handBuilt.length === 0 && eqIds.length === 0 ? (

{showCarve ? "No other effects on this track." : "No effects on this track."}

@@ -824,6 +913,37 @@ export function FxSection({ {adding ? (
+
+ + Tone + + {onLevel ? ( + + ) : null} + +
{grouped.map(({ group, defs }) => (
@@ -843,15 +963,29 @@ export function FxSection({
))}
- ) : ( - + ) : null} + + {picking ? : null} + + {adding || picking ? null : ( +
+ + +
)} ); diff --git a/plans/audio-fx-presets.md b/plans/audio-fx-presets.md index ac253e85f5..4023771f9a 100644 --- a/plans/audio-fx-presets.md +++ b/plans/audio-fx-presets.md @@ -137,8 +137,8 @@ and/or automation lanes. **The machinery for this is already built and shipped** | --- | --- | --- | | Voice carve *(shipped)* | `analyseCarveBands` | peaking cuts + per-band lanes | | Auto-duck *(shipped, inside carve)* | `analyseCarveDuck` | one volume lane | +| Leveller *(**shipped** — `audioLeveller.ts`)* | windowed RMS | lane on a `gain` node | | De-esser | `analyseCarveDynamics`, re-parameterised (see §5e) | lane on a peaking cut | -| Leveller | `windowDb` walk | lane on a `gain` node | | Tone match | `powerSpectrum` vs a target curve | 3–5 peaking nodes | This is the strongest argument in the doc: **none of them needs new DSP** — @@ -426,6 +426,61 @@ Four notes: --- +## 6b. What shipped + +| Commit | What | +| --- | --- | +| `a533d1677` | the preset catalogue in core — 18 presets over four shelves | +| `de2b78047` | applying presets from the rack | +| `29f53f935` | `label` on a node, so a chain reads as jobs rather than filter types | +| `e984a9e62` | the multi-band EQ in core, as a composite over shipping filters | +| `2eaa71cac` | the Tone module — faders in the rack | +| `e723216a1` | the levelling script | + +Three things worth carrying forward: + +**`parseAudioFxChain` silently dropped `fromPreset`** until `29f53f935`. The +round-trip test compared only node *types*, so it passed while the tag that +lets a preset find its own nodes was being lost on every reload. Any new tag on +a node (`fromEq`, `fromLeveller`, `label`) must be added to BOTH the parser and +the serializer, and the round-trip test must compare it. + +**The single-knob rule broke on `peaking`** and it took someone asking to see +it. "How much" cannot be the one knob when the range is the first decision. The +answer was to make the range the module — the add menu offers *jobs* — which +also dissolved the duplicate-name problem at the root. + +**The levelling target must be a level the track already reaches.** Anchoring +it to an absolute figure turns levelling into a volume change. The 80th +percentile of the track's own speaking windows is the figure that works. + +## 6c. The two scripts NOT built, and why + +**De-esser — deferred, not abandoned.** The leveller is its structural template: +analysis → profile → result → remove → summary, with a mutation pass over each. +Two constraints have to be designed for before it is written, and neither is +visible until you try: + +1. `analyseCarveDynamics` cannot be reused unchanged. Its hop is + `max(FRAME, length / POINT_BUDGET)` — 85 ms at best, ~150 ms on a + real-length track — while sibilants are 50–150 ms events. At that resolution + the envelope cannot land on them, and its `ATTACK_S`/`RELEASE_S` are tuned + for musical ducking besides. Same machinery, re-parameterised for a + sibilance timescale. +2. **`MAX_AUTOMATION_POINTS` is 512.** A dip needs three or four points, and a + long voiceover holds hundreds of sibilant events, so a naive lane blows the + cap and the scheduler truncates it — silently, leaving an envelope that + stops partway through the clip. Budget it up front: strongest-N events, or + merge adjacent dips. + +**Tone match — superseded for v1.** It existed to give a casual author a way to +fix the tone of a track without understanding frequencies. The Tone EQ now does +that with a control they already know, and it does it *predictably*, which +matching against a reference clip does not. What remains is genuinely advanced +— matching one track to another — and it carries real unknowns: which reference, +how much correction, what to do when the two sources have different content. +Not worth building before anyone has asked for it. + ## 7. What to build first 1. **Character presets** (§5c) — highest ratio of delight to risk. Pure diff --git a/plans/audio-fx-ux/README.md b/plans/audio-fx-ux/README.md new file mode 100644 index 0000000000..cf50d19819 --- /dev/null +++ b/plans/audio-fx-ux/README.md @@ -0,0 +1,191 @@ +# 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. + +## Shipped: a multi-band EQ ("Tone") + +*Built in `e984a9e62` / `2eaa71cac`. The design below is what was built.* + +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 + +The EQ, the named jobs and the levelling script are **built**. `copy.mts` +itself is still a proposal — the plain-language layer over the other thirteen +effects has not landed, and neither have the `PROFILES` figures. + +`copy.mts` has no entry for Tone or for the levelling module, because both +carry their own copy in core (`audioEqSummary`, `levellingSummary`). That is +the right home for it: a summary that has to read the chain belongs beside the +code that writes it. + +`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.