Skip to content

Commit 30693e2

Browse files
vanceingallsclaude
andcommitted
feat(studio): the Tone module — a multi-band EQ on faders
One control surface over the EQ's tagged nodes, added from the rack's own menu. 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 everyone who has met one — recognising the control is most of the value here. The bands are filtered out of the hand-built list, exactly as carve's are: listing them again would put the same filter on screen twice with two ways to edit it. Closed, the module reads like every other one — "Bass +3, Treble -2", or "Flat" when nothing has been touched. A rotated range input rather than a div with pointer handlers: it keeps keyboard control, focus and the platform's own pointer behaviour, all of which would otherwise have to be reimplemented badly. Writing the test caught a real bug. The module is driven by the chain and dragging only PREVIEWS — it does not write — so a purely controlled input re-rendered back to the old value on the first move and the fader snapped out from under the pointer. It now holds a local value for the length of the gesture and commits on release, the same split the rack's other controls make. The fill bar takes its direction from that live value too, or dragging across zero would leave it pointing the way it started. "Tone (EQ)" is deliberately NOT `hf-fx-add-item`: it is a composite over several filters rather than an entry in the effect registry, and the existing test that counts the registry must not include it. Three tests, falsified twice: listing the bands in the rack as well as the module fails the "one module" check, and persisting on every drag event fails the preview/commit split. Studio 3665 -> 3668. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 072040b commit 30693e2

3 files changed

