Skip to content

Commit e477ec2

Browse files
committed
feat(studio): draw the rack as the signal path it is
The last of the schematic direction, translated to a one-column panel rather than the wide diagram the review page draws. **Both ends named.** IN — this track, OUT — to mix. Two lines, and they change what the rack is: without them the order reads as a list, and a list is the one reading that makes "move up" look cosmetic. It is the most consequential control in the panel — chain order is audible. **Every step numbered**, counted over what the rack SHOWS rather than over the chain. The carve's filters and an EQ's bands live inside their own modules, so counting raw nodes would leave the visible rack jumping from 02 to 07, and the numbers would read as a bug rather than a position. **A preset draws as one thing.** Applying one used to drop five loose rows in with nothing saying they arrived together — the same failure the carve module exists to fix, one level down. Consecutive nodes only: a preset pulled apart by a reorder is no longer a unit, and a bracket around the gap would claim an adjacency the signal path does not have. Falsified: numbering by chain index fails the path test, and grouping a preset's nodes regardless of adjacency fails the run test. studio 3695 passing, 18 todo.
1 parent a2ea0a6 commit e477ec2

3 files changed

Lines changed: 130 additions & 2 deletions

File tree

‎packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ function plainDef(def: HfAudioFxDef): HfAudioFxDef {
7373
interface FxNodeRowProps {
7474
node: HfAudioFxNode;
7575
index: number;
76+
/** Where it sits in the signal path, as the rack counts it. Absent means unnumbered. */
77+
position?: number;
7678
automatedTargets?: ReadonlySet<string>;
7779
liveAutomationValues?: ReadonlyMap<string, number>;
7880
onAutomateParam?(nodeId: string, paramKey: string): void;
@@ -117,6 +119,7 @@ function FxMoveButton({
117119
function FxNodeHeader({
118120
label,
119121
family,
122+
position,
120123
open,
121124
bypassed,
122125
first,
@@ -130,6 +133,7 @@ function FxNodeHeader({
130133
label: string;
131134
/** How this family letters, so the KIND reads before the word does. */
132135
family: string;
136+
position?: number;
133137
open: boolean;
134138
bypassed: boolean;
135139
first: boolean;
@@ -142,6 +146,14 @@ function FxNodeHeader({
142146
}) {
143147
return (
144148
<div className="hf-fx-node-head flex min-h-7 items-center gap-1 px-1.5">
149+
{/* Two digits, because a rack reads as a path when its steps are numbered
150+
and as a list when they are not — and the difference decides whether an
151+
author thinks the order matters. It does; it is audible. */}
152+
{position !== undefined ? (
153+
<span className="hf-fx-node-index shrink-0 font-mono text-[9px] tabular-nums text-panel-text-4">
154+
{String(position).padStart(2, "0")}
155+
</span>
156+
) : null}
145157
<button
146158
type="button"
147159
className={`hf-fx-node-name flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${family}`}
@@ -263,6 +275,7 @@ function FxNodeParams({
263275
export function FxNodeRow({
264276
node,
265277
index,
278+
position,
266279
automatedTargets,
267280
liveAutomationValues,
268281
onAutomateParam,
@@ -307,6 +320,7 @@ export function FxNodeRow({
307320
>
308321
<FxNodeHeader
309322
family={FX_FAMILY_TYPE[fxFamilyOf(node)]}
323+
position={position}
310324
// The node's own job name when a preset gave it one, because that is the
311325
// most specific truth available: a chain that cuts mud and then lifts
312326
// clarity must not show the same name twice. Then the plain name, and

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,60 @@ describe("FxSection chain", () => {
362362
);
363363
});
364364

365+
it("draws the rack as a signal path, with both ends named", () => {
366+
// Order is audible here, and a list does not look ordered. Numbering the
367+
// steps and naming the two ends is what makes "move up" read as the most
368+
// consequential control in the panel rather than a cosmetic one.
369+
const { host } = mount({ chain: chainOf("highpass", "limiter") });
370+
const terms = Array.from(host.querySelectorAll(".hf-fx-term")).map((e) => e.textContent);
371+
expect(terms).toHaveLength(2);
372+
expect(terms[0]).toContain("In");
373+
expect(terms[1]).toContain("Out");
374+
// Counted over what the rack SHOWS: the carve module leads it, so the first
375+
// hand-built effect is 02.
376+
const numbers = Array.from(host.querySelectorAll(".hf-fx-node-index")).map((e) =>
377+
e.textContent?.trim(),
378+
);
379+
expect(numbers).toEqual(["02", "03"]);
380+
});
381+
382+
it("draws a preset's nodes as the one thing that was added", () => {
383+
// Applying a preset drops five rows into the rack with nothing saying they
384+
// arrived together — the same failure the carve module exists to fix, one
385+
// level down.
386+
const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
387+
click(byText(host, "button", "Presets"));
388+
click(presetButton(host, "telephone"));
389+
// Applying does not re-render this mount — the chain comes back as a prop —
390+
// so the rack is read from what was written.
391+
const applied = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain | undefined;
392+
const written = applied?.nodes ?? [];
393+
const { host: after } = mount({ chain: { version: 1, nodes: written } });
394+
395+
const run = after.querySelector("[data-fx-preset='telephone']");
396+
expect(run).toBeTruthy();
397+
expect(run?.querySelector(".hf-fx-preset-run-label")?.textContent).toBe("Telephone");
398+
expect(run?.querySelectorAll(".hf-fx-node")).toHaveLength(written.length);
399+
});
400+
401+
it("brackets only nodes a preset still sits next to", () => {
402+
// Pulled apart by a reorder, they are no longer a unit — and a bracket
403+
// around the gap would claim an adjacency the signal path does not have.
404+
const { host } = mount({
405+
chain: {
406+
version: 1,
407+
nodes: [
408+
{ type: "highpass", fromPreset: "telephone", params: defaultAudioFxParams("highpass") },
409+
{ type: "reverb", params: defaultAudioFxParams("reverb") },
410+
{ type: "lowpass", fromPreset: "telephone", params: defaultAudioFxParams("lowpass") },
411+
],
412+
} as unknown as HfAudioFxChain,
413+
});
414+
const runs = Array.from(host.querySelectorAll("[data-fx-preset='telephone']"));
415+
expect(runs).toHaveLength(2);
416+
for (const run of runs) expect(run.querySelectorAll(".hf-fx-node")).toHaveLength(1);
417+
});
418+
365419
it("letters each family differently, so the kind reads before the word does", () => {
366420
// A rack of eight modules is eight lines of text. Reading it should not mean
367421
// reading eight names — the shape of the line carries what KIND of module

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,41 @@ export function FxSection({
326326
[chain.nodes],
327327
);
328328

329+
/**
330+
* The hand-built list cut into runs, so a preset reads as one thing.
331+
*
332+
* Applying a preset drops five rows into the rack with nothing saying they
333+
* arrived together — which is the same failure the carve module was built to
334+
* fix, one level down. Consecutive only: a preset whose nodes have been pulled
335+
* apart by a reorder is no longer a unit, and drawing a bracket around the gap
336+
* would claim an adjacency the signal path does not have.
337+
*/
338+
const runs = useMemo(() => {
339+
const out: { preset?: string; items: { node: HfAudioFxNode; i: number }[] }[] = [];
340+
for (const item of handBuilt) {
341+
const preset = item.node.fromPreset;
342+
const last = out.at(-1);
343+
if (last && last.preset === preset) last.items.push(item);
344+
else out.push({ ...(preset ? { preset } : {}), items: [item] });
345+
}
346+
return out;
347+
}, [handBuilt]);
348+
329349
const eqIds = useMemo(() => audioEqIds(chain), [chain]);
350+
351+
/**
352+
* The number each row wears, counted over what the rack actually shows.
353+
*
354+
* Not the chain index: the carve's filters and an EQ's bands are inside their
355+
* own modules, so counting raw nodes would leave the visible rack jumping from
356+
* 02 to 07 and the numbers would look like a bug rather than a position.
357+
*/
358+
const positions = useMemo(() => {
359+
const map = new Map<number, number>();
360+
let at = (showCarve ? 1 : 0) + eqIds.length;
361+
for (const { i } of handBuilt) map.set(i, ++at);
362+
return map;
363+
}, [handBuilt, eqIds.length, showCarve]);
330364
const [openEq, setOpenEq] = useState<string | null>(null);
331365

332366
const addEq = useCallback(() => {
@@ -375,6 +409,13 @@ export function FxSection({
375409
return (
376410
<div className="hf-fx-section space-y-2">
377411
<div className="hf-fx-chain space-y-1">
412+
{/* The rack IS the signal path, and saying so costs two lines. Without
413+
them the order reads as a list, which is the one reading that makes
414+
"move up" look cosmetic — it is the most consequential control here. */}
415+
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
416+
<span className="hf-fx-term-cap text-panel-text-1">In</span>
417+
<span>this track</span>
418+
</p>
378419
{/* Carve leads the rack, which is also where its effects sit in the signal
379420
path — corrective work before anything the author added. Present
380421
whenever there is a voice for it to listen to, rather than appearing
@@ -413,8 +454,8 @@ export function FxSection({
413454
{showCarve ? "No other effects on this track." : "No effects on this track."}
414455
</p>
415456
) : (
416-
handBuilt.map(({ node, i }) => {
417-
return (
457+
runs.map((run) => {
458+
const rows = run.items.map(({ node, i }) => (
418459
<FxNodeRow
419460
// Keyed by id, as the carve module's list above already is.
420461
// On `${type}-${index}` two effects of the same type keep their
@@ -425,6 +466,7 @@ export function FxSection({
425466
key={node.id ?? `${node.type}-${i}`}
426467
node={node}
427468
index={i}
469+
position={positions.get(i)}
428470
automatedTargets={automatedTargets}
429471
liveAutomationValues={liveAutomationValues}
430472
onAutomateParam={onAutomateParam}
@@ -438,9 +480,27 @@ export function FxSection({
438480
onRemove={removeNode}
439481
onPreview={previewNode}
440482
/>
483+
));
484+
const preset = run.preset ? getAudioFxPreset(run.preset) : null;
485+
if (!preset) return rows;
486+
return (
487+
<div
488+
key={`preset-${run.preset}-${run.items[0]?.i}`}
489+
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-dashed border-panel-border-input p-1"
490+
data-fx-preset={run.preset}
491+
>
492+
<span className="hf-fx-preset-run-label block px-0.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
493+
{preset.label}
494+
</span>
495+
{rows}
496+
</div>
441497
);
442498
})
443499
)}
500+
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
501+
<span className="hf-fx-term-cap text-panel-text-1">Out</span>
502+
<span>to mix</span>
503+
</p>
444504
</div>
445505

446506
{adding ? (

0 commit comments

Comments
 (0)