Skip to content

Commit 072040b

Browse files
vanceingallsclaude
andcommitted
feat(core): add a multi-band EQ as a composite over existing filters
Bass, middle and treble is the most widely understood audio control there is, 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 shape is also forced: the registry's 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. Two band sets. Three is Bass/Middle/Treble. Five opens to Bass/Warmth/Middle/Clarity/Air, named from the shared vocabulary the rest of the rack uses, so reaching for the EQ is also how the words get learned. The chain is authoritative, not a cached band list: an author can open the details and move a frequency by hand, and the faders read it back rather than overwriting it on the next drag. The fader is held to ±12 dB while the filters themselves allow ±40 — a tone control that can bury a track under 40 dB of bass is not a tone control. `fromEq` joins `fromCarve`, `fromPreset` and `label` through the parser and serializer, so an EQ survives the attribute round trip. Without that the module cannot find its own bands after a reload and silently becomes loose filters. Fourteen tests, falsified against four mutations: the parser dropping fromEq, the fader re-seeding the whole band (which would lose a hand-set frequency), the ±12 clamp removed, and two EQs sharing an id. Core 1690 -> 1704. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c9c300f commit 072040b

5 files changed

Lines changed: 376 additions & 0 deletions

File tree

packages/core/package-subpaths.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@
9292
"types": "./dist/audioFx.d.ts",
9393
"environments": ["browser", "bun", "node"]
9494
},
95+
"./audio-fx-eq": {
96+
"source": "./src/audioFxEq.ts",
97+
"runtime": "./dist/audioFxEq.js",
98+
"types": "./dist/audioFxEq.d.ts",
99+
"environments": ["browser", "bun", "node"]
100+
},
95101
"./audio-fx-presets": {
96102
"source": "./src/audioFxPresets.ts",
97103
"runtime": "./dist/audioFxPresets.js",

packages/core/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@
106106
"import": "./src/audioFx.ts",
107107
"types": "./src/audioFx.ts"
108108
},
109+
"./audio-fx-eq": {
110+
"bun": "./src/audioFxEq.ts",
111+
"node": "./dist/audioFxEq.js",
112+
"import": "./src/audioFxEq.ts",
113+
"types": "./src/audioFxEq.ts"
114+
},
109115
"./audio-fx-presets": {
110116
"bun": "./src/audioFxPresets.ts",
111117
"node": "./dist/audioFxPresets.js",
@@ -398,6 +404,10 @@
398404
"import": "./dist/audioFx.js",
399405
"types": "./dist/audioFx.d.ts"
400406
},
407+
"./audio-fx-eq": {
408+
"import": "./dist/audioFxEq.js",
409+
"types": "./dist/audioFxEq.d.ts"
410+
},
401411
"./audio-fx-presets": {
402412
"import": "./dist/audioFxPresets.js",
403413
"types": "./dist/audioFxPresets.d.ts"

packages/core/src/audioFx.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,14 @@ export interface HfAudioFxNode {
824824
* as a list of things that were done rather than a list of filter types.
825825
*/
826826
label?: string;
827+
/**
828+
* Id of the multi-band EQ that owns this node, when it is one of its bands.
829+
*
830+
* Same device as `fromCarve`: the module gathers its own nodes out of the
831+
* chain and presents them as one control surface, so an EQ needs no new
832+
* effect type and its bands stay ordinary filters underneath.
833+
*/
834+
fromEq?: string;
827835
/** Absent means enabled — chain files written before the field existed still load. */
828836
enabled?: boolean;
829837
params?: HfAudioFxParamValues;
@@ -875,6 +883,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
875883
fromCarve?: unknown;
876884
fromPreset?: unknown;
877885
label?: unknown;
886+
fromEq?: unknown;
878887
};
879888
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
880889
throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
@@ -890,6 +899,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
890899
? { fromPreset: node.fromPreset }
891900
: {}),
892901
...(typeof node.label === "string" && node.label ? { label: node.label } : {}),
902+
...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}),
893903
enabled: node.enabled !== false,
894904
params: normalizeAudioFxParams(
895905
node.type,
@@ -915,6 +925,7 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string {
915925
...(node.fromCarve === true ? { fromCarve: true } : {}),
916926
...(node.fromPreset ? { fromPreset: node.fromPreset } : {}),
917927
...(node.label ? { label: node.label } : {}),
928+
...(node.fromEq ? { fromEq: node.fromEq } : {}),
918929
...(node.enabled === false ? { enabled: false } : {}),
919930
params: normalizeAudioFxParams(node.type, node.params),
920931
})),
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
HF_AUDIO_FX_CHAIN_VERSION,
4+
parseAudioFxChain,
5+
serializeAudioFxChain,
6+
type HfAudioFxChain,
7+
} from "./audioFx.js";
8+
import {
9+
addAudioEq,
10+
audioEqIds,
11+
audioEqSummary,
12+
HF_AUDIO_EQ_3,
13+
HF_AUDIO_EQ_5,
14+
HF_AUDIO_EQ_RANGE_DB,
15+
readAudioEqBands,
16+
removeAudioEq,
17+
setAudioEqBandGain,
18+
} from "./audioFxEq.js";
19+
20+
const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] });
21+
22+
describe("adding an EQ", () => {
23+
it("writes one ordinary filter per band, in band order", () => {
24+
const { chain } = addAudioEq(empty());
25+
expect(chain.nodes.map((n) => n.type)).toEqual(["lowshelf", "peaking", "highshelf"]);
26+
// Ordinary nodes: an author who opens the details finds filters they could
27+
// have added by hand, not an opaque "eq" the graph has to special-case.
28+
expect(chain.nodes.every((n) => n.enabled)).toBe(true);
29+
expect(chain.nodes.map((n) => n.label)).toEqual(["Bass", "Middle", "Treble"]);
30+
});
31+
32+
it("gives every band an id, because a lane addresses effects by id", () => {
33+
const { chain } = addAudioEq(empty(), HF_AUDIO_EQ_5);
34+
const ids = chain.nodes.map((n) => n.id);
35+
expect(ids.every(Boolean)).toBe(true);
36+
expect(new Set(ids).size).toBe(ids.length);
37+
});
38+
39+
it("starts flat, so adding one changes nothing until a fader moves", () => {
40+
const { chain, eqId } = addAudioEq(empty());
41+
for (const band of readAudioEqBands(chain, eqId)) expect(band.gain).toBe(0);
42+
expect(audioEqSummary(readAudioEqBands(chain, eqId))).toMatch(/^Flat/);
43+
});
44+
45+
it("keeps two EQs apart", () => {
46+
const first = addAudioEq(empty());
47+
const second = addAudioEq(first.chain, HF_AUDIO_EQ_5);
48+
expect(second.eqId).not.toBe(first.eqId);
49+
expect(audioEqIds(second.chain)).toEqual([first.eqId, second.eqId]);
50+
expect(readAudioEqBands(second.chain, first.eqId)).toHaveLength(3);
51+
expect(readAudioEqBands(second.chain, second.eqId)).toHaveLength(5);
52+
expect(new Set(second.chain.nodes.map((n) => n.id)).size).toBe(second.chain.nodes.length);
53+
});
54+
55+
it("leaves effects that were already there alone", () => {
56+
const before: HfAudioFxChain = {
57+
version: HF_AUDIO_FX_CHAIN_VERSION,
58+
nodes: [{ type: "reverb", id: "mine", enabled: true }],
59+
};
60+
const { chain } = addAudioEq(before);
61+
expect(chain.nodes[0]?.id).toBe("mine");
62+
expect(chain.nodes).toHaveLength(4);
63+
});
64+
});
65+
66+
describe("moving a fader", () => {
67+
it("changes only that band", () => {
68+
const { chain, eqId } = addAudioEq(empty());
69+
const next = setAudioEqBandGain(chain, eqId, "Bass", 4.5);
70+
const bands = readAudioEqBands(next, eqId);
71+
expect(bands.find((b) => b.name === "Bass")?.gain).toBe(4.5);
72+
expect(bands.find((b) => b.name === "Middle")?.gain).toBe(0);
73+
expect(bands.find((b) => b.name === "Treble")?.gain).toBe(0);
74+
});
75+
76+
it("leaves the band's frequency and width alone", () => {
77+
// The fader is one control. Moving it must not quietly re-seed the rest of
78+
// the band, or an author who set a frequency by hand loses it on the next drag.
79+
const { chain, eqId } = addAudioEq(empty(), HF_AUDIO_EQ_5);
80+
const before = readAudioEqBands(chain, eqId).find((b) => b.name === "Clarity")!;
81+
const next = setAudioEqBandGain(chain, eqId, "Clarity", -3);
82+
const after = readAudioEqBands(next, eqId).find((b) => b.name === "Clarity")!;
83+
expect(after.frequency).toBe(before.frequency);
84+
expect(after.q).toBe(before.q);
85+
expect(after.gain).toBe(-3);
86+
});
87+
88+
it("holds the fader to a tone control's range, not a repair tool's", () => {
89+
// The filters themselves allow ±40 dB. A tone control that can bury a
90+
// track under 40 dB of bass is not a tone control.
91+
const { chain, eqId } = addAudioEq(empty());
92+
const hot = setAudioEqBandGain(chain, eqId, "Bass", 40);
93+
const cold = setAudioEqBandGain(chain, eqId, "Bass", -40);
94+
expect(readAudioEqBands(hot, eqId)[0]?.gain).toBe(HF_AUDIO_EQ_RANGE_DB);
95+
expect(readAudioEqBands(cold, eqId)[0]?.gain).toBe(-HF_AUDIO_EQ_RANGE_DB);
96+
});
97+
98+
it("ignores a band name that is not in this EQ", () => {
99+
const { chain, eqId } = addAudioEq(empty());
100+
const next = setAudioEqBandGain(chain, eqId, "Nonsense", 6);
101+
expect(readAudioEqBands(next, eqId).every((b) => b.gain === 0)).toBe(true);
102+
});
103+
});
104+
105+
describe("the chain is the truth", () => {
106+
it("reads a frequency the author moved by hand", () => {
107+
// The nodes are authoritative, not a cached band list: opening the details
108+
// and moving a frequency has to show up on the fader's own band.
109+
const { chain, eqId } = addAudioEq(empty());
110+
const edited: HfAudioFxChain = {
111+
...chain,
112+
nodes: chain.nodes.map((n) =>
113+
n.label === "Middle" ? { ...n, params: { ...n.params, frequency: 700 } } : n,
114+
),
115+
};
116+
expect(readAudioEqBands(edited, eqId).find((b) => b.name === "Middle")?.frequency).toBe(700);
117+
});
118+
119+
it("survives being written to an attribute and read back", () => {
120+
const { chain, eqId } = addAudioEq(empty(), HF_AUDIO_EQ_5);
121+
const moved = setAudioEqBandGain(chain, eqId, "Air", 2.5);
122+
const back = parseAudioFxChain(serializeAudioFxChain(moved));
123+
// Without fromEq surviving, the module cannot find its own bands after a
124+
// reload and the EQ silently becomes five loose filters.
125+
expect(audioEqIds(back)).toEqual([eqId]);
126+
expect(readAudioEqBands(back, eqId).map((b) => b.name)).toEqual([
127+
"Bass",
128+
"Warmth",
129+
"Middle",
130+
"Clarity",
131+
"Air",
132+
]);
133+
expect(readAudioEqBands(back, eqId).find((b) => b.name === "Air")?.gain).toBe(2.5);
134+
});
135+
136+
it("removes a whole EQ without touching anything else", () => {
137+
const before: HfAudioFxChain = {
138+
version: HF_AUDIO_FX_CHAIN_VERSION,
139+
nodes: [{ type: "reverb", id: "mine", enabled: true }],
140+
};
141+
const { chain, eqId } = addAudioEq(before);
142+
const gone = removeAudioEq(chain, eqId);
143+
expect(gone.nodes.map((n) => n.id)).toEqual(["mine"]);
144+
});
145+
});
146+
147+
describe("what it says when closed", () => {
148+
it("names only the bands that were moved", () => {
149+
const { chain, eqId } = addAudioEq(empty());
150+
const next = setAudioEqBandGain(setAudioEqBandGain(chain, eqId, "Bass", 3), eqId, "Treble", -2);
151+
expect(audioEqSummary(readAudioEqBands(next, eqId))).toBe("Bass +3, Treble −2");
152+
});
153+
154+
it("says so when nothing has been touched", () => {
155+
expect(audioEqSummary(HF_AUDIO_EQ_3)).toMatch(/^Flat/);
156+
});
157+
});

0 commit comments

Comments
 (0)