Lines changed: 371 additions & 2 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* The Tone module: a multi-band EQ as one control surface over several nodes.
3+
*
4+
* Faders rather than the rack's usual horizontal sliders, because a row of them
5+
* around a centre detent is what an equaliser looks like to everybody who has
6+
* met one. Recognising the control is most of the value — an author who has
7+
* never opened a mixer has still used bass, middle and treble.
8+
*/
9+
10+
import { useCallback, useEffect, useState } from "react";
11+
import {
12+
audioEqSummary,
13+
HF_AUDIO_EQ_RANGE_DB,
14+
type HfAudioEqBand,
15+
} from "@hyperframes/core/audio-fx-eq";
16+
17+
export interface FxEqModuleProps {
18+
eqId: string;
19+
bands: HfAudioEqBand[];
20+
open: boolean;
21+
disabled?: boolean;
22+
onToggleOpen(): void;
23+
/** Dragging: heard immediately, not persisted. */
24+
onPreview(bandName: string, gain: number): void;
25+
/** Release: the write that persists. */
26+
onCommit(bandName: string, gain: number): void;
27+
onRemove(): void;
28+
}
29+
30+
/** Fader travel as a percentage from the top, with 0 dB at the centre. */
31+
function offsetFor(gain: number): number {
32+
const clamped = Math.max(-HF_AUDIO_EQ_RANGE_DB, Math.min(HF_AUDIO_EQ_RANGE_DB, gain));
33+
return 50 - (clamped / (HF_AUDIO_EQ_RANGE_DB * 2)) * 100;
34+
}
35+
36+
const shown = (gain: number): string => {
37+
const v = Number(gain.toFixed(1));
38+
return v > 0 ? `+${v}` : String(v);
39+
};
40+
41+
function Fader({
42+
band,
43+
disabled,
44+
onPreview,
45+
onCommit,
46+
}: {
47+
band: HfAudioEqBand;
48+
disabled?: boolean;
49+
onPreview(gain: number): void;
50+
onCommit(gain: number): void;
51+
}) {
52+
/**
53+
* Held locally for the length of the gesture.
54+
*
55+
* The module is driven by the chain, and dragging only PREVIEWS — it does
56+
* not write — so a purely controlled input re-renders back to the old value
57+
* on the first move and the fader snaps out from under the pointer. Same
58+
* split the rack's other controls already make.
59+
*/
60+
const [local, setLocal] = useState(band.gain);
61+
const [dragging, setDragging] = useState(false);
62+
useEffect(() => {
63+
if (!dragging) setLocal(band.gain);
64+
}, [band.gain, dragging]);
65+
66+
const value = dragging ? local : band.gain;
67+
const pct = offsetFor(value);
68+
const moved = Math.abs(value) >= 0.05;
69+
70+
const move = (next: number) => {
71+
setDragging(true);
72+
setLocal(next);
73+
onPreview(next);
74+
};
75+
const settle = () => {
76+
if (!dragging) return;
77+
setDragging(false);
78+
onCommit(local);
79+
};
80+
81+
// A range input rotated into a fader: it keeps keyboard control, focus and
82+
// the platform's own pointer handling, which a div with pointer events would
83+
// all have to reimplement badly.
84+
return (
85+
<div className="hf-fx-eq-band flex min-w-0 flex-1 flex-col items-center gap-1">
86+
<div className="relative h-[74px] w-full">
87+
<span className="pointer-events-none absolute inset-x-0 top-1/2 h-px bg-panel-border-input" />
88+
<span
89+
className="pointer-events-none absolute left-1/2 w-[3px] -translate-x-1/2 rounded-sm bg-panel-accent"
90+
style={
91+
// `value`, not `band.gain`: mid-drag across zero the fill would
92+
// otherwise keep pointing the way it started.
93+
value >= 0 ? { top: `${pct}%`, bottom: "50%" } : { top: "50%", bottom: `${100 - pct}%` }
94+
}
95+
/>
96+
<input
97+
className="hf-fx-eq-fader absolute left-1/2 h-[19px] w-[74px] -translate-x-1/2 -translate-y-1/2 rotate-[-90deg] cursor-ns-resize appearance-none bg-transparent"
98+
style={{ top: "50%" }}
99+
type="range"
100+
min={-HF_AUDIO_EQ_RANGE_DB}
101+
max={HF_AUDIO_EQ_RANGE_DB}
102+
step={0.5}
103+
value={value}
104+
disabled={disabled}
105+
aria-label={`${band.name} ${shown(value)} dB`}
106+
onChange={(e) => move(Number(e.target.value))}
107+
onPointerUp={settle}
108+
onKeyUp={settle}
109+
onBlur={settle}
110+
/>
111+
</div>
112+
<span className="hf-fx-eq-name w-full truncate text-center font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
113+
{band.name}
114+
</span>
115+
<span
116+
className={`hf-fx-eq-value font-mono text-[9px] tabular-nums ${
117+
moved ? "text-panel-accent" : "text-panel-text-4"
118+
}`}
119+
>
120+
{moved ? shown(value) : "0"}
121+
</span>
122+
</div>
123+
);
124+
}
125+
126+
export function FxEqModule({
127+
bands,
128+
open,
129+
disabled,
130+
onToggleOpen,
131+
onPreview,
132+
onCommit,
133+
onRemove,
134+
}: FxEqModuleProps) {
135+
const preview = useCallback((name: string, gain: number) => onPreview(name, gain), [onPreview]);
136+
const commit = useCallback((name: string, gain: number) => onCommit(name, gain), [onCommit]);
137+
138+
return (
139+
<div
140+
className="hf-fx-node hf-fx-eq-module rounded-[4px] border border-panel-border-input"
141+
data-fx-node="eq"
142+
>
143+
<div className="hf-fx-node-head flex items-center gap-1.5 px-1.5 py-1">
144+
<button
145+
type="button"
146+
className="hf-fx-node-name flex-1 text-left text-[11px] text-panel-text-0"
147+
aria-expanded={open}
148+
onClick={onToggleOpen}
149+
>
150+
Tone
151+
</button>
152+
<span className="font-mono text-[9px] text-panel-text-4">{bands.length}-band</span>
153+
<button
154+
type="button"
155+
className="hf-fx-remove px-1 text-[11px] text-panel-text-4 hover:text-panel-danger"
156+
aria-label="Remove Tone"
157+
disabled={disabled}
158+
onClick={onRemove}
159+
>
160+
×
161+
</button>
162+
</div>
163+
164+
{open ? (
165+
<div className="hf-fx-eq-body px-2 pb-2">
166+
<div className="flex gap-1.5">
167+
{bands.map((band) => (
168+
<Fader
169+
key={band.name}
170+
band={band}
171+
disabled={disabled}
172+
onPreview={(g) => preview(band.name, g)}
173+
onCommit={(g) => commit(band.name, g)}
174+
/>
175+
))}
176+
</div>
177+
<div className="mt-1.5 flex justify-between font-mono text-[8px] tracking-wide text-panel-text-4">
178+
<span>CUT</span>
179+
<span>BOOST</span>
180+
</div>
181+
</div>
182+
) : (
183+
// Closed, it reads like every other module: a sentence about the sound
184+
// rather than a list of values.
185+
<p className="hf-fx-eq-summary px-2 pb-1.5 text-[11px] text-panel-text-2">
186+
{audioEqSummary(bands)}
187+
</p>
188+
)}
189+
</div>
190+
);
191+
}

