Skip to content

Commit 1f663a2

Browse files
committed
feat(studio): switch a preset off, or take it out, as one thing
The bracket said a preset was one thing the author added, and then made them treat it as five: every member module had its own On / ↑ / ↓ / × and the preset itself had none. Switching off Telephone meant reaching into seven modules and toggling each — exactly the bookkeeping the bracket exists to remove. The run head now carries the two controls that belong to the whole: - **On/Off bypasses every node it wrote**, and leaves anything the author added themselves alone. A bypass, not a delete: the settings survive, which is what makes a preset worth trying rather than committing to. - **× takes it back out whole**, with its lanes. An orphaned lane keeps driving a parameter that is no longer in the graph, and with ids minted lowest-free the next effect added inherits it — the same contract removing a single node already has. It reads as On while *any* of it is still running. "Some of it is bypassed" is not a state an author set, it is one they arrived at by toggling a member, and the switch has to offer to stop the preset rather than claim it has already stopped. Also fixes the mount helper in the section tests, which never passed `onRemoveNodeAutomation` — so nothing in that file could have caught a lane leak on removal. studio 3700 passing, 18 todo.
1 parent 212ddf3 commit 1f663a2

2 files changed

Lines changed: 153 additions & 4 deletions

File tree

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

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
1111
import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
1212
import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
1313
import { audioFxProfileStrength } from "@hyperframes/core/audio-fx-profiles";
14-
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
14+
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
1515

