diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index ff5e739d88..7ccbd919af 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -92,6 +92,12 @@ "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-fx-presets": { "source": "./src/audioFxPresets.ts", "runtime": "./dist/audioFxPresets.js", diff --git a/packages/core/package.json b/packages/core/package.json index c4f5e16cc4..92bd3edd28 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -106,6 +106,12 @@ "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-fx-presets": { "bun": "./src/audioFxPresets.ts", "node": "./dist/audioFxPresets.js", @@ -398,6 +404,10 @@ "import": "./dist/audioFx.js", "types": "./dist/audioFx.d.ts" }, + "./audio-fx-eq": { + "import": "./dist/audioFxEq.js", + "types": "./dist/audioFxEq.d.ts" + }, "./audio-fx-presets": { "import": "./dist/audioFxPresets.js", "types": "./dist/audioFxPresets.d.ts" diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 249cf89eba..87531da235 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -824,6 +824,14 @@ export interface HfAudioFxNode { * 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; /** Absent means enabled — chain files written before the field existed still load. */ enabled?: boolean; params?: HfAudioFxParamValues; @@ -875,6 +883,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { fromCarve?: unknown; fromPreset?: unknown; label?: unknown; + fromEq?: unknown; }; if (typeof node.type !== "string" || !BY_ID.has(node.type)) { throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`); @@ -890,6 +899,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { ? { fromPreset: node.fromPreset } : {}), ...(typeof node.label === "string" && node.label ? { label: node.label } : {}), + ...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}), enabled: node.enabled !== false, params: normalizeAudioFxParams( node.type, @@ -915,6 +925,7 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string { ...(node.fromCarve === true ? { fromCarve: true } : {}), ...(node.fromPreset ? { fromPreset: node.fromPreset } : {}), ...(node.label ? { label: node.label } : {}), + ...(node.fromEq ? { fromEq: node.fromEq } : {}), ...(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;