packages/studio/src/components/editor/propertyPanelFxSection.test.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,110 @@ describe("FxSection chain", () => {
282282
expect(names).not.toContain("Peaking EQ");
283283
});
284284

285+
it("adds a Tone EQ as three ordinary filters on one control surface", () => {
286+
const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
287+
click(host.querySelector(".hf-fx-add"));
288+
click(byText(host, ".hf-fx-add-composite", "Tone (EQ)"));
289+
290+
const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
291+
expect(next.nodes.map((n) => n.type)).toEqual(["lowshelf", "peaking", "highshelf"]);
292+
expect(next.nodes.map((n) => n.label)).toEqual(["Bass", "Middle", "Treble"]);
293+
expect(next.nodes.every((n) => n.fromEq === "eq1")).toBe(true);
294+
});
295+
296+
it("shows the EQ as one module, not as its individual bands", () => {
297+
// The bands belong to the Tone module. Listing them again in the rack would
298+
// put the same filter on screen twice with two ways to edit it.
299+
const { host } = mount({
300+
chain: {
301+
version: 1,
302+
nodes: [
303+
{
304+
type: "lowshelf",
305+
id: "a",
306+
fromEq: "eq1",
307+
label: "Bass",
308+
enabled: true,
309+
params: defaultAudioFxParams("lowshelf"),
310+
},
311+
{
312+
type: "peaking",
313+
id: "b",
314+
fromEq: "eq1",
315+
label: "Middle",
316+
enabled: true,
317+
params: defaultAudioFxParams("peaking"),
318+
},
319+
{
320+
type: "highshelf",
321+
id: "c",
322+
fromEq: "eq1",
323+
label: "Treble",
324+
enabled: true,
325+
params: defaultAudioFxParams("highshelf"),
326+
},
327+
],
328+
} as HfAudioFxChain,
329+
});
330+
expect(host.querySelectorAll(".hf-fx-eq-module")).toHaveLength(1);
331+
const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) =>
332+
e.textContent?.trim(),
333+
);
334+
expect(names).toContain("Tone");
335+
expect(names).not.toContain("Bass");
336+
// Closed, it says what it is doing rather than listing three zeroes.
337+
expect(host.querySelector(".hf-fx-eq-summary")?.textContent).toMatch(/^Flat/);
338+
});
339+
340+
it("moves one band without persisting until the fader is released", () => {
341+
const { host, onChainChange, onChainPreview } = mount({
342+
chain: {
343+
version: 1,
344+
nodes: [
345+
{
346+
type: "lowshelf",
347+
id: "a",
348+
fromEq: "eq1",
349+
label: "Bass",
350+
enabled: true,
351+
params: defaultAudioFxParams("lowshelf"),
352+
},
353+
{
354+
type: "peaking",
355+
id: "b",
356+
fromEq: "eq1",
357+
label: "Middle",
358+
enabled: true,
359+
params: defaultAudioFxParams("peaking"),
360+
},
361+
{
362+
type: "highshelf",
363+
id: "c",
364+
fromEq: "eq1",
365+
label: "Treble",
366+
enabled: true,
367+
params: defaultAudioFxParams("highshelf"),
368+
},
369+
],
370+
} as HfAudioFxChain,
371+
});
372+
// The carve module leads the rack, so its header is the first one — open
373+
// the EQ's own.
374+
click(host.querySelector(".hf-fx-eq-module .hf-fx-node-name"));
375+
const fader = host.querySelectorAll<HTMLInputElement>(".hf-fx-eq-fader")[0]!;
376+
expect(fader, "the EQ did not open").toBeTruthy();
377+
typeInto(fader, "4");
378+
// Heard, not written — a persisting write per drag event reloads the
379+
// composition and restarts the audio.
380+
expect(onChainPreview).toHaveBeenCalled();
381+
expect(onChainChange).not.toHaveBeenCalled();
382+
383+
act(() => fader.dispatchEvent(new Event("pointerup", { bubbles: true })));
384+
const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
385+
expect(next.nodes.find((n) => n.label === "Bass")!.params!.gain).toBe(4);
386+
expect(next.nodes.find((n) => n.label === "Middle")!.params!.gain).toBe(0);
387+
});
388+
285389
it("cannot move the ends past themselves", () => {
286390
const { host } = mount({ chain: chainOf("peaking", "reverb") });
287391
const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]');

0 commit comments

Comments
 (0)