1616
/**
1717
* What a knob is CALLED in the panel, looked up rather than spelled out.
@@ -87,6 +87,7 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
8787
automatedTargets={overrides.automatedTargets}
8888
onAutomateParam={overrides.onAutomateParam}
8989
onRemoveParamAutomation={overrides.onRemoveParamAutomation}
90+
onRemoveNodeAutomation={overrides.onRemoveNodeAutomation}
9091
onLevel={overrides.onLevel}
9192
onRemoveLevel={overrides.onRemoveLevel}
9293
levelled={overrides.levelled}
@@ -412,6 +413,90 @@ describe("FxSection chain", () => {
412413
expect(run?.querySelectorAll(".hf-fx-node")).toHaveLength(written.length);
413414
});
414415

416+
describe("a preset is one thing to switch off or take away", () => {
417+
/** A telephone preset applied, plus one hand-built effect beside it. */
418+
const applied = (): HfAudioFxChain => {
419+
const preset = getAudioFxPreset("telephone");
420+
if (!preset) throw new Error("no telephone preset");
421+
// Through the real applier: `fromPreset` is stamped there, not carried in
422+
// the catalogue, and the tag is the whole basis of the bracket.
423+
const withPreset = applyAudioFxPreset({ version: 1, nodes: [] }, preset);
424+
return {
425+
...withPreset,
426+
nodes: [
427+
...withPreset.nodes,
428+
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
429+
],
430+
};
431+
};
432+
433+
it("bypasses every node it wrote, in one gesture", () => {
434+
// Reaching into five modules and toggling each is exactly the bookkeeping
435+
// the bracket exists to remove.
436+
const { host, onChainChange } = mount({ chain: applied() });
437+
const run = host.querySelector("[data-fx-preset='telephone']");
438+
click(run?.querySelector(".hf-fx-preset-run-toggle"));
439+
440+
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
441+
expect(next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => !n.enabled)).toBe(
442+
true,
443+
);
444+
// And leaves what the author added themselves alone.
445+
expect(next.nodes.find((n) => n.id === "own")?.enabled).toBe(true);
446+
});
447+
448+
it("switches back on rather than deleting, so the settings survive", () => {
449+
const off = applied();
450+
off.nodes = off.nodes.map((n) =>
451+
n.fromPreset === "telephone" ? { ...n, enabled: false } : n,
452+
);
453+
const { host, onChainChange } = mount({ chain: off });
454+
const toggle = host
455+
.querySelector("[data-fx-preset='telephone']")
456+
?.querySelector(".hf-fx-preset-run-toggle");
457+
expect(toggle?.getAttribute("aria-pressed")).toBe("false");
458+
click(toggle);
459+
460+
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
461+
const back = next.nodes.filter((n) => n.fromPreset === "telephone");
462+
expect(back.every((n) => n.enabled)).toBe(true);
463+
// Nothing was thrown away.
464+
expect(back).toHaveLength(off.nodes.filter((n) => n.fromPreset === "telephone").length);
465+
});
466+
467+
it("reads as on while any of it is still running", () => {
468+
// "Some of it is bypassed" is not a state an author set — it is one they
469+
// arrived at, and the switch has to offer to stop it rather than claim it
470+
// has already stopped.
471+
const partial = applied();
472+
partial.nodes = partial.nodes.map((n, i) => (i === 0 ? { ...n, enabled: false } : n));
473+
const { host } = mount({ chain: partial });
474+
expect(
475+
host
476+
.querySelector("[data-fx-preset='telephone']")
477+
?.querySelector(".hf-fx-preset-run-toggle")
478+
?.getAttribute("aria-pressed"),
479+
).toBe("true");
480+
});
481+
482+
it("takes the preset back out whole, with its lanes", () => {
483+
const onRemoveNodeAutomation = vi.fn();
484+
const { host, onChainChange } = mount({ chain: applied(), onRemoveNodeAutomation });
485+
click(
486+
host
487+
.querySelector("[data-fx-preset='telephone']")
488+
?.querySelector(".hf-fx-preset-run-remove"),
489+
);
490+
491+
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
492+
expect(next.nodes.filter((n) => n.fromPreset === "telephone")).toEqual([]);
493+
expect(next.nodes.map((n) => n.id)).toEqual(["own"]);
494+
// An orphaned lane keeps driving a parameter that is no longer in the
495+
// graph, and the next effect added inherits it with the id.
496+
expect(onRemoveNodeAutomation).toHaveBeenCalled();
497+
});
498+
});
499+
415500
it("brackets only nodes a preset still sits next to", () => {
416501
// Pulled apart by a reorder, they are no longer a unit — and a bracket
417502
// around the gap would claim an adjacency the signal path does not have.

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

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,41 @@ export function FxSection({
314314
[chain.nodes, mutate],
315315
);
316316

317+
/**
318+
* Bypass or restore every node a preset wrote, as one gesture.
319+
*
320+
* A preset is one thing the author added, so it has to be one thing they can
321+
* switch off — reaching into five modules and toggling each is the bookkeeping
322+
* the bracket exists to remove. Off is a bypass, not a delete: the settings
323+
* survive, which is what makes it worth trying rather than committing to.
324+
*/
325+
const toggleRun = useCallback(
326+
(items: { node: HfAudioFxNode; i: number }[], on: boolean) => {
327+
const slots = new Set(items.map((item) => item.i));
328+
mutate(chain.nodes.map((n, i) => (slots.has(i) ? { ...n, enabled: on } : n)));
329+
},
330+
[chain.nodes, mutate],
331+
);
332+
333+
/**
334+
* Take a preset back out whole, lanes and all.
335+
*
336+
* Same contract as removing one node — an orphaned lane keeps driving a
337+
* parameter that is no longer in the graph, and with ids minted lowest-free
338+
* the next effect added inherits it.
339+
*/
340+
const removeRun = useCallback(
341+
(items: { node: HfAudioFxNode; i: number }[]) => {
342+
for (const { node } of items) {
343+
if (node.id) onRemoveNodeAutomation?.(node.id);
344+
}
345+
const slots = new Set(items.map((item) => item.i));
346+
mutate(chain.nodes.filter((_, i) => !slots.has(i)));
347+
setOpenNode(null);
348+
},
349+
[chain.nodes, mutate, onRemoveNodeAutomation],
350+
);
351+
317352
const removeNode = useCallback(
318353
(index: number) => {
319354
// The node's lanes go with it. `resolveAutomation` only hides an orphan at
@@ -501,15 +536,44 @@ export function FxSection({
501536
));
502537
const preset = run.preset ? getAudioFxPreset(run.preset) : null;
503538
if (!preset) return rows;
539+
// On unless every node in it is bypassed: one switched back on means
540+
// the preset is doing something, and the switch has to offer to stop
541+
// it rather than claiming it has already stopped.
542+
const runOn = run.items.some(({ node }) => node.enabled !== false);
504543
return (
505544
<div
506545
key={`preset-${run.preset}-${run.items[0]?.i}`}
507546
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-dashed border-panel-border-input p-1"
508547
data-fx-preset={run.preset}
509548
>
510-
<span className="hf-fx-preset-run-label block px-0.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
511-
{preset.label}
512-
</span>
549+
<div className="hf-fx-preset-run-head flex min-h-6 items-center gap-1 px-0.5">
550+
<span className="hf-fx-preset-run-label min-w-0 flex-1 truncate font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
551+
{preset.label}
552+
</span>
553+
{/* The whole preset, on or off. Partly-bypassed reads as off,
554+
because "some of it is running" is not a state an author
555+
set — it is one they arrived at, and the switch is how they
556+
get back out of it. */}
557+
<button
558+
type="button"
559+
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
560+
aria-pressed={runOn}
561+
title={runOn ? `Bypass ${preset.label}` : `Switch ${preset.label} back on`}
562+
disabled={disabled}
563+
onClick={() => toggleRun(run.items, !runOn)}
564+
>
565+
{runOn ? "On" : "Off"}
566+
</button>
567+
<button
568+
type="button"
569+
className="hf-fx-preset-run-remove px-1 font-mono text-[11px] text-panel-text-4 hover:text-red-400 disabled:opacity-40"
570+
title={`Remove ${preset.label}`}
571+
disabled={disabled}
572+
onClick={() => removeRun(run.items)}
573+
>
574+
&times;
575+
</button>
576+
</div>
513577
{rows}
514578
</div>
515579
);

0 commit comments

Comments
 (0)