From 2befe3b9522dc4ad8fef1f52ad1a22846a7e4e7b Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 07:19:37 -0700
Subject: [PATCH 01/21] fix(studio): call an effect the same thing in the menu
that adds it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rack was renamed and the add menu was not, so the author picked
"High-pass" and a module called "Remove Rumble" appeared. That is the
exact confusion the plain-language layer exists to remove, reintroduced
one gesture upstream of it.
The menu now reads `EFFECT_COPY[…].title`, and the tooltip carries the
copy's own "what it is for" line with the registry name in brackets —
taught rather than withheld, which is rule 3 in
`plans/audio-fx-ux/README.md`.
Tests that clicked add-menu entries by their registry label now look the
name up, for the same reason the knob rows already do.
Falsified: putting `d.label` back fails three tests.
studio 3685 passing, 18 todo.
---
.../editor/propertyPanelAudioFxGroup.test.tsx | 4 +++-
.../editor/propertyPanelFxSection.test.tsx | 11 +++++++----
.../src/components/editor/propertyPanelFxSection.tsx | 12 ++++++++++--
3 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
index 509a0b642c..2734d438d8 100644
--- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
@@ -456,7 +456,9 @@ describe("AudioFxGroup dynamic carve", () => {
act(() => byTextButton(host, "Even Out Levels")?.focus());
// Straight to a neighbour, without ever leaving the shelf.
act(() =>
- byTextButton(host, "Reverb")?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })),
+ byTextButton(host, EFFECT_COPY.reverb?.title ?? "")?.dispatchEvent(
+ new MouseEvent("mouseover", { bubbles: true }),
+ ),
);
await settleDecode(release, decoded);
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index c012466cd8..0113cff91c 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -151,13 +151,16 @@ describe("FxSection chain", () => {
e.textContent?.trim(),
);
expect(items).toHaveLength(HF_AUDIO_FX.length);
- for (const def of HF_AUDIO_FX) expect(items).toContain(def.label);
+ // By the name the RACK will use, not the registry's — picking "High-pass"
+ // and getting a module called "Remove Rumble" is the inconsistency this
+ // whole layer exists to remove.
+ for (const def of HF_AUDIO_FX) expect(items).toContain(EFFECT_COPY[def.id]?.title);
});
it("adds an effect seeded with its declared defaults", () => {
const { host, onChainChange } = mount();
click(host.querySelector(".hf-fx-add"));
- click(byText(host, ".hf-fx-add-item", "Compressor"));
+ click(byText(host, ".hf-fx-add-item", EFFECT_COPY.compressor?.title ?? ""));
expect(onChainChange).toHaveBeenCalledTimes(1);
const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
expect(next.nodes).toHaveLength(1);
@@ -391,7 +394,7 @@ describe("FxSection chain", () => {
it("auditions an effect the add menu is offering", () => {
const { host, onChainPreview, onChainChange } = mount({ chain: chainOf("peaking") });
click(byText(host, "button", "Add effect"));
- enter(byText(host, "button", "Reverb"));
+ enter(byText(host, "button", EFFECT_COPY.reverb?.title ?? ""));
const heard = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
expect(heard.nodes.map((n) => n.type)).toEqual(["peaking", "reverb"]);
@@ -1046,7 +1049,7 @@ describe("automation in the panel", () => {
const add = host.querySelector(".hf-fx-add") as HTMLButtonElement;
act(() => add.click());
const item = Array.from(host.querySelectorAll(".hf-fx-add-item")).find(
- (b) => b.textContent === "Low-pass",
+ (b) => b.textContent === EFFECT_COPY.lowpass?.title,
)!;
act(() => item.click());
expect(onChainChange.mock.calls[0][0].nodes[0].id).toBe("n1");
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index d632847618..1d282e089f 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -24,6 +24,7 @@ import {
removeAudioEq,
setAudioEqBandGain,
} from "@hyperframes/core/audio-fx-eq";
+import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -484,7 +485,14 @@ export function FxSection({
key={d.id}
type="button"
className="hf-fx-add-item rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
- title={d.description}
+ // The menu that adds it has to call it what the rack will call
+ // it, or the author picks "High-pass" and a module named
+ // "Remove Rumble" appears. The registry's own description
+ // stays as the tooltip beside the plain one: the mechanism is
+ // taught here rather than withheld.
+ title={
+ EFFECT_COPY[d.id] ? `${EFFECT_COPY[d.id]?.does} (${d.label})` : d.description
+ }
onClick={() => addEffect(d.id)}
// Cancels the levelling audition as well as starting its own.
// The shelf's leave handler only fires on the way OUT of the
@@ -498,7 +506,7 @@ export function FxSection({
}}
onFocus={() => audition((base) => withEffect(base, d.id))}
>
- {d.label}
+ {EFFECT_COPY[d.id]?.title ?? d.label}
))}
From 8281023170d878382d54ea36c19910b0c60b4bb8 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 07:45:42 -0700
Subject: [PATCH 02/21] =?UTF-8?q?feat:=20offer=20the=20job,=20not=20the=20?=
=?UTF-8?q?machine=20=E2=80=94=20the=20range=20IS=20the=20module?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Shape One Range" has three controls and no honest way to nominate one of
them as the knob that matters. The copy nominated *how much*, which is
incoherent: boosting an unspecified frequency means nothing. The range is
the first decision, not the second — `plans/audio-fx-ux/README.md`
§"The hole in the single-knob rule", where B is named as the answer.
So the add menu offers the decision instead of the machine. Five jobs —
Tame Boominess, Reduce Mud, Reduce Boxiness, Add Clarity, Soften
Harshness — each a peaking filter with its frequency already chosen, low
to high, which is the order an author hears them in. Picking the module
is picking the range, so one knob is honest rather than a simplification
hiding the real choice. `peaking` is no longer offered as itself: two
doors to the same effect, one of them the incoherent one, is worse than
either alone.
Every job is one the preset catalogue already ships, at the settings it
ships it with — a test asserts that. The list names the vocabulary the
presets were written in rather than inventing a second one beside it, and
a job nobody's preset does would be a guess about what authors want.
It needs no new node field. `label` already names a node for the work it
does, so a job node and a preset node are indistinguishable afterwards —
which is right, because they are the same idea. It also dissolves the
duplicate-name problem at the root rather than papering it: Clean Voice
reads Remove Rumble · Reduce Mud · Even Out Loudness · Add Clarity · Peak
Ceiling, and nothing repeats.
Underneath it is an ordinary peaking node. Details opens on the same
three controls it always had, and the frequency is a starting point
rather than a cage.
Falsified: offering `peaking` alongside its jobs fails the menu test.
Note for the next session: adding a core subpath needs `bun run build` in
packages/core, not just the subpath sync. Studio's PropertyPanel tests
resolve `@hyperframes/core/*` through the `node` condition, which points
at `dist/` — so a new module resolves in the FX tests and fails in those
with a "does the file exist?" that looks like a config error.
core 1750 passing (112 files), studio 3687 passing / 18 todo.
---
packages/core/package-subpaths.json | 6 +
packages/core/package.json | 10 ++
packages/core/src/audioFxJobs.test.ts | 63 ++++++++++
packages/core/src/audioFxJobs.ts | 117 ++++++++++++++++++
.../editor/propertyPanelFxSection.test.tsx | 59 ++++++++-
.../editor/propertyPanelFxSection.tsx | 61 ++++++++-
6 files changed, 309 insertions(+), 7 deletions(-)
create mode 100644 packages/core/src/audioFxJobs.test.ts
create mode 100644 packages/core/src/audioFxJobs.ts
diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json
index 3422498b1d..013027dfc4 100644
--- a/packages/core/package-subpaths.json
+++ b/packages/core/package-subpaths.json
@@ -98,6 +98,12 @@
"types": "./dist/audioFxCopy.d.ts",
"environments": ["browser", "bun", "node"]
},
+ "./audio-fx-jobs": {
+ "source": "./src/audioFxJobs.ts",
+ "runtime": "./dist/audioFxJobs.js",
+ "types": "./dist/audioFxJobs.d.ts",
+ "environments": ["browser", "bun", "node"]
+ },
"./audio-fx-eq": {
"source": "./src/audioFxEq.ts",
"runtime": "./dist/audioFxEq.js",
diff --git a/packages/core/package.json b/packages/core/package.json
index b95101cde3..02fd67f575 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -112,6 +112,12 @@
"import": "./src/audioFxCopy.ts",
"types": "./src/audioFxCopy.ts"
},
+ "./audio-fx-jobs": {
+ "bun": "./src/audioFxJobs.ts",
+ "node": "./dist/audioFxJobs.js",
+ "import": "./src/audioFxJobs.ts",
+ "types": "./src/audioFxJobs.ts"
+ },
"./audio-fx-eq": {
"bun": "./src/audioFxEq.ts",
"node": "./dist/audioFxEq.js",
@@ -420,6 +426,10 @@
"import": "./dist/audioFxCopy.js",
"types": "./dist/audioFxCopy.d.ts"
},
+ "./audio-fx-jobs": {
+ "import": "./dist/audioFxJobs.js",
+ "types": "./dist/audioFxJobs.d.ts"
+ },
"./audio-fx-eq": {
"import": "./dist/audioFxEq.js",
"types": "./dist/audioFxEq.d.ts"
diff --git a/packages/core/src/audioFxJobs.test.ts b/packages/core/src/audioFxJobs.test.ts
new file mode 100644
index 0000000000..4b31cc05d0
--- /dev/null
+++ b/packages/core/src/audioFxJobs.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { getAudioFxDef } from "./audioFx.js";
+import { HF_AUDIO_FX_PRESETS } from "./audioFxPresets.js";
+import { audioFxJobNode, getAudioFxJob, HF_AUDIO_FX_JOBS } from "./audioFxJobs.js";
+
+const EMPTY = { version: 1 as const, nodes: [] };
+
+describe("named jobs", () => {
+ it("makes an ordinary effect node, named for the work", () => {
+ const job = getAudioFxJob("reduce-mud");
+ if (!job) throw new Error("no such job");
+ const node = audioFxJobNode(job, EMPTY);
+ // Ordinary underneath: the author can open it and find the filter they could
+ // have added by hand, at the frequency the job picked for them.
+ expect(node.type).toBe("peaking");
+ expect(node.params?.frequency).toBe(250);
+ expect(node.label).toBe("Reduce Mud");
+ expect(node.enabled).toBe(true);
+ expect(node.id).toBeTruthy();
+ });
+
+ it("gives every job a value for every parameter its effect declares", () => {
+ // A job names a range and leaves the rest alone, so the omitted parameters
+ // have to come from the registry — a node missing `q` renders at whatever
+ // the graph builder falls back to rather than what the panel shows.
+ for (const job of HF_AUDIO_FX_JOBS) {
+ const def = getAudioFxDef(job.type);
+ expect(def, `${job.id} names an effect that does not exist`).toBeDefined();
+ const node = audioFxJobNode(job, EMPTY);
+ for (const param of def?.params ?? []) {
+ expect(node.params?.[param.key], `${job.id} has no ${param.key}`).toBeDefined();
+ }
+ }
+ });
+
+ it("mints an id that does not collide with what is already there", () => {
+ const job = getAudioFxJob("add-clarity");
+ if (!job) throw new Error("no such job");
+ const first = audioFxJobNode(job, EMPTY);
+ const second = audioFxJobNode(job, { version: 1, nodes: [first] });
+ // Two of the same job is a real thing to want, and a shared id would give
+ // them each other's automation lanes.
+ expect(second.id).not.toBe(first.id);
+ });
+
+ it("names jobs the preset catalogue already ships", () => {
+ // The point of the list is to name the vocabulary the presets were written
+ // in, not to invent a second one beside it. A job nobody's preset uses is a
+ // guess about what authors want; one a preset uses has already been chosen.
+ const shipped = new Set(
+ HF_AUDIO_FX_PRESETS.flatMap((preset) => preset.nodes.map((node) => node.label)),
+ );
+ for (const job of HF_AUDIO_FX_JOBS) {
+ expect(shipped, `${job.label} is not a job any preset does`).toContain(job.label);
+ }
+ });
+
+ it("has an id for every job and no duplicates", () => {
+ const ids = HF_AUDIO_FX_JOBS.map((job) => job.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const id of ids) expect(getAudioFxJob(id)?.id).toBe(id);
+ });
+});
diff --git a/packages/core/src/audioFxJobs.ts b/packages/core/src/audioFxJobs.ts
new file mode 100644
index 0000000000..fe619c53a9
--- /dev/null
+++ b/packages/core/src/audioFxJobs.ts
@@ -0,0 +1,117 @@
+/**
+ * Named jobs: the range IS the module.
+ *
+ * "Shape One Range" has three controls — where, how much, how wide — and no
+ * honest way to nominate one of them as the knob that matters. Nominating *how
+ * much* is incoherent, because boosting an unspecified frequency means nothing:
+ * the range is the first decision, not the second.
+ *
+ * So the menu offers the decision instead of the machine. Each job is a peaking
+ * filter with its frequency already chosen — Reduce Mud, Add Clarity, Soften
+ * Harshness — and picking the module IS picking the range, which makes one knob
+ * honest rather than a simplification hiding the real choice. It also dissolves
+ * the duplicate-name problem at the root: a preset that cuts mud and then lifts
+ * clarity used to read "Shape One Range" twice with nothing to tell them apart.
+ *
+ * Every job here is one the preset catalogue already ships, at the settings it
+ * ships them with, so this names the vocabulary the presets were written in
+ * rather than inventing a second one. Reasoning in
+ * `plans/audio-fx-ux/README.md` §"The hole in the single-knob rule".
+ *
+ * The frequency is a starting point, not a cage — it is an ordinary peaking node
+ * underneath, and Details opens on the same three controls it always had.
+ */
+
+import {
+ defaultAudioFxParams,
+ mintAudioFxNodeId,
+ normalizeAudioFxParams,
+ type HfAudioFxChain,
+ type HfAudioFxNode,
+ type HfAudioFxParamValues,
+} from "./audioFx.js";
+
+export interface HfAudioFxJob {
+ id: string;
+ /** What the rack calls the node this makes. */
+ label: string;
+ /** The complaint that leads here, in the author's words. */
+ does: string;
+ /** The registry effect underneath. */
+ type: string;
+ /** Where it acts, and how hard — the decision the author is spared. */
+ params: HfAudioFxParamValues;
+}
+
+/**
+ * Ordered low to high, which is the order an author hears them in: weight and
+ * mud at the bottom, clarity and harshness at the top.
+ */
+export const HF_AUDIO_FX_JOBS: readonly HfAudioFxJob[] = [
+ {
+ id: "tame-boominess",
+ label: "Tame Boominess",
+ does: "Too much chest — it booms.",
+ type: "peaking",
+ params: { frequency: 200, gain: -4, q: 1.4 },
+ },
+ {
+ id: "reduce-mud",
+ label: "Reduce Mud",
+ does: "Muffled, like it is behind cardboard.",
+ type: "peaking",
+ params: { frequency: 250, gain: -3, q: 1.2 },
+ },
+ {
+ id: "reduce-boxiness",
+ label: "Reduce Boxiness",
+ does: "Sounds like a small room, or a box.",
+ type: "peaking",
+ params: { frequency: 400, gain: -3, q: 1.4 },
+ },
+ {
+ id: "add-clarity",
+ label: "Add Clarity",
+ does: "Words are hard to make out.",
+ type: "peaking",
+ params: { frequency: 3000, gain: 2.5, q: 1 },
+ },
+ {
+ id: "soften-harshness",
+ label: "Soften Harshness",
+ does: "Harsh and tiring to listen to.",
+ type: "peaking",
+ params: { frequency: 3200, gain: -3, q: 1.6 },
+ },
+];
+
+export function getAudioFxJob(id: string): HfAudioFxJob | undefined {
+ return HF_AUDIO_FX_JOBS.find((job) => job.id === id);
+}
+
+/** Effect ids the job list covers, which the add menu offers as jobs instead. */
+export const HF_AUDIO_FX_JOB_TYPES: ReadonlySet = new Set(
+ HF_AUDIO_FX_JOBS.map((job) => job.type),
+);
+
+/**
+ * The node a job adds: an ordinary effect carrying the job's name.
+ *
+ * `label` is the field a preset already uses to name a node for the work it
+ * does, so a job needs nothing new — and a job node and a preset node are
+ * indistinguishable afterwards, which is right. They are the same idea.
+ */
+export function audioFxJobNode(job: HfAudioFxJob, chain: HfAudioFxChain): HfAudioFxNode {
+ return {
+ type: job.type,
+ id: mintAudioFxNodeId(chain),
+ enabled: true,
+ label: job.label,
+ // Through the registry, so a job cannot ship a value the effect would clamp
+ // or a key it does not have.
+ params: normalizeAudioFxParams(job.type, {
+ ...defaultAudioFxParams(job.type),
+ ...job.params,
+ }),
+ };
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index 0113cff91c..d96db6c8ff 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -9,6 +9,7 @@ import {
} from "@hyperframes/core/audio-fx";
import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
import { EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
+import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
/**
@@ -150,11 +151,59 @@ describe("FxSection chain", () => {
const items = Array.from(host.querySelectorAll(".hf-fx-add-item")).map((e) =>
e.textContent?.trim(),
);
- expect(items).toHaveLength(HF_AUDIO_FX.length);
- // By the name the RACK will use, not the registry's — picking "High-pass"
- // and getting a module called "Remove Rumble" is the inconsistency this
- // whole layer exists to remove.
- for (const def of HF_AUDIO_FX) expect(items).toContain(EFFECT_COPY[def.id]?.title);
+ // Every effect is reachable, but not every one under its own name: the jobs
+ // stand in for the effect they are made of, because picking `peaking` is
+ // picking a machine and leaving the real decision for afterwards.
+ const standIns = HF_AUDIO_FX.filter((d) => HF_AUDIO_FX_JOB_TYPES.has(d.id));
+ expect(items).toHaveLength(HF_AUDIO_FX.length - standIns.length + HF_AUDIO_FX_JOBS.length);
+ for (const def of HF_AUDIO_FX) {
+ if (HF_AUDIO_FX_JOB_TYPES.has(def.id)) {
+ // Not offered as itself, and it must not be — two doors to the same
+ // effect, one of them the incoherent one, is worse than either alone.
+ expect(items).not.toContain(EFFECT_COPY[def.id]?.title);
+ continue;
+ }
+ // By the name the RACK will use, not the registry's — picking "High-pass"
+ // and getting a module called "Remove Rumble" is the inconsistency this
+ // whole layer exists to remove.
+ expect(items).toContain(EFFECT_COPY[def.id]?.title);
+ }
+ for (const job of HF_AUDIO_FX_JOBS) expect(items).toContain(job.label);
+ });
+
+ it("adds a job as an ordinary effect, already named and already aimed", () => {
+ // The range IS the module: one knob is honest here because the decision the
+ // knob depends on has already been made.
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
+ click(host.querySelector(".hf-fx-add"));
+ click(byText(host, ".hf-fx-add-item", "Reduce Mud"));
+
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
+ expect(next.nodes).toHaveLength(1);
+ expect(next.nodes[0]?.type).toBe("peaking");
+ expect(next.nodes[0]?.label).toBe("Reduce Mud");
+ expect(next.nodes[0]?.params?.frequency).toBe(250);
+ });
+
+ it("shows two jobs over the same effect as the different things they are", () => {
+ // The whole point. Read down the rack, "Shape One Range" twice was two rows
+ // an author could not tell apart — and one of them was cutting while the
+ // other was boosting.
+ const { host } = mount({
+ chain: {
+ version: 1,
+ nodes: [
+ { type: "peaking", label: "Reduce Mud", params: { frequency: 250, gain: -3, q: 1.2 } },
+ { type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 2.5, q: 1 } },
+ ],
+ } as unknown as HfAudioFxChain,
+ });
+ const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) =>
+ e.textContent?.trim(),
+ );
+ expect(names).toContain("Reduce Mud");
+ expect(names).toContain("Add Clarity");
+ expect(names).not.toContain(EFFECT_COPY.peaking?.title);
});
it("adds an effect seeded with its declared defaults", () => {
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 1d282e089f..76387a9a1b 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
defaultAudioFxParams,
+ getAudioFxDef,
HF_AUDIO_FX,
mintAudioFxNodeId,
type HfAudioFxChain,
@@ -25,6 +26,12 @@ import {
setAudioEqBandGain,
} from "@hyperframes/core/audio-fx-eq";
import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
+import {
+ audioFxJobNode,
+ HF_AUDIO_FX_JOBS,
+ HF_AUDIO_FX_JOB_TYPES,
+ type HfAudioFxJob,
+} from "@hyperframes/core/audio-fx-jobs";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -133,8 +140,20 @@ export function FxSection({
const [picking, setPicking] = useState(false);
const [openNode, setOpenNode] = useState(0);
+ /**
+ * The add menu, with the jobs standing in for the effect they are made of.
+ *
+ * `peaking` is not offered as itself: picking it is picking a machine and
+ * leaving the real decision — which range — for afterwards. The jobs are that
+ * decision, already made. See `audioFxJobs.ts`.
+ */
const grouped = useMemo(
- () => GROUP_ORDER.map((g) => ({ group: g, defs: HF_AUDIO_FX.filter((d) => d.group === g) })),
+ () =>
+ GROUP_ORDER.map((g) => ({
+ group: g,
+ defs: HF_AUDIO_FX.filter((d) => d.group === g && !HF_AUDIO_FX_JOB_TYPES.has(d.id)),
+ jobs: HF_AUDIO_FX_JOBS.filter((job) => getAudioFxDef(job.type)?.group === g),
+ })),
[],
);
@@ -242,6 +261,25 @@ export function FxSection({
[],
);
+ /** The same, for a job — an ordinary node that arrives already named and aimed. */
+ const withJob = useCallback(
+ (base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain => ({
+ ...base,
+ nodes: [...base.nodes, audioFxJobNode(job, base)],
+ }),
+ [],
+ );
+
+ const addJob = useCallback(
+ (job: HfAudioFxJob) => {
+ auditionBase.current = null;
+ mutate(withJob(chain, job).nodes);
+ setOpenNode(chain.nodes.length);
+ setAdding(false);
+ },
+ [chain, mutate, withJob],
+ );
+
const addEffect = useCallback(
(type: string) => {
auditionBase.current = null;
@@ -475,11 +513,30 @@ export function FxSection({
Tone (EQ)
- {grouped.map(({ group, defs }) => (
+ {grouped.map(({ group, defs, jobs }) => (
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index d96db6c8ff..421b2e2a11 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -102,6 +102,17 @@ const click = (el: Element | null | undefined) => {
(el as HTMLElement).click();
});
};
+/**
+ * Open a module's Details, where every control that is not the primary one now
+ * lives — a module opens on one knob and the rest is one click away.
+ */
+function openDetails(host: HTMLElement, index = 0): void {
+ const buttons = Array.from(host.querySelectorAll(".hf-fx-node-details"));
+ const button = buttons[index];
+ if (!button) throw new Error("no Details disclosure to open");
+ act(() => button.click());
+}
+
const byText = (host: HTMLElement, sel: string, text: string) =>
Array.from(host.querySelectorAll(sel)).find((e) => e.textContent?.trim() === text);
@@ -284,6 +295,9 @@ describe("FxSection chain", () => {
});
render(chainOfNodes(a, b));
+ // Frequency is behind Details for a peaking node — the module opens on how
+ // much, now that picking the module is what picks the range.
+ openDetails(host);
// Only the first card is open, which is the one being edited.
const openFrequency = (): HTMLInputElement =>
host.querySelector(".hf-fx-node .hf-fx-number")!;
@@ -295,6 +309,9 @@ describe("FxSection chain", () => {
// The author moves that effect down; the other one takes the open slot.
render(chainOfNodes(b, a));
+ // Its own Details, not the one that was open: the disclosure is per module,
+ // so the effect arriving in the slot arrives closed like any other.
+ openDetails(host);
expect(openFrequency().value).toBe("1600");
});
@@ -331,9 +348,31 @@ describe("FxSection chain", () => {
expect(name).not.toBe(getAudioFxDef("highpass")?.label);
// And a sentence under it, so the rack reads top to bottom.
expect(node.querySelector(".hf-fx-node-summary")?.textContent).toContain("Cutting everything");
- // The first node is open by default, which is where the DSP name lives.
- expect(node.querySelector(".hf-fx-node-mechanism")?.textContent).toContain(
- getAudioFxDef("highpass")?.label,
+ // Open, it says what it is for and offers ONE knob — the rest is behind a
+ // disclosure, which is also the only place the DSP name appears.
+ expect(node.querySelector(".hf-fx-node-does")?.textContent).toBe(EFFECT_COPY.highpass?.does);
+ expect(node.querySelectorAll(".hf-fx-row")).toHaveLength(1);
+ const details = node.querySelector(".hf-fx-node-details");
+ expect(details?.textContent).toContain(getAudioFxDef("highpass")?.label);
+ expect(details?.getAttribute("aria-expanded")).toBe("false");
+
+ openDetails(host);
+ expect(node.querySelectorAll(".hf-fx-row").length).toBe(
+ getAudioFxDef("highpass")?.params.length,
+ );
+ });
+
+ it("opens a module on all of its controls when its one knob does not exist yet", () => {
+ // Five effects want a single DERIVED control over several parameters — the
+ // `PROFILES` idea, whose figures are proposed rather than measured. Until it
+ // ships they open on everything, which is honest: inventing one knob for
+ // them now would be a knob that lies about what it sets.
+ const { host } = mount({ chain: chainOf("compressor") });
+ const node = fxCard(host);
+ expect(EFFECT_COPY.compressor?.primary).toBe("strength");
+ expect(node.querySelector(".hf-fx-node-details")).toBeNull();
+ expect(node.querySelectorAll(".hf-fx-row").length).toBe(
+ getAudioFxDef("compressor")?.params.length,
);
});
@@ -654,6 +693,9 @@ describe("FxSection chain", () => {
// Persisting on every input event refreshes the preview, which reloads the
// composition and restarts audio — that is what made playback stutter.
const { host, onChainChange, onChainPreview } = mount({ chain: chainOf("peaking") });
+ // Details, because this is about the drag mechanics on a real control and
+ // the frequency it asserts on is not the one knob the module opens with.
+ openDetails(host);
const slider = fxCard(host).querySelector(".hf-fx-slider")!;
act(() => slider.dispatchEvent(new Event("pointerdown", { bubbles: true })));
for (const v of ["5000", "10000", "15000"]) {
@@ -705,6 +747,7 @@ describe("FxSection chain", () => {
sourceOptions: [{ id: "vo", label: "Voiceover" }],
};
const { host, root } = renderInto();
+ openDetails(host);
const slider = fxCard(host).querySelector(".hf-fx-slider")!;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
act(() => slider.dispatchEvent(new Event("pointerdown", { bubbles: true })));
@@ -747,6 +790,7 @@ describe("FxSection chain", () => {
it("clamps a typed value into the renderable range", () => {
const { host, onChainChange } = mount({ chain: chainOf("peaking") });
+ openDetails(host);
const input = fxCard(host).querySelector(".hf-fx-number")!;
typeInto(input, "999999");
// React delegates onBlur through focusout, which is the event that bubbles.
@@ -1024,7 +1068,9 @@ describe("automation in the panel", () => {
expect(row.querySelector('input[type="range"]')?.disabled).toBe(true);
expect(row.querySelector('input[type="number"]')?.disabled).toBe(true);
expect(row.hasAttribute("data-automated")).toBe(true);
- // A sibling parameter on the same effect stays editable.
+ // A sibling parameter on the same effect stays editable — one click in,
+ // which is where every control that is not the primary one lives.
+ openDetails(host);
const q = rowFor(host, plainLabel("lowpass", "q"))!;
expect(q.querySelector('input[type="range"]')?.disabled).toBe(false);
});
From 13f574f3e64ab0dadd8304129f0857713cf29382 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 07:58:29 -0700
Subject: [PATCH 04/21] feat: the shared frequency ruler, so the vocabulary
gets taught
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`BANDS` has named the ranges since the copy layer landed — rumble,
weight, mud, middle, presence, edge, air — and nothing showed them. The
rack spoke entirely in Hz, which mean nothing to somebody who has not
been taught them, and the words that would teach them sat unread in core.
Every spectral module now shows where it acts on one ruler. Two things at
once, deliberately: the bar says where this module works relative to
everything else, and the caption names the range and what lives there.
A bar alone is decoration; a caption alone does not teach the shape.
Log-spaced, because hearing is. Rumble is 20–80 Hz — three tenths of one
percent of the range linearly and about a fifth of it by ear — so laid
out linearly the bottom six bands collapse into a sliver and the ruler
teaches nothing. A test asserts the bottom segment is over a tenth of the
bar, which a linear layout fails.
Segments outside what the module can reach are dimmed rather than hidden:
a filter that only works in the bottom three bands should not look like
it could move anywhere, and it should still be legible against the whole
range.
`audioBandAt` clamps past both ends rather than returning nothing — 15 Hz
is still rumble to anybody who can hear it, and the alternative is a
filter parked at the edge of its range having no name at all. Boundaries
belong to the band they open, or a filter at exactly 250 Hz reads as
Weight while the ruler beside it highlights Mud.
Nothing under an effect with no range to place: there is nothing spectral
about a limiter, and a bar under one would be a decoration claiming to be
information.
core 1754 passing (112 files), studio 3690 passing / 18 todo.
---
packages/core/src/audioFxCopy.test.ts | 32 ++++++++-
packages/core/src/audioFxCopy.ts | 18 +++++
.../editor/propertyPanelFxBandRuler.tsx | 72 +++++++++++++++++++
.../editor/propertyPanelFxNodeRow.tsx | 7 ++
.../editor/propertyPanelFxSection.test.tsx | 31 +++++++-
5 files changed, 158 insertions(+), 2 deletions(-)
create mode 100644 packages/studio/src/components/editor/propertyPanelFxBandRuler.tsx
diff --git a/packages/core/src/audioFxCopy.test.ts b/packages/core/src/audioFxCopy.test.ts
index 95c9d979f2..2cdf20f574 100644
--- a/packages/core/src/audioFxCopy.test.ts
+++ b/packages/core/src/audioFxCopy.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { defaultAudioFxParams, HF_AUDIO_FX } from "./audioFx.js";
import { HF_AUDIO_FX_PRESETS } from "./audioFxPresets.js";
-import { BANDS, EFFECT_COPY, PRESET_PROBLEM, SUMMARY } from "./audioFxCopy.js";
+import { audioBandAt, BANDS, EFFECT_COPY, PRESET_PROBLEM, SUMMARY } from "./audioFxCopy.js";
/**
* The copy layer is only worth having if it covers everything that ships. A gap
@@ -73,3 +73,33 @@ it("covers the spectrum without a gap or an overlap", () => {
expect(BANDS[i]?.from, `gap or overlap before ${BANDS[i]?.name}`).toBe(BANDS[i - 1]?.to);
}
});
+
+describe("audioBandAt", () => {
+ it("names the range a frequency sits in", () => {
+ expect(audioBandAt(50)?.name).toBe("Rumble");
+ expect(audioBandAt(250)?.name).toBe("Mud");
+ expect(audioBandAt(3000)?.name).toBe("Presence");
+ expect(audioBandAt(12000)?.name).toBe("Air");
+ });
+
+ it("puts a boundary in the band it opens, not the one it closes", () => {
+ // Off by one here means a filter at exactly 250 Hz reads as "Weight" while
+ // the ruler beside it highlights Mud.
+ for (let i = 1; i < BANDS.length; i++) {
+ const edge = BANDS[i]?.from;
+ if (edge === undefined) continue;
+ expect(audioBandAt(edge)?.name).toBe(BANDS[i]?.name);
+ }
+ });
+
+ it("clamps past both ends rather than going nameless", () => {
+ // A filter parked at the edge of its range still has to say where it works.
+ expect(audioBandAt(5)?.name).toBe(BANDS[0]?.name);
+ expect(audioBandAt(30000)?.name).toBe(BANDS.at(-1)?.name);
+ expect(audioBandAt(20000)?.name).toBe(BANDS.at(-1)?.name);
+ });
+
+ it("has no answer for a value that is not a frequency", () => {
+ expect(audioBandAt(Number.NaN)).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/audioFxCopy.ts b/packages/core/src/audioFxCopy.ts
index 2dbcd3f4c8..f040a4b2c6 100644
--- a/packages/core/src/audioFxCopy.ts
+++ b/packages/core/src/audioFxCopy.ts
@@ -294,6 +294,24 @@ export const BANDS: { from: number; to: number; name: string; says: string }[] =
{ from: 10000, to: 20000, name: "Air", says: "sparkle, openness" },
];
+/**
+ * Which named range a frequency falls in.
+ *
+ * The whole point of `BANDS` is that the words get taught, and they only get
+ * taught if a module can say which one it is working in. Below the first band
+ * and above the last both clamp rather than returning nothing: 15 Hz is still
+ * rumble to anybody who can hear it, and the alternative is a filter at the edge
+ * of its range having no name at all.
+ */
+export function audioBandAt(hz: number): (typeof BANDS)[number] | undefined {
+ if (!Number.isFinite(hz)) return undefined;
+ const first = BANDS[0];
+ const last = BANDS.at(-1);
+ if (first && hz < first.from) return first;
+ if (last && hz >= last.to) return last;
+ return BANDS.find((band) => hz >= band.from && hz < band.to);
+}
+
/** Which everyday complaint each preset answers. Presets ARE the product here. */
export const PRESET_PROBLEM: Record = {
"voice-clean": "My voice sounds amateur",
diff --git a/packages/studio/src/components/editor/propertyPanelFxBandRuler.tsx b/packages/studio/src/components/editor/propertyPanelFxBandRuler.tsx
new file mode 100644
index 0000000000..a5e569b429
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxBandRuler.tsx
@@ -0,0 +1,72 @@
+/**
+ * The shared frequency ruler.
+ *
+ * Frequencies mean nothing to somebody who has not been taught them, and the
+ * rack speaks entirely in them. `BANDS` names the ranges in the words the same
+ * person would use unprompted — rumble, weight, mud, middle, presence, edge,
+ * air — and every spectral module shows where it acts on this one ruler, so
+ * naming them once teaches them everywhere they appear.
+ *
+ * Two things at once, deliberately. The bar says where this module works
+ * relative to everything else, which is the spatial fact; the caption under it
+ * names the range and what lives there, which is the vocabulary. A bar alone
+ * would be decoration and a caption alone would not teach the shape.
+ *
+ * Log-spaced, because hearing is: 20–200 Hz is as much of the range to an ear as
+ * 2–20 kHz, and a linear ruler would crush six of the seven bands into a corner.
+ */
+
+import { audioBandAt, BANDS } from "@hyperframes/core/audio-fx-copy";
+
+const LOW = BANDS[0]?.from ?? 20;
+const HIGH = BANDS.at(-1)?.to ?? 20000;
+
+/** Where a frequency sits across the ruler, 0..1. */
+function positionOf(hz: number): number {
+ const span = Math.log10(HIGH) - Math.log10(LOW);
+ const at = (Math.log10(Math.min(HIGH, Math.max(LOW, hz))) - Math.log10(LOW)) / span;
+ return Math.min(1, Math.max(0, at));
+}
+
+export interface FxBandRulerProps {
+ /** The range this effect can act over, from its copy. */
+ band: readonly [number, number];
+ /** Where it is acting right now. */
+ at: number;
+}
+
+export function FxBandRuler({ band, at }: FxBandRulerProps) {
+ const here = audioBandAt(at);
+ if (!here) return null;
+ const [from, to] = band;
+ return (
+
+
+ {BANDS.map((range) => {
+ // Reachable at all, and where it is now: a module that can only work in
+ // the bottom three bands should not look like it could move anywhere.
+ const reachable = range.to > from && range.from < to;
+ return (
+
+ );
+ })}
+
+
+ {here.name} — {here.says}
+
+
+ );
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
index b62fb0ba23..04759f1d79 100644
--- a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
@@ -21,6 +21,7 @@ import {
import { EFFECT_COPY, SUMMARY } from "@hyperframes/core/audio-fx-copy";
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
import { FxParams } from "./propertyPanelFxControls.js";
+import { FxBandRuler } from "./propertyPanelFxBandRuler.js";
/**
* The one control that carries the module, if it has one.
@@ -347,6 +348,12 @@ export function FxNodeRow({
{copy.primaryEnds.high}
) : null}
+ {/* Where it is working, in the words the rack shares. Only for a
+ module that acts on a range at all — there is nothing spectral
+ about a limiter, and a ruler under one would be noise. */}
+ {copy?.band && typeof params.frequency === "number" ? (
+
+ ) : null}
>
) : null}
{/* The DSP name lives on the disclosure, so it is read at the moment
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index 421b2e2a11..d3a4ad4287 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -8,7 +8,7 @@ import {
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
-import { EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
+import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
@@ -362,6 +362,35 @@ describe("FxSection chain", () => {
);
});
+ it("says where a filter is working, in the words the rack shares", () => {
+ // Frequencies mean nothing to somebody who has not been taught them, and the
+ // rack speaks entirely in them. The ruler is where they get taught.
+ const { host } = mount({
+ chain: {
+ version: 1,
+ nodes: [{ type: "highpass", params: { frequency: 250, q: 0.707, poles: "2" } }],
+ } as unknown as HfAudioFxChain,
+ });
+ const ruler = fxCard(host).querySelector(".hf-fx-ruler");
+ expect(ruler?.getAttribute("data-band")).toBe("Mud");
+ expect(ruler?.querySelector(".hf-fx-ruler-name")?.textContent).toBe("Mud");
+ // Every named range is on the bar, or it is not a shared ruler.
+ const segments = Array.from(ruler?.querySelectorAll(".hf-fx-ruler-band") ?? []);
+ expect(segments).toHaveLength(BANDS.length);
+ // Log-spaced, because hearing is. Rumble is 20-80 Hz — three tenths of one
+ // percent of the range linearly, and a fifth of it by ear. Laid out linearly
+ // the bottom six bands collapse into a sliver and the ruler teaches nothing.
+ const rumble = Number.parseFloat(segments[0]?.style.width ?? "0");
+ expect(rumble).toBeGreaterThan(10);
+ });
+
+ it("puts no ruler under an effect that does not act on a range", () => {
+ // A limiter has no frequency to place, and a bar under one would be a
+ // decoration claiming to be information.
+ const { host } = mount({ chain: chainOf("limiter") });
+ expect(fxCard(host).querySelector(".hf-fx-ruler")).toBeNull();
+ });
+
it("opens a module on all of its controls when its one knob does not exist yet", () => {
// Five effects want a single DERIVED control over several parameters — the
// `PROFILES` idea, whose figures are proposed rather than measured. Until it
From a2ea0a66c3f2a178d92916a92d27119813c7aeb8 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 08:03:30 -0700
Subject: [PATCH 05/21] feat(studio): letter the families, and tint each module
inside its own
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A rack of eight modules is eight lines of text, and reading it top to
bottom should not mean reading eight names. Each family letters
differently, so an author knows what KIND of module they are looking at
with the label out of focus and the word only confirms it.
Two faces of type, as budgeted in `plans/audio-fx-ux/README.md`. The sans
carries four families apart by weight, case and tracking — filters light
and widely tracked like a measuring instrument, dynamics heavy and tight
because it grips the signal, time thin and very wide because it is
atmosphere rather than control. The serif is spent on the single family
that behaves differently from all of them: non-linear is the only one
that generates signal rather than measuring or shaping what is there, and
it should not look like the others.
`smart` is the fifth and is not a registry group — the carve, the Tone EQ
and the leveller, which measure the audio and write their own settings.
Monospace, because what they show IS a readout: numbers something else
decided.
Alongside it a tint step per module WITHIN its family, derived from
position in the registry rather than assigned by hand, so two filters
read as two different modules without reading as two different families
and adding an effect upstream never re-colours its siblings. It sits on
the card's left edge rather than on the text: the name already carries
the family in its lettering, and colouring it too would fight the panel's
own tokens for automated and bypassed. Kept to 62–76% lightness at low
saturation for the same reason.
Falsified: flattening the tint step fails one test, and lettering
non-linear like dynamics fails the other.
studio 3692 passing, 18 todo.
---
.../editor/propertyPanelFxCarveModule.tsx | 10 ++-
.../editor/propertyPanelFxEqModule.tsx | 9 +-
.../editor/propertyPanelFxFamily.ts | 83 +++++++++++++++++++
.../editor/propertyPanelFxNodeRow.tsx | 14 +++-
.../editor/propertyPanelFxSection.test.tsx | 29 +++++++
5 files changed, 139 insertions(+), 6 deletions(-)
create mode 100644 packages/studio/src/components/editor/propertyPanelFxFamily.ts
diff --git a/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx b/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx
index b2630f0068..d6ba3a7ddf 100644
--- a/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx
@@ -17,6 +17,7 @@ import {
import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
import { FxParamRow } from "./propertyPanelFxControls.js";
+import { FX_FAMILY_TYPE, fxFamilyTint } from "./propertyPanelFxFamily.js";
// Shared with the timeline's lane labels: a band is named by its frequency in
// both places, and two formatters would drift.
import { formatHz } from "../../player/components/automationLaneData";
@@ -201,16 +202,21 @@ export function FxCarveModule({
: "pick a voice";
return (
diff --git a/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx
index 36629bca22..1a4b283e00 100644
--- a/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxEqModule.tsx
@@ -13,6 +13,7 @@ import {
HF_AUDIO_EQ_RANGE_DB,
type HfAudioEqBand,
} from "@hyperframes/core/audio-fx-eq";
+import { FX_FAMILY_TYPE, fxFamilyTint } from "./propertyPanelFxFamily.js";
export interface FxEqModuleProps {
eqId: string;
@@ -137,13 +138,17 @@ export function FxEqModule({
return (
diff --git a/packages/studio/src/components/editor/propertyPanelFxFamily.ts b/packages/studio/src/components/editor/propertyPanelFxFamily.ts
new file mode 100644
index 0000000000..136618f45c
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxFamily.ts
@@ -0,0 +1,83 @@
+/**
+ * Telling module families apart before the word registers.
+ *
+ * A rack of eight modules is eight lines of text, and reading it top to bottom
+ * means reading eight names. Lettering each family differently means an author
+ * knows what KIND of module they are looking at with the label out of focus —
+ * the shape of the line carries it, and the word only confirms.
+ *
+ * Two faces of type, as budgeted in `plans/audio-fx-ux/README.md`: the sans
+ * carries four families apart by weight, case and tracking, and the serif is
+ * spent on the single family that behaves differently from all of them.
+ * Non-linear is the only generative one — it invents signal rather than
+ * measuring or shaping what is there — so it should not look like the others.
+ *
+ * Alongside it, a tint step per module WITHIN its family, derived from position
+ * in the registry rather than assigned by hand: two filters read as two
+ * different modules without reading as two different families, and adding an
+ * effect upstream never re-colours its siblings.
+ */
+
+import { HF_AUDIO_FX, type HfAudioFxNode } from "@hyperframes/core/audio-fx";
+
+export type FxFamily = "filter" | "dynamics" | "nonlinear" | "time" | "smart";
+
+/**
+ * How each family letters.
+ *
+ * `smart` is not a registry group. It is the measuring modules — the carve, the
+ * Tone EQ, the leveller — which analyse the audio and write their own settings.
+ * Monospace because that is what a readout looks like, and what they show IS a
+ * readout: numbers something else decided.
+ */
+export const FX_FAMILY_TYPE: Record = {
+ filter: "font-light uppercase tracking-[0.14em]",
+ dynamics: "font-bold uppercase tracking-tight",
+ nonlinear: "font-serif italic tracking-normal",
+ time: "font-extralight uppercase tracking-[0.24em]",
+ smart: "font-mono font-medium tracking-normal",
+};
+
+/** Hue per family, and a lightness ramp across the modules inside it. */
+const FAMILY_HUE: Record = {
+ filter: 205,
+ dynamics: 25,
+ nonlinear: 310,
+ time: 165,
+ smart: 95,
+};
+
+/**
+ * Which family a chain node belongs to.
+ *
+ * The composite tags win over the registry group, because what a node IS to the
+ * author is the module that owns it: a carve's peaking filters are not filters
+ * the author added, they are one carve.
+ */
+export function fxFamilyOf(
+ node: Pick,
+): FxFamily {
+ if (node.fromCarve || node.fromEq || node.fromLeveller) return "smart";
+ const group = HF_AUDIO_FX.find((def) => def.id === node.type)?.group;
+ return group === "dynamics" || group === "nonlinear" || group === "time" ? group : "filter";
+}
+
+/**
+ * The module's own tint: its family's hue, stepped by where it sits among its
+ * siblings in the registry.
+ *
+ * Kept narrow on purpose — 62% to 76% lightness at low saturation. This has to
+ * separate two modules at a glance without reading as a status colour, and the
+ * panel already spends saturation on "automated" and "bypassed".
+ */
+export function fxFamilyTint(
+ node: Pick,
+): string {
+ const family = fxFamilyOf(node);
+ const siblings = HF_AUDIO_FX.filter((def) => fxFamilyOf({ type: def.id }) === family);
+ const index = siblings.findIndex((def) => def.id === node.type);
+ const step = siblings.length > 1 ? Math.max(0, index) / (siblings.length - 1) : 0;
+ // Comma syntax rather than the space-separated modern form: it is what every
+ // CSS parser in the chain agrees on, including the one the tests run in.
+ return `hsl(${FAMILY_HUE[family]}, 32%, ${(62 + step * 14).toFixed(1)}%)`;
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
index 04759f1d79..3c032a4bd9 100644
--- a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
@@ -22,6 +22,7 @@ import { EFFECT_COPY, SUMMARY } from "@hyperframes/core/audio-fx-copy";
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
import { FxParams } from "./propertyPanelFxControls.js";
import { FxBandRuler } from "./propertyPanelFxBandRuler.js";
+import { FX_FAMILY_TYPE, fxFamilyOf, fxFamilyTint } from "./propertyPanelFxFamily.js";
/**
* The one control that carries the module, if it has one.
@@ -115,6 +116,7 @@ function FxMoveButton({
/** Name, bypass, reorder and remove for one effect. */
function FxNodeHeader({
label,
+ family,
open,
bypassed,
first,
@@ -126,6 +128,8 @@ function FxNodeHeader({
onRemove,
}: {
label: string;
+ /** How this family letters, so the KIND reads before the word does. */
+ family: string;
open: boolean;
bypassed: boolean;
first: boolean;
@@ -140,7 +144,7 @@ function FxNodeHeader({
{
);
});
+ it("letters each family differently, so the kind reads before the word does", () => {
+ // A rack of eight modules is eight lines of text. Reading it should not mean
+ // reading eight names — the shape of the line carries what KIND of module
+ // this is, and the word only confirms it.
+ const { host } = mount({ chain: chainOf("lowpass", "compressor", "saturate", "delay") });
+ const families = Array.from(host.querySelectorAll("[data-fx-family]")).map((e) =>
+ e.getAttribute("data-fx-family"),
+ );
+ // The carve module leads the rack and is smart; then the four registry ones.
+ expect(families).toEqual(["smart", "filter", "dynamics", "nonlinear", "time"]);
+
+ const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) => e.className);
+ // Four families told apart by the sans, and the serif spent on the one that
+ // generates signal rather than measuring or shaping what is there.
+ expect(names.filter((c) => c.includes("font-serif"))).toHaveLength(1);
+ expect(names[3]).toContain("font-serif");
+ expect(new Set(names.map((c) => c.replace(/^.*?(?=font-)/, "")))).toHaveProperty("size", 5);
+ });
+
+ it("tints two modules of the same family apart without changing family", () => {
+ const { host } = mount({ chain: chainOf("lowpass", "highpass") });
+ const cards = Array.from(host.querySelectorAll("[data-fx-family='filter']"));
+ expect(cards).toHaveLength(2);
+ // Same hue, different step: two filters, visibly two modules.
+ const tints = cards.map((c) => c.style.borderLeftColor);
+ expect(tints[0]).not.toBe(tints[1]);
+ for (const tint of tints) expect(tint).toContain("205");
+ });
+
it("says where a filter is working, in the words the rack shares", () => {
// Frequencies mean nothing to somebody who has not been taught them, and the
// rack speaks entirely in them. The ruler is where they get taught.
From e477ec254b962c5037c3504e8c5d6a46cc53db1f Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 08:08:46 -0700
Subject: [PATCH 06/21] feat(studio): draw the rack as the signal path it is
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../editor/propertyPanelFxNodeRow.tsx | 14 ++++
.../editor/propertyPanelFxSection.test.tsx | 54 ++++++++++++++++
.../editor/propertyPanelFxSection.tsx | 64 ++++++++++++++++++-
3 files changed, 130 insertions(+), 2 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
index 3c032a4bd9..b7d7e7515b 100644
--- a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
@@ -73,6 +73,8 @@ function plainDef(def: HfAudioFxDef): HfAudioFxDef {
interface FxNodeRowProps {
node: HfAudioFxNode;
index: number;
+ /** Where it sits in the signal path, as the rack counts it. Absent means unnumbered. */
+ position?: number;
automatedTargets?: ReadonlySet;
liveAutomationValues?: ReadonlyMap;
onAutomateParam?(nodeId: string, paramKey: string): void;
@@ -117,6 +119,7 @@ function FxMoveButton({
function FxNodeHeader({
label,
family,
+ position,
open,
bypassed,
first,
@@ -130,6 +133,7 @@ function FxNodeHeader({
label: string;
/** How this family letters, so the KIND reads before the word does. */
family: string;
+ position?: number;
open: boolean;
bypassed: boolean;
first: boolean;
@@ -142,6 +146,14 @@ function FxNodeHeader({
}) {
return (
+ {/* Two digits, because a rack reads as a path when its steps are numbered
+ and as a list when they are not — and the difference decides whether an
+ author thinks the order matters. It does; it is audible. */}
+ {position !== undefined ? (
+
+ {String(position).padStart(2, "0")}
+
+ ) : null}
{
);
});
+ it("draws the rack as a signal path, with both ends named", () => {
+ // Order is audible here, and a list does not look ordered. Numbering the
+ // steps and naming the two ends is what makes "move up" read as the most
+ // consequential control in the panel rather than a cosmetic one.
+ const { host } = mount({ chain: chainOf("highpass", "limiter") });
+ const terms = Array.from(host.querySelectorAll(".hf-fx-term")).map((e) => e.textContent);
+ expect(terms).toHaveLength(2);
+ expect(terms[0]).toContain("In");
+ expect(terms[1]).toContain("Out");
+ // Counted over what the rack SHOWS: the carve module leads it, so the first
+ // hand-built effect is 02.
+ const numbers = Array.from(host.querySelectorAll(".hf-fx-node-index")).map((e) =>
+ e.textContent?.trim(),
+ );
+ expect(numbers).toEqual(["02", "03"]);
+ });
+
+ it("draws a preset's nodes as the one thing that was added", () => {
+ // Applying a preset drops five rows into the rack with nothing saying they
+ // arrived together — the same failure the carve module exists to fix, one
+ // level down.
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
+ click(byText(host, "button", "Presets"));
+ click(presetButton(host, "telephone"));
+ // Applying does not re-render this mount — the chain comes back as a prop —
+ // so the rack is read from what was written.
+ const applied = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain | undefined;
+ const written = applied?.nodes ?? [];
+ const { host: after } = mount({ chain: { version: 1, nodes: written } });
+
+ const run = after.querySelector("[data-fx-preset='telephone']");
+ expect(run).toBeTruthy();
+ expect(run?.querySelector(".hf-fx-preset-run-label")?.textContent).toBe("Telephone");
+ expect(run?.querySelectorAll(".hf-fx-node")).toHaveLength(written.length);
+ });
+
+ it("brackets only nodes a preset still sits next to", () => {
+ // Pulled apart by a reorder, they are no longer a unit — and a bracket
+ // around the gap would claim an adjacency the signal path does not have.
+ const { host } = mount({
+ chain: {
+ version: 1,
+ nodes: [
+ { type: "highpass", fromPreset: "telephone", params: defaultAudioFxParams("highpass") },
+ { type: "reverb", params: defaultAudioFxParams("reverb") },
+ { type: "lowpass", fromPreset: "telephone", params: defaultAudioFxParams("lowpass") },
+ ],
+ } as unknown as HfAudioFxChain,
+ });
+ const runs = Array.from(host.querySelectorAll("[data-fx-preset='telephone']"));
+ expect(runs).toHaveLength(2);
+ for (const run of runs) expect(run.querySelectorAll(".hf-fx-node")).toHaveLength(1);
+ });
+
it("letters each family differently, so the kind reads before the word does", () => {
// A rack of eight modules is eight lines of text. Reading it should not mean
// reading eight names — the shape of the line carries what KIND of module
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 76387a9a1b..6e4d647527 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -326,7 +326,41 @@ export function FxSection({
[chain.nodes],
);
+ /**
+ * The hand-built list cut into runs, so a preset reads as one thing.
+ *
+ * Applying a preset drops five rows into the rack with nothing saying they
+ * arrived together — which is the same failure the carve module was built to
+ * fix, one level down. Consecutive only: a preset whose nodes have been pulled
+ * apart by a reorder is no longer a unit, and drawing a bracket around the gap
+ * would claim an adjacency the signal path does not have.
+ */
+ const runs = useMemo(() => {
+ const out: { preset?: string; items: { node: HfAudioFxNode; i: number }[] }[] = [];
+ for (const item of handBuilt) {
+ const preset = item.node.fromPreset;
+ const last = out.at(-1);
+ if (last && last.preset === preset) last.items.push(item);
+ else out.push({ ...(preset ? { preset } : {}), items: [item] });
+ }
+ return out;
+ }, [handBuilt]);
+
const eqIds = useMemo(() => audioEqIds(chain), [chain]);
+
+ /**
+ * The number each row wears, counted over what the rack actually shows.
+ *
+ * Not the chain index: the carve's filters and an EQ's bands are inside their
+ * own modules, so counting raw nodes would leave the visible rack jumping from
+ * 02 to 07 and the numbers would look like a bug rather than a position.
+ */
+ const positions = useMemo(() => {
+ const map = new Map();
+ let at = (showCarve ? 1 : 0) + eqIds.length;
+ for (const { i } of handBuilt) map.set(i, ++at);
+ return map;
+ }, [handBuilt, eqIds.length, showCarve]);
const [openEq, setOpenEq] = useState(null);
const addEq = useCallback(() => {
@@ -375,6 +409,13 @@ export function FxSection({
return (
+ {/* The rack IS the signal path, and saying so costs two lines. Without
+ them the order reads as a list, which is the one reading that makes
+ "move up" look cosmetic — it is the most consequential control here. */}
+
+ In
+ this track
+
{/* Carve leads the rack, which is also where its effects sit in the signal
path — corrective work before anything the author added. Present
whenever there is a voice for it to listen to, rather than appearing
@@ -413,8 +454,8 @@ export function FxSection({
{showCarve ? "No other effects on this track." : "No effects on this track."}
) : (
- handBuilt.map(({ node, i }) => {
- return (
+ runs.map((run) => {
+ const rows = run.items.map(({ node, i }) => (
+ ));
+ const preset = run.preset ? getAudioFxPreset(run.preset) : null;
+ if (!preset) return rows;
+ return (
+
+
+ {preset.label}
+
+ {rows}
+
);
})
)}
+
+ Out
+ to mix
+
{adding ? (
From e47c5667c35f88268955b5e83a7f64ddb64ccf46 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 08:11:34 -0700
Subject: [PATCH 07/21] docs(plans): record the design build-out
---
plans/audio-fx-ux/README.md | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/plans/audio-fx-ux/README.md b/plans/audio-fx-ux/README.md
index c231dd07fa..815496cb9a 100644
--- a/plans/audio-fx-ux/README.md
+++ b/plans/audio-fx-ux/README.md
@@ -211,6 +211,33 @@ down thirty times a second. Applying must NOT revert, since the audition *was*
the thing applied. And moving between two entries in a shelf is not leaving it,
so each entry has to call its neighbours' auditions off itself.
+**The three rules are built too**, and so is the visual direction:
+
+- *Two faces.* A module opens on its name, what it is for, and the one control
+ that carries it — `EFFECT_COPY.primary`, with `primaryEnds` saying what its
+ two ends sound like. Everything else is behind a Details disclosure, which is
+ also where the DSP name lives. Ten of fifteen effects; the five whose primary
+ is "strength" open on all their controls until `PROFILES` ships, which is
+ honest — inventing one knob for them now would be a knob that lies.
+- *One knob that matters.* Half of it. The derived control is `PROFILES` and has
+ not shipped; what HAS is the case that made the rule incoherent — see below.
+- *Name the outcome.* Modules, knobs, the add menu and the preset shelf.
+
+- **The range IS the module.** The add menu offers five named jobs — Tame
+ Boominess, Reduce Mud, Reduce Boxiness, Add Clarity, Soften Harshness — and
+ `peaking` is not offered as itself. `packages/core/src/audioFxJobs.ts`. Every
+ one is a job the preset catalogue already ships, at the settings it ships it
+ with, so the list names the vocabulary the presets were written in rather than
+ inventing a second one.
+- **The shared ruler.** Every spectral module shows where it acts across the
+ seven named ranges, log-spaced, with the range it is in named underneath.
+- **Family lettering and the tint step.** Four families told apart by the sans,
+ the serif spent on non-linear, monospace for the measuring modules, and a
+ lightness step per module derived from registry position.
+- **The schematic**, translated to one column: IN and OUT terminals, every step
+ numbered over what the rack shows, and a preset's consecutive nodes bracketed
+ as the one thing that was added.
+
What is still not wired: `PROFILES`, below.
It has no entry for Tone or for the levelling module, because both carry their
From 12a67ab82e519cec2fd5bc2777b8d2b0341dbff6 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 11:17:04 -0700
Subject: [PATCH 08/21] feat: one knob for the five effects that cannot
honestly have one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The last unbuilt piece of the rack design. A compressor has seven
controls and an author wants one — but unlike a filter or a delay, no
single one of them can be its face: threshold means nothing without ratio,
ratio means nothing without make-up gain. So the knob is derived, exactly
as `carveProfile` already turns one number into six for the carve.
`EFFECT_COPY[id].primary === "strength"` was already the copy layer saying
"this module wants this treatment", and a test now asserts the profile set
matches that list exactly — a missing profile leaves a module opening on
all seven controls, and an extra one is a knob nobody asked for.
**Continuous, not the three-point tables the design proposed.** A table
makes gentle/middle/strong three settings to pick between, and the thing
being modelled is one axis — which the carve's knob has already proved
reads. The proposal's figures survive as anchors where they held up.
## Three of the five figures were wrong, and rendering is what showed it
Full numbers and method in `~/audio-fx-profiles-ab/README.md`.
- **Compressor make-up was too small and not linear.** 1/3/7 dB left the
track 2.5 dB QUIETER at full evenness — an evenness knob that turns the
track down as it goes up. Gain reduction accelerates as threshold and
ratio move together, so linear was wrong too; solved as s² × 9.5. Spread
now falls 19.1 → 8.8 dB with the level unmoved.
- **Saturation's trim went the wrong way.** A soft clipper at −18 dB IS a
limiter at −18 dB: the proposed −3 dB trim took the peak from 0.496 to
0.089 and nearly halved RMS, so "Warmth" mostly meant "much quieter".
Reversed to s² × 2.8 up; RMS now holds within 0.4% while the peak comes
down, which is the signature of saturation rather than attenuation.
- **The gate did essentially nothing.** The design derived threshold and
range only; left at the effect's 100 ms default release it moved the
gaps 0.1 dB against a range asking for 30. Swept, release dominates
everything else — 0.2 dB at 140 ms, 13.4 dB at 10 ms — so it joins the
profile. Gaps now fall 29.6 dB with speech unmoved to a tenth of a dB.
Reverb and bitcrush measured correctly as proposed and are unchanged.
The first measurement reported the gate as working when it was doing
nothing at all: a speaking-window measure excludes exactly the windows a
gate acts on. Gaps are measured separately for that reason.
## In the panel
The derived knob renders through the ordinary `FxParamRow`, not through
`FxNodeParams` — it has no AudioParam behind it and nothing to automate.
What automation there is belongs to the parameters it sets, under Details,
where they can be aimed at individually.
It writes mechanism, never the knob: the chain stores what renders, and
`audioFxProfileStrength` reads the knob back by inverting the curve — the
same contract `normalizeCarveSettings` has. A profile merges over what is
there, so a compressor's knee and a saturation's curve type survive the
knob moving.
Falsified: a derived parameter that turns around mid-sweep, one that runs
past its range, a profile that replaces rather than merges, and a panel
that writes the knob instead of the mechanism each fail a test.
core 1762 (113 files) · studio 3695 + 18 todo.
---
packages/core/package-subpaths.json | 6 +
packages/core/package.json | 10 +
packages/core/src/audioFxProfiles.test.ts | 122 ++++++++++
packages/core/src/audioFxProfiles.ts | 209 ++++++++++++++++++
.../editor/propertyPanelFxNodeRow.tsx | 81 ++++++-
.../editor/propertyPanelFxSection.test.tsx | 49 +++-
6 files changed, 460 insertions(+), 17 deletions(-)
create mode 100644 packages/core/src/audioFxProfiles.test.ts
create mode 100644 packages/core/src/audioFxProfiles.ts
diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json
index 013027dfc4..a8948eeef3 100644
--- a/packages/core/package-subpaths.json
+++ b/packages/core/package-subpaths.json
@@ -98,6 +98,12 @@
"types": "./dist/audioFxCopy.d.ts",
"environments": ["browser", "bun", "node"]
},
+ "./audio-fx-profiles": {
+ "source": "./src/audioFxProfiles.ts",
+ "runtime": "./dist/audioFxProfiles.js",
+ "types": "./dist/audioFxProfiles.d.ts",
+ "environments": ["browser", "bun", "node"]
+ },
"./audio-fx-jobs": {
"source": "./src/audioFxJobs.ts",
"runtime": "./dist/audioFxJobs.js",
diff --git a/packages/core/package.json b/packages/core/package.json
index 02fd67f575..5961af0917 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -112,6 +112,12 @@
"import": "./src/audioFxCopy.ts",
"types": "./src/audioFxCopy.ts"
},
+ "./audio-fx-profiles": {
+ "bun": "./src/audioFxProfiles.ts",
+ "node": "./dist/audioFxProfiles.js",
+ "import": "./src/audioFxProfiles.ts",
+ "types": "./src/audioFxProfiles.ts"
+ },
"./audio-fx-jobs": {
"bun": "./src/audioFxJobs.ts",
"node": "./dist/audioFxJobs.js",
@@ -426,6 +432,10 @@
"import": "./dist/audioFxCopy.js",
"types": "./dist/audioFxCopy.d.ts"
},
+ "./audio-fx-profiles": {
+ "import": "./dist/audioFxProfiles.js",
+ "types": "./dist/audioFxProfiles.d.ts"
+ },
"./audio-fx-jobs": {
"import": "./dist/audioFxJobs.js",
"types": "./dist/audioFxJobs.d.ts"
diff --git a/packages/core/src/audioFxProfiles.test.ts b/packages/core/src/audioFxProfiles.test.ts
new file mode 100644
index 0000000000..a9f7e6c1ca
--- /dev/null
+++ b/packages/core/src/audioFxProfiles.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from "vitest";
+import { defaultAudioFxParams, getAudioFxDef } from "./audioFx.js";
+import { EFFECT_COPY } from "./audioFxCopy.js";
+import {
+ applyAudioFxProfile,
+ audioFxProfileStrength,
+ getAudioFxProfile,
+ HF_AUDIO_FX_PROFILES,
+} from "./audioFxProfiles.js";
+
+describe("derived one-knob profiles", () => {
+ it("covers exactly the effects whose copy asks for one", () => {
+ // `primary: "strength"` is the copy layer saying "this module has no real
+ // parameter that can honestly be its face". A profile missing for one of
+ // those leaves the module opening on all seven of its controls; a profile
+ // for anything else is a knob nobody asked for.
+ const asked = Object.entries(EFFECT_COPY)
+ .filter(([, copy]) => copy.primary === "strength")
+ .map(([id]) => id)
+ .sort();
+ expect(Object.keys(HF_AUDIO_FX_PROFILES).sort()).toEqual(asked);
+ });
+
+ it("derives only parameters the effect actually has", () => {
+ for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) {
+ const keys = getAudioFxDef(id)?.params.map((p) => p.key) ?? [];
+ for (const key of profile.derives) {
+ expect(keys, `${id} derives "${key}", which it does not have`).toContain(key);
+ }
+ // And the curve sets everything it claims to.
+ for (const key of profile.derives) {
+ expect(profile.at(0.5)[key], `${id}.${key} is not set at 0.5`).toBeDefined();
+ }
+ }
+ });
+
+ it("stays inside every parameter's declared range across the whole knob", () => {
+ // A profile that runs past a range is not clipped by the panel — it is
+ // clamped on the way into the chain, so the knob would silently stop
+ // meaning anything past that point.
+ for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) {
+ const def = getAudioFxDef(id);
+ for (let s = 0; s <= 1.0001; s += 0.05) {
+ const derived = profile.at(s);
+ for (const param of def?.params ?? []) {
+ const value = derived[param.key];
+ if (value === undefined || param.kind !== "number") continue;
+ expect(value, `${id}.${param.key} at ${s.toFixed(2)}`).toBeGreaterThanOrEqual(param.min);
+ expect(value, `${id}.${param.key} at ${s.toFixed(2)}`).toBeLessThanOrEqual(param.max);
+ }
+ }
+ }
+ });
+
+ it("moves every derived parameter monotonically", () => {
+ // The whole promise of one knob: everything under it moves together and in
+ // one direction. A parameter that turns around mid-sweep means the knob
+ // makes the effect stronger and then weaker, which is unusable and is the
+ // failure a hand-tuned table invites.
+ for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) {
+ for (const key of profile.derives) {
+ const series: number[] = [];
+ for (let s = 0; s <= 1.0001; s += 0.05) {
+ const value = profile.at(s)[key];
+ if (typeof value === "number") series.push(value);
+ }
+ const up = series.every((v, i) => i === 0 || v >= (series[i - 1] ?? v));
+ const down = series.every((v, i) => i === 0 || v <= (series[i - 1] ?? v));
+ expect(up || down, `${id}.${key} turns around mid-sweep`).toBe(true);
+ }
+ }
+ });
+
+ it("keeps what the author set under Details", () => {
+ // A profile names only the parameters it derives. A compressor's knee and a
+ // saturation's curve type are the author's, and the knob must not reach in
+ // and reset them on the way past.
+ const params = { ...defaultAudioFxParams("compressor"), knee: 6, mix: 0.5 };
+ const next = applyAudioFxProfile("compressor", 0.8, params);
+ expect(next.knee).toBe(6);
+ expect(next.mix).toBe(0.5);
+ expect(next.ratio).not.toBe(params.ratio);
+ });
+
+ it("reads a strength back out of the values a chain stores", () => {
+ // A chain stores mechanism, not the knob — the mechanism is what renders,
+ // so it has to be authoritative. Reopening a project has to put the knob
+ // back where it was.
+ for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) {
+ for (const s of [0, 0.25, 0.5, 0.75, 1]) {
+ const params = applyAudioFxProfile(id, s, defaultAudioFxParams(id));
+ expect(audioFxProfileStrength(id, params), `${id} at ${s}`).toBeCloseTo(s, 1);
+ }
+ void profile;
+ }
+ });
+
+ it("passes through the figures the design proposed", () => {
+ // The curves replaced a three-point table, and these are the points it
+ // named. Continuous beats three settings, but not at the price of landing
+ // somewhere else than the design said.
+ expect(getAudioFxProfile("compressor")?.at(0).threshold).toBe(-12);
+ expect(getAudioFxProfile("compressor")?.at(0.5).ratio).toBe(4);
+ expect(getAudioFxProfile("compressor")?.at(1).release).toBe(90);
+ expect(getAudioFxProfile("gate")?.at(0).threshold).toBe(-55);
+ expect(getAudioFxProfile("saturate")?.at(1).threshold).toBe(-18);
+ expect(getAudioFxProfile("reverb")?.at(0).size).toBe(0.25);
+ expect(getAudioFxProfile("reverb")?.at(0.5).size).toBe(0.55);
+ expect(getAudioFxProfile("reverb")?.at(1).size).toBe(0.9);
+ expect(getAudioFxProfile("bitcrush")?.at(1).bits).toBe(6);
+ });
+
+ it("takes a value that is not a number as the middle", () => {
+ // The knob has to land somewhere, and silence is the one answer that is
+ // never right.
+ expect(getAudioFxProfile("reverb")?.at(Number.NaN).size).toBe(
+ getAudioFxProfile("reverb")?.at(0.5).size,
+ );
+ expect(audioFxProfileStrength("reverb", {})).toBe(0.5);
+ expect(audioFxProfileStrength("not-an-effect", {})).toBe(0.5);
+ });
+});
diff --git a/packages/core/src/audioFxProfiles.ts b/packages/core/src/audioFxProfiles.ts
new file mode 100644
index 0000000000..0ec7cd745f
--- /dev/null
+++ b/packages/core/src/audioFxProfiles.ts
@@ -0,0 +1,209 @@
+/**
+ * One knob over several, for the five effects that cannot honestly have one
+ * real parameter nominated as the control that matters.
+ *
+ * A compressor has seven controls and an author wants one. The rest of the rack
+ * opens on a single real parameter — a filter's frequency, a delay's mix — but
+ * these five have no such parameter: threshold means nothing without ratio,
+ * ratio means nothing without make-up gain, and picking any one of them as the
+ * face of the module would be a knob that lies about what it sets.
+ *
+ * So they get a derived one, exactly as the carve already does: `carveProfile`
+ * turns a single 0..1 into six numbers, and these do the same for the rest of
+ * the rack. `EFFECT_COPY[id].primary` is `"strength"` for precisely these five,
+ * which is what says a module wants this treatment.
+ *
+ * Continuous rather than the three-point tables the design proposed. A table
+ * makes gentle/middle/strong three settings an author picks between, and the
+ * thing being modelled is not three settings — it is one axis, and the carve's
+ * knob has proved that reads. The proposal's figures survive as the anchors:
+ * each curve passes through them at 0, 0.5 and 1.
+ *
+ * Every number below is expressed in the parameter's own registry unit and then
+ * clamped by `normalizeAudioFxParams` on the way into the chain, so a profile
+ * cannot ship a value the effect would refuse.
+ */
+
+import { normalizeAudioFxParams, type HfAudioFxParamValues } from "./audioFx.js";
+
+/** 0..1, whatever arrives. NaN reads as the middle rather than as silence. */
+function clamp01(strength: number): number {
+ return Number.isFinite(strength) ? Math.min(1, Math.max(0, strength)) : 0.5;
+}
+
+/** Two decimal places, so a derived value reads as a setting and not as float noise. */
+function to2(value: number): number {
+ return Number(value.toFixed(2));
+}
+
+export interface HfAudioFxProfile {
+ /** What the one knob is called. Never the DSP name of anything it drives. */
+ label: string;
+ /** What the two ends sound like — the question an author actually has. */
+ ends: { low: string; high: string };
+ /** The parameters it sets. Everything else keeps the effect's own default. */
+ derives: readonly string[];
+ /** The mechanism values at a given strength. */
+ at(strength: number): HfAudioFxParamValues;
+}
+
+export const HF_AUDIO_FX_PROFILES: Record = {
+ compressor: {
+ label: "Evenness",
+ ends: { low: "Barely touched", high: "Very even, quite squashed" },
+ derives: ["threshold", "ratio", "attack", "release", "makeup"],
+ at(strength) {
+ const s = clamp01(strength);
+ return {
+ // Lower threshold and higher ratio together: more of the signal is
+ // caught, and what is caught is held harder. Moving one without the
+ // other is the pair of knobs this exists to stop an author meeting.
+ threshold: to2(-12 - s * 18),
+ ratio: to2(2 + s * 4),
+ // Faster as it gets firmer, because a firm compressor that lets peaks
+ // through is doing the audible half of its job and not the useful half.
+ attack: to2(25 - s * 20),
+ release: to2(300 - s * 210),
+ // Compression makes things quieter; this is the level put back, and it
+ // has to rise with the amount taken off or the knob reads as a volume
+ // control that goes the wrong way.
+ //
+ // Solved from measurement rather than proposed, and it is not linear:
+ // gain reduction accelerates as the threshold drops and the ratio rises
+ // together, so a straight line is too loud in the middle and too quiet
+ // at the top. The design's 1/3/7 dB left the track +0.9 dB at rest,
+ // +1.3 dB at the middle and −2.5 dB at full. Numbers in
+ // `~/audio-fx-profiles-ab/README.md`.
+ makeup: to2(s * s * 9.5),
+ };
+ },
+ },
+
+ gate: {
+ label: "Tightness",
+ ends: { low: "Only true silence", high: "Cuts quiet words too" },
+ derives: ["threshold", "range", "release"],
+ at(strength) {
+ const s = clamp01(strength);
+ return {
+ threshold: to2(-55 + s * 23),
+ // How far the gaps are ducked, never to silence: a room that stops dead
+ // between sentences sounds broken rather than clean.
+ range: to2(-10 - s * 20),
+ // Release has to come along, which the design did not have — and it
+ // dominates: swept against a fixed threshold and range, the gaps move
+ // 0.2 dB at 140 ms and 13.4 dB at 10 ms. Left at the effect's 100 ms
+ // default the gate was very nearly inaudible whatever else it was told.
+ //
+ // Measured on narration, speech level is unmoved (−24.0 dB) at every
+ // release down to 10 ms, so the usual reason to stay slow — clipping
+ // word endings — does not bite on this material at these thresholds.
+ release: to2(120 - s * 105),
+ };
+ },
+ },
+
+ saturate: {
+ label: "Warmth",
+ ends: { low: "Just a sheen", high: "Openly distorted" },
+ derives: ["threshold", "output"],
+ at(strength) {
+ const s = clamp01(strength);
+ return {
+ // Drive: the lower the threshold, the more of the signal meets the
+ // curve.
+ threshold: to2(-3 - s * 15),
+ // Up, not down — which reverses the design's figure, on the measurement
+ // that motivated taking one. A soft clipper at -18 dB threshold IS a
+ // limiter at -18 dB: peak fell to 0.089 from 0.496 and RMS almost
+ // halved, so the proposed trim of −3 dB made "warmer" mean "much
+ // quieter" and an author would have heard the level, not the warmth.
+ // Accelerating for the same reason as the compressor's make-up.
+ output: to2(s * s * 2.8),
+ };
+ },
+ },
+
+ reverb: {
+ label: "Space",
+ ends: { low: "A small tight room", high: "A big open hall" },
+ derives: ["size", "wet", "dry"],
+ at(strength) {
+ const s = clamp01(strength);
+ return {
+ // Anchored at the design's three figures — 0.25 / 0.55 / 0.90 — which a
+ // single linear run cannot hit, because 0.55 is not their midpoint.
+ size: to2(s <= 0.5 ? 0.25 + s * 0.6 : 0.55 + (s - 0.5) * 0.7),
+ wet: to2(0.15 + s * 0.3),
+ // Dry comes down as wet goes up, but not by the same amount: the two
+ // legs sum, and matching them exactly makes a big room quieter than a
+ // small one instead of further away.
+ dry: to2(0.92 - s * 0.2),
+ };
+ },
+ },
+
+ bitcrush: {
+ label: "Crush",
+ ends: { low: "Slightly gritty", high: "Destroyed" },
+ derives: ["bits", "samples", "mix"],
+ at(strength) {
+ const s = clamp01(strength);
+ return {
+ // Fewer steps and longer holds. `bits` runs DOWN as the knob runs up,
+ // which is why it cannot be the module's face on its own.
+ bits: to2(14 - s * 8),
+ samples: Math.max(1, Math.round(1 + s * 3)),
+ mix: to2(0.25 + s * 0.75),
+ };
+ },
+ },
+};
+
+export function getAudioFxProfile(type: string): HfAudioFxProfile | undefined {
+ return HF_AUDIO_FX_PROFILES[type];
+}
+
+/**
+ * The parameters a profile sets at this strength, merged over what is there.
+ *
+ * Merged rather than replacing: a profile names only the parameters it derives,
+ * and the rest — a compressor's knee, a saturation's curve type — are the
+ * author's to set under Details and must survive the knob moving.
+ */
+export function applyAudioFxProfile(
+ type: string,
+ strength: number,
+ params: HfAudioFxParamValues,
+): HfAudioFxParamValues {
+ const profile = getAudioFxProfile(type);
+ if (!profile) return params;
+ return normalizeAudioFxParams(type, { ...params, ...profile.at(strength) });
+}
+
+/**
+ * The strength a set of parameters reads as, by inverting the profile's own
+ * curve on its most characteristic parameter.
+ *
+ * A chain stores mechanism values, not the knob — the same contract
+ * `normalizeCarveSettings` has, and for the same reason: the mechanism is what
+ * renders, so it is what must be authoritative. Reading the knob back means one
+ * parameter has to be nominated as the one that says most about intent.
+ *
+ * A hand-edited chain therefore lands the knob at the nearest strength that
+ * would have produced its most telling value, which is the honest answer — the
+ * alternative is a knob parked at a default while the effect is set to
+ * something else entirely.
+ */
+export function audioFxProfileStrength(type: string, params: HfAudioFxParamValues): number {
+ const profile = getAudioFxProfile(type);
+ if (!profile) return 0.5;
+ const key = profile.derives[0];
+ if (key === undefined) return 0.5;
+ const value = params[key];
+ if (typeof value !== "number") return 0.5;
+ const low = profile.at(0)[key];
+ const high = profile.at(1)[key];
+ if (typeof low !== "number" || typeof high !== "number" || low === high) return 0.5;
+ return to2(Math.min(1, Math.max(0, (value - low) / (high - low))));
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
index b7d7e7515b..90720aaf4d 100644
--- a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
@@ -16,11 +16,17 @@ import {
getAudioFxDef,
type HfAudioFxDef,
type HfAudioFxNode,
+ type HfAudioFxParam,
type HfAudioFxParamValues,
} from "@hyperframes/core/audio-fx";
import { EFFECT_COPY, SUMMARY } from "@hyperframes/core/audio-fx-copy";
+import {
+ applyAudioFxProfile,
+ audioFxProfileStrength,
+ getAudioFxProfile,
+} from "@hyperframes/core/audio-fx-profiles";
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
-import { FxParams } from "./propertyPanelFxControls.js";
+import { FxParams, FxParamRow } from "./propertyPanelFxControls.js";
import { FxBandRuler } from "./propertyPanelFxBandRuler.js";
import { FX_FAMILY_TYPE, fxFamilyOf, fxFamilyTint } from "./propertyPanelFxFamily.js";
@@ -40,13 +46,37 @@ import { FX_FAMILY_TYPE, fxFamilyOf, fxFamilyTint } from "./propertyPanelFxFamil
*/
function primaryParamOf(def: HfAudioFxDef): string | null {
const primary = EFFECT_COPY[def.id]?.primary;
- // "strength" names no parameter, which is exactly what makes this work: the
- // day `PROFILES` ships and a derived `strength` knob really is in the
- // registry, this starts using it without being told.
- if (!primary) return null;
+ // "strength" names no real parameter, and for the five effects that declare it
+ // that is the point: their one knob is derived, and `profileRow` below builds
+ // it. Anything else has to be a parameter the effect actually has.
+ if (!primary || primary === "strength") return null;
return def.params.some((p) => p.key === primary) ? primary : null;
}
+/**
+ * The derived knob, as a parameter the existing controls can render.
+ *
+ * A profile is not in the registry — it sets five real parameters and is none of
+ * them — so this fabricates the one row it needs rather than teaching `FxParams`
+ * about a second kind of control. 0..1 in hundredths, the same shape and the
+ * same feel as the carve's Strength.
+ */
+function profileParam(type: string): HfAudioFxParam | null {
+ const profile = getAudioFxProfile(type);
+ if (!profile) return null;
+ return {
+ kind: "number",
+ key: "strength",
+ label: profile.label,
+ unit: "",
+ min: 0,
+ max: 1,
+ step: 0.01,
+ default: 0.5,
+ hint: `Sets ${profile.derives.length} settings at once. Open Details to see where they land.`,
+ };
+}
+
/**
* The registry's definition with the plain names written over it.
*
@@ -292,6 +322,15 @@ export function FxNodeRow({
const registryDef = getAudioFxDef(node.type);
const def = useMemo(() => (registryDef ? plainDef(registryDef) : null), [registryDef]);
const primary = registryDef ? primaryParamOf(registryDef) : null;
+ /**
+ * The derived knob, for a module with no real parameter that can be its face.
+ *
+ * It behaves like the primary one everywhere below — one control on the open
+ * face, everything it sets behind Details — so the two are the same shape and
+ * only their source differs.
+ */
+ const profile = getAudioFxProfile(node.type);
+ const derived = useMemo(() => profileParam(node.type), [node.type]);
/** The same def cut down to the one knob, so the open face reuses every wire. */
const onlyPrimary = useMemo(
() => (def && primary ? { ...def, params: def.params.filter((p) => p.key === primary) } : def),
@@ -301,6 +340,7 @@ export function FxNodeRow({
// the row itself, so it stays with its effect across a reorder.
const [details, setDetails] = useState(false);
if (!registryDef || !def || !onlyPrimary) return null;
+ const oneKnob = primary !== null || derived !== null;
const copy = EFFECT_COPY[node.type];
const bypassed = node.enabled === false;
const params = node.params ?? defaultAudioFxParams(node.type);
@@ -349,6 +389,33 @@ export function FxNodeRow({
{copy.does}
) : null}
+ {derived && !details ? (
+ <>
+ {/* Not routed through FxNodeParams: this knob is not in the
+ registry, so it has no AudioParam behind it and nothing to
+ automate. What automation there is belongs to the parameters it
+ sets, under Details, where they can be aimed at individually. */}
+
+ ) : null}
+ >
+ ) : null}
{primary && !details ? (
<>
)}
- {details || !primary ? (
+ {details || !oneKnob ? (
{
const labels = Array.from(host.querySelectorAll(".hf-fx-label")).map((e) =>
e.textContent?.trim(),
);
- for (const p of def.params) expect(labels).toContain(plainLabel("compressor", p.key));
+ // Under Details: a compressor's face is one derived knob, and its seven real
+ // controls are one click in.
+ openDetails(host);
+ const opened = Array.from(host.querySelectorAll(".hf-fx-label")).map((e) =>
+ e.textContent?.trim(),
+ );
+ for (const p of def.params) expect(opened).toContain(plainLabel("compressor", p.key));
+ void labels;
});
it("uses a select for an enum parameter and a slider for a number", () => {
const { host } = mount({ chain: chainOf("saturate") });
+ // Its curve type is an enum and lives under Details, since saturation's one
+ // knob is derived rather than being any single parameter.
+ openDetails(host);
expect(host.querySelector(".hf-fx-select")).toBeTruthy();
expect(host.querySelector(".hf-fx-slider")).toBeTruthy();
});
@@ -474,18 +484,33 @@ describe("FxSection chain", () => {
expect(fxCard(host).querySelector(".hf-fx-ruler")).toBeNull();
});
- it("opens a module on all of its controls when its one knob does not exist yet", () => {
- // Five effects want a single DERIVED control over several parameters — the
- // `PROFILES` idea, whose figures are proposed rather than measured. Until it
- // ships they open on everything, which is honest: inventing one knob for
- // them now would be a knob that lies about what it sets.
- const { host } = mount({ chain: chainOf("compressor") });
+ it("gives a module with no single real control a derived one", () => {
+ // A compressor has seven controls and an author wants one, but no single one
+ // of them can be its face: threshold means nothing without ratio. So the
+ // knob is derived, and it sets all five at once.
+ const { host, onChainChange } = mount({ chain: chainOf("compressor") });
const node = fxCard(host);
expect(EFFECT_COPY.compressor?.primary).toBe("strength");
- expect(node.querySelector(".hf-fx-node-details")).toBeNull();
- expect(node.querySelectorAll(".hf-fx-row").length).toBe(
- getAudioFxDef("compressor")?.params.length,
+ // One control on the open face, named for the outcome.
+ const rows = Array.from(node.querySelectorAll(".hf-fx-row"));
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.querySelector(".hf-fx-label")?.textContent).toBe("Evenness");
+ expect(node.querySelector(".hf-fx-node-details")).toBeTruthy();
+
+ // Moving it moves the mechanism underneath.
+ const before = defaultAudioFxParams("compressor");
+ typeInto(node.querySelector(".hf-fx-number")!, "0.9");
+ act(() =>
+ node
+ .querySelector(".hf-fx-number")
+ ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true })),
);
+ const next = onChainChange.mock.calls.at(-1)?.[0] as HfAudioFxChain;
+ const params = next.nodes[0]?.params ?? {};
+ expect(params.ratio).not.toBe(before.ratio);
+ expect(params.threshold).not.toBe(before.threshold);
+ // And leaves alone what the author set under Details.
+ expect(params.knee).toBe(before.knee);
});
it("offers presets as the complaint they answer", () => {
@@ -891,6 +916,7 @@ describe("FxSection chain", () => {
it("commits an enum immediately, since a select has no drag", () => {
const { host, onChainChange } = mount({ chain: chainOf("saturate") });
+ openDetails(host);
const select = fxCard(host).querySelector(".hf-fx-select")!;
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
act(() => {
@@ -1148,6 +1174,9 @@ describe("automation in the panel", () => {
// Saturate: `output` is a make-up gain, but the curve's type and threshold
// are rebuilt wholesale and cannot be scheduled.
const { host } = automatable(idChain("saturate"));
+ // Under Details, where the real parameters are: the derived knob on the open
+ // face has no AudioParam behind it and nothing to automate.
+ openDetails(host);
expect(
rowFor(host, plainLabel("saturate", "output"))?.querySelector(".hf-fx-automate"),
).toBeTruthy();
From c85c95f7d2f11e7ded7dbcddd147d2b2d69ed7f2 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 11:20:37 -0700
Subject: [PATCH 09/21] docs(plans): the profiles shipped; record the stack
---
plans/audio-fx-ux/README.md | 28 ++++++++++++++++++----------
1 file changed, 18 insertions(+), 10 deletions(-)
diff --git a/plans/audio-fx-ux/README.md b/plans/audio-fx-ux/README.md
index 815496cb9a..5e3ebc2eb7 100644
--- a/plans/audio-fx-ux/README.md
+++ b/plans/audio-fx-ux/README.md
@@ -9,8 +9,9 @@ The plain-language layer over every effect in the registry now ships as
`packages/core/src/audioFxCopy.ts`, with the coverage that used to gate this
page — every effect, parameter and preset must have copy — as
`audioFxCopy.test.ts`. `build-preview.mts` renders the review page from it
-**plus the real registry and preset catalogue**. Only `PROFILES` is still a
-proposal, and it is all that is left in `copy.mts`.
+**plus the real registry and preset catalogue**, including the shipped one-knob
+profiles — sampled off their real curves, so the page cannot disagree with what
+the knob does.
```bash
bun plans/audio-fx-ux/build-preview.mts /tmp/rack-ux.html
@@ -219,8 +220,10 @@ so each entry has to call its neighbours' auditions off itself.
also where the DSP name lives. Ten of fifteen effects; the five whose primary
is "strength" open on all their controls until `PROFILES` ships, which is
honest — inventing one knob for them now would be a knob that lies.
-- *One knob that matters.* Half of it. The derived control is `PROFILES` and has
- not shipped; what HAS is the case that made the rule incoherent — see below.
+- *One knob that matters.* `packages/core/src/audioFxProfiles.ts`. Five effects
+ get a derived control over several parameters, continuous rather than the
+ three-point tables proposed here. **Three of the five figures were wrong** and
+ only rendering showed it — the write-up is `~/audio-fx-profiles-ab/README.md`.
- *Name the outcome.* Modules, knobs, the add menu and the preset shelf.
- **The range IS the module.** The add menu offers five named jobs — Tame
@@ -238,15 +241,20 @@ so each entry has to call its neighbours' auditions off itself.
numbered over what the rack shows, and a preset's consecutive nodes bracketed
as the one thing that was added.
-What is still not wired: `PROFILES`, below.
+Everything in this document is now built.
It has no entry for Tone or for the levelling module, because both carry their
own copy in core (`audioEqSummary`, `levellingSummary`). That is the right home
for it: a summary that has to read the chain belongs beside the code that
writes it.
-The `PROFILES` figures — what one knob derives at gentle/middle/strong — are
-**still proposed values, not measured ones**, which is why they stayed behind in
-`copy.mts` rather than going to core with the rest. They want the same
-before/after listen the clip-before-duck fix got before a knob is wired to
-them.
+The `PROFILES` figures — what one knob derives at gentle/middle/strong — have
+**shipped and been corrected**. They are `HF_AUDIO_FX_PROFILES` in
+`packages/core/src/audioFxProfiles.ts`, continuous rather than three-point, and
+three of the five were wrong as proposed: the compressor's make-up left the
+track quieter at full evenness, saturation's trim went the wrong way and made
+"Warmth" mean "much quieter", and the gate did essentially nothing because
+`release` was not in the profile at all. Measurements, method and the sweep that
+found the last one are in `~/audio-fx-profiles-ab/README.md`.
+
+The stub that used to hold them, `plans/audio-fx-ux/copy.mts`, is gone.
From e015f834c2768731ccf4b7938e56285a53f1c86f Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 12:11:35 -0700
Subject: [PATCH 10/21] fix(studio): seed a profiled effect on its curve, not
at registry defaults
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Found in a running studio, which is the only place it could be found: add
a compressor and the module opens reading **Evenness 0.67** with its
make-up gain at 0 dB.
The registry's defaults are not a point on the profile's curve, and
`audioFxProfileStrength` reads the knob back by inverting that curve — so
a default-seeded compressor reports a strength it was never set to, and
every parameter under the knob disagrees with it. That is the
"quieter as you turn it up" failure the measurement pass fixed, arriving
on the very first frame instead.
Adding a profiled effect now seeds it through the profile at 0.5, so the
knob and the mechanism agree from the start. Everything else still
arrives exactly as the registry declares it.
Falsified: restoring the default seed fails the new test. The old
"seeded with its declared defaults" case asserted the behaviour being
fixed, and now uses an effect that has no derived knob — which is what it
was really about.
studio 3696 passing, 18 todo · core 1762.
---
.../editor/propertyPanelFxSection.test.tsx | 27 ++++++++++++++++---
.../editor/propertyPanelFxSection.tsx | 22 +++++++++++++--
2 files changed, 44 insertions(+), 5 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index d00bccbf44..3ddab6d5b4 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -10,6 +10,7 @@ import {
import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
+import { audioFxProfileStrength } from "@hyperframes/core/audio-fx-profiles";
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
/**
@@ -218,14 +219,17 @@ describe("FxSection chain", () => {
});
it("adds an effect seeded with its declared defaults", () => {
+ // An effect with no derived knob arrives exactly as the registry declares
+ // it. The five that DO have one are seeded on their curve instead — see
+ // "adds a profiled effect on its curve" below.
const { host, onChainChange } = mount();
click(host.querySelector(".hf-fx-add"));
- click(byText(host, ".hf-fx-add-item", EFFECT_COPY.compressor?.title ?? ""));
+ click(byText(host, ".hf-fx-add-item", EFFECT_COPY.delay?.title ?? ""));
expect(onChainChange).toHaveBeenCalledTimes(1);
const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
expect(next.nodes).toHaveLength(1);
- expect(next.nodes[0]!.type).toBe("compressor");
- expect(next.nodes[0]!.params).toEqual(defaultAudioFxParams("compressor"));
+ expect(next.nodes[0]!.type).toBe("delay");
+ expect(next.nodes[0]!.params).toEqual(defaultAudioFxParams("delay"));
});
it("renders a control for every parameter the effect declares", () => {
@@ -484,6 +488,23 @@ describe("FxSection chain", () => {
expect(fxCard(host).querySelector(".hf-fx-ruler")).toBeNull();
});
+ it("adds a profiled effect on its curve, not at registry defaults", () => {
+ // The registry's defaults are not a point on the profile's curve, so an
+ // effect seeded with them opened reading a strength it was not set to: a
+ // compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB —
+ // the "quieter as you turn it up" bug the profiles exist to prevent, on the
+ // very first frame. Caught in a running studio, not by these tests.
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
+ click(host.querySelector(".hf-fx-add"));
+ click(byText(host, ".hf-fx-add-item", EFFECT_COPY.compressor?.title ?? ""));
+
+ const written = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain | undefined;
+ const added = written?.nodes[0]?.params ?? {};
+ expect(audioFxProfileStrength("compressor", added)).toBeCloseTo(0.5, 2);
+ // And the mechanism agrees with the knob rather than sitting at its default.
+ expect(added.makeup).not.toBe(defaultAudioFxParams("compressor").makeup);
+ });
+
it("gives a module with no single real control a derived one", () => {
// A compressor has seven controls and an author wants one, but no single one
// of them can be its face: threshold means nothing without ratio. So the
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 6e4d647527..a301ffb7ad 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -26,6 +26,7 @@ import {
setAudioEqBandGain,
} from "@hyperframes/core/audio-fx-eq";
import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
+import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-fx-profiles";
import {
audioFxJobNode,
HF_AUDIO_FX_JOBS,
@@ -249,13 +250,30 @@ export function FxSection({
[chain, mutate],
);
- /** One effect at its defaults, appended — what both adding and auditioning do. */
+ /**
+ * One effect appended, at the values its module opens on.
+ *
+ * For most effects that is the registry's defaults. For the five with a
+ * derived knob it is NOT: the registry defaults are not a point on the
+ * profile's curve, so the module opened reading a strength it was not set to —
+ * a compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB,
+ * which is the "quieter as you turn it up" bug the profiles exist to prevent,
+ * on the very first frame. Seeding through the profile puts the knob and the
+ * mechanism in agreement from the start.
+ */
const withEffect = useCallback(
(base: HfAudioFxChain, type: string): HfAudioFxChain => ({
...base,
nodes: [
...base.nodes,
- { type, id: mintAudioFxNodeId(base), enabled: true, params: defaultAudioFxParams(type) },
+ {
+ type,
+ id: mintAudioFxNodeId(base),
+ enabled: true,
+ params: getAudioFxProfile(type)
+ ? applyAudioFxProfile(type, 0.5, defaultAudioFxParams(type))
+ : defaultAudioFxParams(type),
+ },
],
}),
[],
From e727e39b7f59b21673c1ad416a6775527baa1963 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 12:19:14 -0700
Subject: [PATCH 11/21] feat(studio): switch a preset off, or take it out, as
one thing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../editor/propertyPanelFxSection.test.tsx | 87 ++++++++++++++++++-
.../editor/propertyPanelFxSection.tsx | 70 ++++++++++++++-
2 files changed, 153 insertions(+), 4 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index 3ddab6d5b4..1cdfad9b20 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -11,7 +11,7 @@ import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
import { audioFxProfileStrength } from "@hyperframes/core/audio-fx-profiles";
-import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
+import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
/**
* What a knob is CALLED in the panel, looked up rather than spelled out.
@@ -87,6 +87,7 @@ function mount(overrides: Partial[0]> = {}) {
automatedTargets={overrides.automatedTargets}
onAutomateParam={overrides.onAutomateParam}
onRemoveParamAutomation={overrides.onRemoveParamAutomation}
+ onRemoveNodeAutomation={overrides.onRemoveNodeAutomation}
onLevel={overrides.onLevel}
onRemoveLevel={overrides.onRemoveLevel}
levelled={overrides.levelled}
@@ -412,6 +413,90 @@ describe("FxSection chain", () => {
expect(run?.querySelectorAll(".hf-fx-node")).toHaveLength(written.length);
});
+ describe("a preset is one thing to switch off or take away", () => {
+ /** A telephone preset applied, plus one hand-built effect beside it. */
+ const applied = (): HfAudioFxChain => {
+ const preset = getAudioFxPreset("telephone");
+ if (!preset) throw new Error("no telephone preset");
+ // Through the real applier: `fromPreset` is stamped there, not carried in
+ // the catalogue, and the tag is the whole basis of the bracket.
+ const withPreset = applyAudioFxPreset({ version: 1, nodes: [] }, preset);
+ return {
+ ...withPreset,
+ nodes: [
+ ...withPreset.nodes,
+ { type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
+ ],
+ };
+ };
+
+ it("bypasses every node it wrote, in one gesture", () => {
+ // Reaching into five modules and toggling each is exactly the bookkeeping
+ // the bracket exists to remove.
+ const { host, onChainChange } = mount({ chain: applied() });
+ const run = host.querySelector("[data-fx-preset='telephone']");
+ click(run?.querySelector(".hf-fx-preset-run-toggle"));
+
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
+ expect(next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => !n.enabled)).toBe(
+ true,
+ );
+ // And leaves what the author added themselves alone.
+ expect(next.nodes.find((n) => n.id === "own")?.enabled).toBe(true);
+ });
+
+ it("switches back on rather than deleting, so the settings survive", () => {
+ const off = applied();
+ off.nodes = off.nodes.map((n) =>
+ n.fromPreset === "telephone" ? { ...n, enabled: false } : n,
+ );
+ const { host, onChainChange } = mount({ chain: off });
+ const toggle = host
+ .querySelector("[data-fx-preset='telephone']")
+ ?.querySelector(".hf-fx-preset-run-toggle");
+ expect(toggle?.getAttribute("aria-pressed")).toBe("false");
+ click(toggle);
+
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
+ const back = next.nodes.filter((n) => n.fromPreset === "telephone");
+ expect(back.every((n) => n.enabled)).toBe(true);
+ // Nothing was thrown away.
+ expect(back).toHaveLength(off.nodes.filter((n) => n.fromPreset === "telephone").length);
+ });
+
+ 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, and the switch has to offer to stop it rather than claim it
+ // has already stopped.
+ const partial = applied();
+ partial.nodes = partial.nodes.map((n, i) => (i === 0 ? { ...n, enabled: false } : n));
+ const { host } = mount({ chain: partial });
+ expect(
+ host
+ .querySelector("[data-fx-preset='telephone']")
+ ?.querySelector(".hf-fx-preset-run-toggle")
+ ?.getAttribute("aria-pressed"),
+ ).toBe("true");
+ });
+
+ it("takes the preset back out whole, with its lanes", () => {
+ const onRemoveNodeAutomation = vi.fn();
+ const { host, onChainChange } = mount({ chain: applied(), onRemoveNodeAutomation });
+ click(
+ host
+ .querySelector("[data-fx-preset='telephone']")
+ ?.querySelector(".hf-fx-preset-run-remove"),
+ );
+
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
+ expect(next.nodes.filter((n) => n.fromPreset === "telephone")).toEqual([]);
+ expect(next.nodes.map((n) => n.id)).toEqual(["own"]);
+ // An orphaned lane keeps driving a parameter that is no longer in the
+ // graph, and the next effect added inherits it with the id.
+ expect(onRemoveNodeAutomation).toHaveBeenCalled();
+ });
+ });
+
it("brackets only nodes a preset still sits next to", () => {
// Pulled apart by a reorder, they are no longer a unit — and a bracket
// around the gap would claim an adjacency the signal path does not have.
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index a301ffb7ad..83c96b4818 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -314,6 +314,41 @@ export function FxSection({
[chain.nodes, mutate],
);
+ /**
+ * Bypass or restore every node a preset wrote, as one gesture.
+ *
+ * A preset is one thing the author added, so it has to be one thing they can
+ * switch off — reaching into five modules and toggling each is the bookkeeping
+ * the bracket exists to remove. Off is a bypass, not a delete: the settings
+ * survive, which is what makes it worth trying rather than committing to.
+ */
+ const toggleRun = useCallback(
+ (items: { node: HfAudioFxNode; i: number }[], on: boolean) => {
+ const slots = new Set(items.map((item) => item.i));
+ mutate(chain.nodes.map((n, i) => (slots.has(i) ? { ...n, enabled: on } : n)));
+ },
+ [chain.nodes, mutate],
+ );
+
+ /**
+ * Take a preset back out whole, lanes and all.
+ *
+ * Same contract as removing one node — 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.
+ */
+ const removeRun = useCallback(
+ (items: { node: HfAudioFxNode; i: number }[]) => {
+ for (const { node } of items) {
+ if (node.id) onRemoveNodeAutomation?.(node.id);
+ }
+ const slots = new Set(items.map((item) => item.i));
+ mutate(chain.nodes.filter((_, i) => !slots.has(i)));
+ setOpenNode(null);
+ },
+ [chain.nodes, mutate, onRemoveNodeAutomation],
+ );
+
const removeNode = useCallback(
(index: number) => {
// The node's lanes go with it. `resolveAutomation` only hides an orphan at
@@ -501,15 +536,44 @@ export function FxSection({
));
const preset = run.preset ? getAudioFxPreset(run.preset) : null;
if (!preset) return rows;
+ // On unless every node in it is bypassed: one switched back on means
+ // the preset is doing something, and the switch has to offer to stop
+ // it rather than claiming it has already stopped.
+ const runOn = run.items.some(({ node }) => node.enabled !== false);
return (
);
From 0880a92690989403d6923cc6fcc996e96700efe3 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 13:27:56 -0700
Subject: [PATCH 13/21] feat(studio): light the hovered preset, and show that
it is sounding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hovering a preset auditions it on the running audio, and that was
invisible: the shelf looked identical whether the audition was playing or
the pointer just happened to be resting there. An affordance nobody can
see they triggered is one they do not learn.
The hovered row lights in the accent at low alpha, and four bars animate
at its right edge. Deliberately the accent rather than a neutral grey —
the row is lit BY the thing that is playing, so the highlight and the
bars should read as one event rather than as a hover style that happens
to sit near an animation.
Only rendered when there is an audition channel to hear it through, so
the panel never claims audio that is not happening. Four bars because at
this size that reads as a level meter; three reads as an ellipsis. They
are staggered so it reads as a waveform travelling rather than four bars
pumping in unison, which reads as a spinner — and a spinner would say
"working", the opposite of what is true.
Under `prefers-reduced-motion` the bars hold at a fixed height instead of
disappearing: they are telling the author something is playing, and that
fact does not go away because the animation does.
Verified in a running studio — synthetic `mouseover` does not trigger CSS
`:hover`, so this was driven with a real pointer move: opacity 1, bars
`rgb(60, 230, 172)`, row `rgba(60, 230, 172, 0.12)`, and nothing left lit
after the pointer leaves.
Falsified: rendering the wave unconditionally fails the new test.
studio 3704 passing, 18 todo.
---
.../editor/propertyPanelFxPresetMenu.tsx | 17 +++-
.../editor/propertyPanelFxSection.test.tsx | 23 +++++
packages/studio/src/styles/studio.css | 89 +++++++++++++++++++
3 files changed, 128 insertions(+), 1 deletion(-)
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx
index e661024c79..257a3c28de 100644
--- a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx
@@ -68,7 +68,8 @@ export function FxPresetMenu({ onPick, onAudition }: FxPresetMenuProps) {
{preset.label}
+ {/* Hovering a preset plays it, and playing is otherwise invisible:
+ the panel looks identical whether the audition is sounding or
+ the pointer is just resting there. Only rendered when there IS
+ an audition channel, so it never claims audio that is not
+ happening. Four bars because that reads as a level meter at
+ this size; three reads as an ellipsis. */}
+ {onAudition ? (
+
+
+
+
+
+
+ ) : null}
))}
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index ddf7fcda9d..f5966f2f0c 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -695,6 +695,29 @@ describe("FxSection chain", () => {
expect(item?.querySelector(".hf-fx-preset-name")?.textContent).toBe("Telephone");
});
+ it("shows a wave on a preset only when hovering it can be heard", () => {
+ // Hovering plays the preset, and playing is otherwise invisible — the panel
+ // looks identical whether the audition is sounding or the pointer is just
+ // resting there. Without an audition channel there is no audio to claim.
+ const { host } = mount({ chain: { version: 1, nodes: [] } });
+ click(byText(host, "button", "Presets"));
+ expect(presetButton(host, "telephone")?.querySelector(".hf-fx-preset-wave")).toBeTruthy();
+
+ // Rendered directly rather than through `mount`, which supplies a preview
+ // handler by default — the case being covered is a section that has none.
+ const { host: dry } = renderInto(
+ ,
+ );
+ click(byText(dry, "button", "Presets"));
+ expect(presetButton(dry, "telephone")?.querySelector(".hf-fx-preset-wave")).toBeNull();
+ });
+
describe("hover-audition", () => {
/** Focus is the keyboard's hover, and both go through the same handler. */
const enter = (el: Element | null | undefined) => {
diff --git a/packages/studio/src/styles/studio.css b/packages/studio/src/styles/studio.css
index 50e3a02fe9..ed97c1eb6e 100644
--- a/packages/studio/src/styles/studio.css
+++ b/packages/studio/src/styles/studio.css
@@ -469,3 +469,92 @@ body {
animation: none;
}
}
+
+/* Preset shelf: the hovered row, and the wave that says it is being heard.
+ *
+ * Hovering a preset auditions it on the running audio, which is invisible — the
+ * panel looks identical whether the audition is playing or the pointer just
+ * happens to be resting there. The bars are that feedback: they run only while a
+ * preset is actually sounding, so they are a readout of the audio rather than a
+ * hover decoration. */
+.hf-fx-preset-item {
+ position: relative;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .hf-fx-preset-item {
+ transition:
+ background-color 120ms ease,
+ color 120ms ease;
+ }
+}
+
+.hf-fx-preset-item:hover,
+.hf-fx-preset-item:focus-visible {
+ /* The accent at low alpha rather than a grey: the row is lit by the thing that
+ is playing, and it should read as the same event as the bars. */
+ background-color: rgba(60, 230, 172, 0.12);
+}
+
+.hf-fx-preset-wave {
+ position: absolute;
+ top: 50%;
+ right: 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ height: 0.75rem;
+ transform: translateY(-50%);
+ opacity: 0;
+ transition: opacity 120ms ease;
+ pointer-events: none;
+}
+
+.hf-fx-preset-item:hover .hf-fx-preset-wave,
+.hf-fx-preset-item:focus-visible .hf-fx-preset-wave {
+ opacity: 1;
+}
+
+.hf-fx-preset-wave span {
+ width: 2px;
+ height: 100%;
+ border-radius: 1px;
+ background: #3ce6ac;
+ transform: scaleY(0.25);
+ transform-origin: center;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .hf-fx-preset-wave span {
+ animation: hf-fx-preset-wave 900ms ease-in-out infinite;
+ }
+ /* Staggered so it reads as a waveform travelling rather than four bars
+ pumping in unison, which reads as a loading spinner. */
+ .hf-fx-preset-wave span:nth-child(2) {
+ animation-delay: 150ms;
+ }
+ .hf-fx-preset-wave span:nth-child(3) {
+ animation-delay: 300ms;
+ }
+ .hf-fx-preset-wave span:nth-child(4) {
+ animation-delay: 450ms;
+ }
+}
+
+/* Still visible without motion — it is telling the author something is
+ playing, and that fact does not go away because the bars hold still. */
+@media (prefers-reduced-motion: reduce) {
+ .hf-fx-preset-wave span {
+ transform: scaleY(0.7);
+ }
+}
+
+@keyframes hf-fx-preset-wave {
+ 0%,
+ 100% {
+ transform: scaleY(0.25);
+ }
+ 50% {
+ transform: scaleY(1);
+ }
+}
From a0cfae336c26059226967f5b6ff1abacc90f4e13 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 13:37:06 -0700
Subject: [PATCH 14/21] fix(studio): let an author out of the preset and
add-effect menus
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening either menu hid both buttons, and the only thing that set them
back was picking something. So an author who opened one and changed their
mind had two ways out: add an effect they did not want, or deselect the
clip and lose their place. Escape did nothing.
The buttons now stay and close what they opened — same control, toggled,
reading "Close" while its menu is up. Opening one closes the other, since
two menus at once is two surfaces over the rack with nothing saying which
the next click belongs to.
Escape closes whichever is open. Bound on the section rather than the
window, because a keystroke aimed at the timeline is not aimed at this,
and it only stops propagation when it actually has a menu to close — the
panel has its own Escape handling and swallowing the key unconditionally
would break it.
Closing reverts an audition in flight, for the same reason leaving the
shelf with the pointer does: a preview left playing is audible over a
chain the document does not have.
Verified live, stepping one render at a time — clicking through the whole
cycle in a single synchronous pass batches into one React render and only
shows the final state, which is what made this look fixed when it was not.
Falsified: a button that only opens, an Escape that does nothing, opening
one menu without closing the other (in EITHER direction — the first test
only covered one, and the reverse mutation survived it), and closing
without reverting the audition each fail a test.
studio 3710 passing, 18 todo.
---
.../editor/propertyPanelFxSection.test.tsx | 83 +++++++++++++++++
.../editor/propertyPanelFxSection.tsx | 88 ++++++++++++++-----
2 files changed, 149 insertions(+), 22 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index f5966f2f0c..340af84cfc 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -695,6 +695,89 @@ describe("FxSection chain", () => {
expect(item?.querySelector(".hf-fx-preset-name")?.textContent).toBe("Telephone");
});
+ describe("getting back out of a menu", () => {
+ /** Escape, from inside the section, the way a keystroke really arrives. */
+ const escape = (host: HTMLElement) =>
+ act(() => {
+ host
+ .querySelector(".hf-fx-section")
+ ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+
+ it("closes the preset shelf with the button that opened it", () => {
+ // Opening a menu used to hide both buttons, and picking something was the
+ // only thing that set them back — so an author who changed their mind had
+ // to add an effect they did not want, or deselect the clip.
+ const { host } = mount();
+ click(byText(host, "button", "Presets"));
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeTruthy();
+
+ click(byText(host, "button", "Close"));
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeNull();
+ expect(byText(host, "button", "Presets")).toBeTruthy();
+ });
+
+ it("closes the add menu the same way", () => {
+ const { host } = mount();
+ click(host.querySelector(".hf-fx-add"));
+ expect(host.querySelector(".hf-fx-add-menu")).toBeTruthy();
+
+ click(host.querySelector(".hf-fx-add"));
+ expect(host.querySelector(".hf-fx-add-menu")).toBeNull();
+ expect(byText(host, "button", "Add effect")).toBeTruthy();
+ });
+
+ it("opens one menu in place of the other", () => {
+ // Two open at once is two surfaces covering the rack, and neither says
+ // which one the next click belongs to.
+ const { host } = mount();
+ click(byText(host, "button", "Presets"));
+ click(host.querySelector(".hf-fx-add"));
+ expect(host.querySelector(".hf-fx-add-menu")).toBeTruthy();
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeNull();
+
+ // And back the other way, which is a separate handler.
+ click(byText(host, "button", "Presets"));
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeTruthy();
+ expect(host.querySelector(".hf-fx-add-menu")).toBeNull();
+ });
+
+ it("closes on Escape, which is what anyone reaches for first", () => {
+ const { host } = mount();
+ click(byText(host, "button", "Presets"));
+ escape(host);
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeNull();
+
+ click(host.querySelector(".hf-fx-add"));
+ escape(host);
+ expect(host.querySelector(".hf-fx-add-menu")).toBeNull();
+ });
+
+ it("puts the chain back when a closing menu was auditioning", () => {
+ // Leaving the shelf by closing it is still leaving it, and an audition
+ // left playing is audible over a chain the document does not have.
+ const { host, onChainPreview } = mount({ chain: chainOf("peaking") });
+ click(byText(host, "button", "Presets"));
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
+ click(byText(host, "button", "Close"));
+
+ const back = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
+ expect(back.nodes.map((n) => n.type)).toEqual(["peaking"]);
+ });
+
+ it("leaves Escape alone when no menu is open", () => {
+ // The panel has its own Escape handling; swallowing the key when this has
+ // nothing to close would break it.
+ const { host } = mount();
+ let reached = false;
+ host.addEventListener("keydown", () => {
+ reached = true;
+ });
+ escape(host);
+ expect(reached).toBe(true);
+ });
+ });
+
it("shows a wave on a preset only when hovering it can be heard", () => {
// Hovering plays the preset, and playing is otherwise invisible — the panel
// looks identical whether the audition is sounding or the pointer is just
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index a1b8fabe4a..477fe4d777 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -5,7 +5,7 @@
* is not an entry in the chain.
*/
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import {
defaultAudioFxParams,
getAudioFxDef,
@@ -499,8 +499,34 @@ export function FxSection({
[chain.nodes, mutate],
);
+ /**
+ * Escape closes whichever menu is open.
+ *
+ * The first thing anyone reaches for, and on a surface that covers the rack it
+ * is the one that needs no discovering. Bound on the section rather than the
+ * window: a keystroke aimed at the timeline is not aimed at this.
+ */
+ const closeMenus = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key !== "Escape" || (!adding && !picking)) return;
+ // Stops the panel's own Escape handling from also firing — closing a menu
+ // and deselecting the clip on one keystroke loses the author their place.
+ event.stopPropagation();
+ audition(null);
+ onAuditionLevel?.(false);
+ setAdding(false);
+ setPicking(false);
+ },
+ [adding, picking, audition, onAuditionLevel],
+ );
+
return (
-
+
{/* The rack IS the signal path, and saying so costs two lines. Without
them the order reads as a list, which is the one reading that makes
@@ -791,26 +817,44 @@ export function FxSection({
/>
) : null}
- {adding || picking ? null : (
-
- )}
+ {/* The buttons stay while their menu is open, and close it — an author who
+ opened one and changed their mind had no way back: picking something
+ was the only thing that set these false, so the only exits were adding
+ an effect they did not want or deselecting the clip. */}
+
+ {
+ // Leaving the shelf by closing it is still leaving it, and an
+ // audition left playing is audible over a chain the document does
+ // not have.
+ if (picking) audition(null);
+ setPicking(!picking);
+ setAdding(false);
+ }}
+ >
+ {picking ? "Close" : "Presets"}
+
+ {
+ if (adding) {
+ audition(null);
+ onAuditionLevel?.(false);
+ }
+ setAdding(!adding);
+ setPicking(false);
+ }}
+ >
+ {adding ? "Close" : "Add effect"}
+
+
);
}
From 83f09a3920d802eb7a89e93b6e910e10f7d9e8cf Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 13:57:31 -0700
Subject: [PATCH 15/21] feat(studio): audition a preset from the playhead while
paused
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hovering a preset writes it to the running graph — which is silent while
the transport is paused. So the whole affordance only worked mid-playback:
a paused author hovering the shelf heard nothing at all and had no way to
know the feature existed.
Hovering now starts playback from wherever the playhead sits, and leaving
stops it and returns the playhead to exactly where it was found. Browsing
the shelf is not an edit and must not cost the author their place.
Two guards, each with a test:
- **A transport the author started is left alone**, in both directions.
Stopping their playback because they passed over a preset would be the
panel taking a decision nobody offered it.
- **Every path that ends an audition stops the transport** — leaving,
applying, and the panel unmounting. A click means "keep this", not "and
carry on playing from wherever the audition reached".
Order matters at both ends: the chain goes into the graph before playback
starts, or the first moment heard is the un-auditioned mix; and playback
stops before the chain reverts, so the last thing heard is the preset
rather than a frame of the old chain coming back.
`playbackRequest` on the player store follows `requestedSeekTime`: the
panel cannot reach `useTimelinePlayer`, which is a single instance owned
by the shell. It carries a nonce because two hovers in a row both want
play, and without one the second is indistinguishable from the first
already having been served.
Falsified: not returning the playhead, and hijacking a transport the
author started, each fail a test.
studio 3716 passing, 18 todo.
---
.../editor/propertyPanelAudioFxGroup.test.tsx | 39 +++++++++++++++
.../editor/propertyPanelAudioFxGroup.tsx | 35 ++++++++++++++
.../editor/propertyPanelFxSection.test.tsx | 47 +++++++++++++++++++
.../editor/propertyPanelFxSection.tsx | 36 +++++++++++---
.../src/player/hooks/useTimelinePlayer.ts | 14 +++++-
.../studio/src/player/store/playerStore.ts | 23 +++++++++
6 files changed, 187 insertions(+), 7 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
index 8f7d52f6e6..e4336f81f2 100644
--- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
@@ -435,6 +435,45 @@ describe("AudioFxGroup dynamic carve", () => {
* nobody asked to level, through a channel that does not persist: audible,
* absent from the document, and gone on the next reload.
*/
+ describe("auditioning starts the transport when it has to", () => {
+ const store = () => usePlayerStore.getState();
+
+ const hoverPreset = (host: HTMLElement) => {
+ act(() => byTextButton(host, "Presets")?.click());
+ act(() => host.querySelector(".hf-fx-preset-item")?.focus());
+ };
+ const leaveShelf = (host: HTMLElement) =>
+ act(() => {
+ host
+ .querySelector(".hf-fx-preset-menu")
+ ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
+ });
+
+ it("plays from the playhead, then puts it back exactly where it was", () => {
+ // Browsing the shelf must not cost the author their place: hovering is not
+ // an edit, so the playhead it borrows has to be returned.
+ act(() => usePlayerStore.setState({ isPlaying: false, currentTime: 42 }));
+ const { host } = mount({ "fx-chain": CHAIN });
+ hoverPreset(host);
+ expect(store().playbackRequest?.playing).toBe(true);
+
+ leaveShelf(host);
+ expect(store().playbackRequest?.playing).toBe(false);
+ expect(store().playbackRequest?.returnTo).toBe(42);
+ });
+
+ it("leaves a transport the author started alone", () => {
+ // Stopping their playback because they passed over a preset would be the
+ // panel taking a decision nobody offered it.
+ act(() => usePlayerStore.setState({ isPlaying: true, currentTime: 12 }));
+ const { host } = mount({ "fx-chain": CHAIN });
+ const before = store().playbackRequest?.nonce ?? 0;
+ hoverPreset(host);
+ leaveShelf(host);
+ expect(store().playbackRequest?.nonce ?? 0).toBe(before);
+ });
+ });
+
it("drops a levelling measurement that lands after the pointer has gone", async () => {
const { release, decoded } = stubGatedDecode();
const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN });
diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
index a5a5afe953..bf2df33c1d 100644
--- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
+++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
@@ -574,6 +574,40 @@ export function AudioFxGroup({
}
};
+ /**
+ * Where the playhead was when an audition started the transport, so leaving
+ * can put it back. Null means this audition did not start playback — the
+ * transport was already running and must be left alone.
+ */
+ const auditionReturn = useRef(null);
+
+ /**
+ * Start playback for an audition, and stop it again on the way out.
+ *
+ * An audition writes the preset to the running graph, which is silent while
+ * the transport is paused — so a paused author hovering a preset heard
+ * nothing at all, and the whole affordance only worked mid-playback. Hovering
+ * now plays from the playhead, and leaving stops and rewinds to exactly where
+ * it started: browsing the shelf must not cost the author their place.
+ *
+ * Already playing, this does nothing in either direction. The author started
+ * that, and stopping their transport because they passed over a preset would
+ * be the panel taking a decision that was not offered to it.
+ */
+ const auditionTransport = (on: boolean): void => {
+ const store = usePlayerStore.getState();
+ if (on) {
+ if (store.isPlaying || auditionReturn.current !== null) return;
+ auditionReturn.current = store.currentTime;
+ store.requestPlayback(true);
+ return;
+ }
+ const returnTo = auditionReturn.current;
+ if (returnTo === null) return;
+ auditionReturn.current = null;
+ store.requestPlayback(false, returnTo);
+ };
+
const [auditioningLevel, setAuditioningLevel] = useState(false);
/**
* Bumped on every enter and leave, so a measurement can tell whether the
@@ -808,6 +842,7 @@ export function AudioFxGroup({
next.nodes.length ? serializeAudioFxChain(next) : null,
)
}
+ onAuditionTransport={auditionTransport}
onChainPreview={(next) =>
// Live writes skip the preview refresh entirely, so dragging a knob no
// longer reloads the composition and restarts playback on every pixel.
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index 340af84cfc..e395dd8787 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -90,6 +90,7 @@ function mount(overrides: Partial[0]> = {}) {
onRemoveNodeAutomation={overrides.onRemoveNodeAutomation}
onAutomatePreset={overrides.onAutomatePreset}
onRemovePresetAutomation={overrides.onRemovePresetAutomation}
+ onAuditionTransport={overrides.onAuditionTransport}
automatedPresets={overrides.automatedPresets}
onLevel={overrides.onLevel}
onRemoveLevel={overrides.onRemoveLevel}
@@ -695,6 +696,52 @@ describe("FxSection chain", () => {
expect(item?.querySelector(".hf-fx-preset-name")?.textContent).toBe("Telephone");
});
+ describe("auditioning while the transport is paused", () => {
+ it("starts playback so a paused author can hear the preset at all", () => {
+ // The audition is written to the running graph, which is silent while the
+ // transport is paused — so without this, hovering a preset did nothing
+ // whatsoever unless the author happened to be mid-playback.
+ const onAuditionTransport = vi.fn();
+ const { host } = mount({ chain: chainOf("peaking"), onAuditionTransport });
+ click(byText(host, "button", "Presets"));
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(true);
+ });
+
+ it("stops it again on the way out", () => {
+ const onAuditionTransport = vi.fn();
+ const { host } = mount({ chain: chainOf("peaking"), onAuditionTransport });
+ click(byText(host, "button", "Presets"));
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
+ act(() => {
+ host
+ .querySelector(".hf-fx-preset-menu")
+ ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
+ });
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(false);
+ });
+
+ it("stops it when the preset is applied, rather than playing on", () => {
+ // The click means "keep this", not "and carry on playing from wherever
+ // the audition reached".
+ const onAuditionTransport = vi.fn();
+ const { host } = mount({ chain: { version: 1, nodes: [] }, onAuditionTransport });
+ click(byText(host, "button", "Presets"));
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
+ click(presetButton(host, "telephone"));
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(false);
+ });
+
+ it("stops it if the panel goes away mid-audition", () => {
+ const onAuditionTransport = vi.fn();
+ const { host, root } = mount({ chain: chainOf("peaking"), onAuditionTransport });
+ click(byText(host, "button", "Presets"));
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
+ act(() => root.unmount());
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(false);
+ });
+ });
+
describe("getting back out of a menu", () => {
/** Escape, from inside the section, the way a keystroke really arrives. */
const escape = (host: HTMLElement) =>
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 477fe4d777..6853759fca 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -111,6 +111,14 @@ export interface FxSectionProps {
onAuditionLevel?(on: boolean): void;
/** Whether that measurement is running, so the button can say so. */
auditioningLevel?: boolean;
+ /**
+ * Start the transport for an audition, and stop it on the way out.
+ *
+ * An audition is written to the running graph, which is silent while the
+ * transport is paused — so without this, hovering a preset does nothing at all
+ * for a paused author.
+ */
+ onAuditionTransport?(on: boolean): void;
/** Structural edits and gesture-end writes; this is the one that persists. */
onChainChange(chain: HfAudioFxChain): void;
/** Continuous updates while a control is being dragged. */
@@ -156,6 +164,7 @@ export function FxSection({
onAutomatePreset,
onRemovePresetAutomation,
automatedPresets,
+ onAuditionTransport,
}: FxSectionProps) {
const presetAutomated = automatedPresets ?? new Set();
// Falls back to the persisting write when no preview handler is supplied, which
@@ -228,12 +237,18 @@ export function FxSection({
if (make) {
auditionBase.current ??= chain;
onChainPreview(make(auditionBase.current));
+ // After the chain is in the graph, not before: starting the transport
+ // first plays a moment of the un-auditioned mix.
+ onAuditionTransport?.(true);
} else if (auditionBase.current) {
+ // Stop before reverting, for the mirror of that reason — the last thing
+ // heard should be the preset, not a frame of the chain coming back.
+ onAuditionTransport?.(false);
onChainPreview(auditionBase.current);
auditionBase.current = null;
}
},
- [chain, onChainPreview],
+ [chain, onChainPreview, onAuditionTransport],
);
/**
@@ -253,9 +268,14 @@ export function FxSection({
// Leaving by any route other than the pointer — the element deselected, the
// panel closed — would otherwise leave the audition playing over a chain the
// document does not have.
+ const transportRef = useRef(onAuditionTransport);
+ transportRef.current = onAuditionTransport;
useEffect(
() => () => {
- if (auditionBase.current) previewRef.current?.(auditionBase.current);
+ if (auditionBase.current) {
+ transportRef.current?.(false);
+ previewRef.current?.(auditionBase.current);
+ }
},
[],
);
@@ -272,13 +292,14 @@ export function FxSection({
// old chain back over the write that just landed is a race the author
// hears as the preset arriving and then leaving again.
auditionBase.current = null;
+ onAuditionTransport?.(false);
mutate(next.nodes);
// Land on the first node the preset wrote, so the author can hear what
// arrived and immediately see what it is made of.
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === preset.id));
setPicking(false);
},
- [chain, mutate],
+ [chain, mutate, onAuditionTransport],
);
/**
@@ -322,21 +343,23 @@ export function FxSection({
const addJob = useCallback(
(job: HfAudioFxJob) => {
auditionBase.current = null;
+ onAuditionTransport?.(false);
mutate(withJob(chain, job).nodes);
setOpenNode(chain.nodes.length);
setAdding(false);
},
- [chain, mutate, withJob],
+ [chain, mutate, withJob, onAuditionTransport],
);
const addEffect = useCallback(
(type: string) => {
auditionBase.current = null;
+ onAuditionTransport?.(false);
mutate(withEffect(chain, type).nodes);
setOpenNode(chain.nodes.length);
setAdding(false);
},
- [chain, mutate, withEffect],
+ [chain, mutate, withEffect, onAuditionTransport],
);
const updateNode = useCallback(
@@ -458,11 +481,12 @@ export function FxSection({
const addEq = useCallback(() => {
auditionBase.current = null;
+ onAuditionTransport?.(false);
const { chain: next, eqId } = addAudioEq(chain);
mutate(next.nodes);
setOpenEq(eqId);
setAdding(false);
- }, [chain, mutate]);
+ }, [chain, mutate, onAuditionTransport]);
// Dragging a fader is heard immediately and written once on release, the same
// split every other control in the rack uses.
diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts
index 23972c1c0d..5d60f68f14 100644
--- a/packages/studio/src/player/hooks/useTimelinePlayer.ts
+++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts
@@ -383,8 +383,20 @@ export function useTimelinePlayer() {
seek(state.requestedSeekTime);
usePlayerStore.getState().clearSeekRequest();
}
+ // Play or stop from outside the loop — the FX rack auditioning a preset
+ // while paused, which is silent otherwise. `returnTo` puts the playhead
+ // back where the request found it: hovering is not an edit.
+ const request = state.playbackRequest;
+ if (request && request.nonce !== prev.playbackRequest?.nonce) {
+ if (request.playing) play();
+ else {
+ pause();
+ if (request.returnTo !== null) seek(request.returnTo);
+ }
+ usePlayerStore.getState().clearPlaybackRequest();
+ }
});
- }, [seek]);
+ }, [seek, play, pause]);
const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } =
usePlaybackKeyboard({
iframeRef,
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index f7d8abe4a2..167d5b8618 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -163,6 +163,22 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice {
requestSeek: (time: number) => void;
clearSeekRequest: () => void;
+ /**
+ * Request the transport start or stop from outside the player loop.
+ *
+ * The FX rack auditions a preset by writing it to the running graph, which is
+ * silent while the transport is paused — so hovering one has to start
+ * playback, and leaving has to put the playhead back where it was. Hovering is
+ * not an edit and must not cost the author their place.
+ *
+ * A nonce rather than a bare boolean: two hovers in a row both want play, and
+ * without it the second request is indistinguishable from the first having
+ * already been served.
+ */
+ playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
+ requestPlayback: (playing: boolean, returnTo?: number | null) => void;
+ clearPlaybackRequest: () => void;
+
/**
* Request the timeline to scroll a clip into view (e.g. clicking an
* already-added asset card in the sidebar). Consumed and cleared by
@@ -336,6 +352,13 @@ export const usePlayerStore = create((set, get) => ({
requestSeek: (time) => set({ requestedSeekTime: time }),
clearSeekRequest: () => set({ requestedSeekTime: null }),
+ playbackRequest: null,
+ requestPlayback: (playing, returnTo = null) =>
+ set((s) => ({
+ playbackRequest: { playing, returnTo, nonce: (s.playbackRequest?.nonce ?? 0) + 1 },
+ })),
+ clearPlaybackRequest: () => set({ playbackRequest: null }),
+
clipRevealRequest: null,
requestClipReveal: (elementId) =>
set((s) => ({
From 5a0b389b1a7b577f78c80dd6d1d967d15350c774 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 14:02:16 -0700
Subject: [PATCH 16/21] feat(core): add the Doofus Worble preset
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A chorus with its wobble dialled up past width and into the effect
itself: 14.6 ms spread, 2.57 ms depth, 10 Hz — the top of the speed
range — and fully wet, so no straight signal is left to anchor the pitch.
Character family, beside Telephone and Megaphone.
Measured on a steady 440 Hz tone, which is where pitch modulation is
legible: dry holds at 420 Hz across the whole take, worbled swings
**380–500 Hz**.
Worth recording, because it nearly read as a broken preset: the first
measurement used short-window energy and reported almost no change (0.723
dry against 0.732). That is correct and irrelevant — a chorus at mix 1
modulates PITCH, and its effect on amplitude is close to nil. The metric
was wrong, not the preset.
`PRESET_PROBLEM` gains its line in the same commit — `audioFxCopy.test.ts`
requires one for every shipped preset, so the catalogue cannot grow an
entry the shelf would render without plain language.
core 1770 · studio 3716 + 18 todo.
---
packages/core/src/audioFxCopy.ts | 1 +
packages/core/src/audioFxPresets.ts | 16 ++++++++++++++++
2 files changed, 17 insertions(+)
diff --git a/packages/core/src/audioFxCopy.ts b/packages/core/src/audioFxCopy.ts
index f040a4b2c6..12c23571d9 100644
--- a/packages/core/src/audioFxCopy.ts
+++ b/packages/core/src/audioFxCopy.ts
@@ -327,6 +327,7 @@ export const PRESET_PROBLEM: Record = {
"lofi-tape": "Make it sound like an old tape",
"pa-system": "Make it sound like a station announcement",
intercom: "Make it sound like a door intercom",
+ "doofus-worble": "Make it wobble like it is seasick",
"room-tight": "It sounds dry and stuck to the speaker",
"room-natural": "It should sound like a real place",
hall: "It should sound far away and big",
diff --git a/packages/core/src/audioFxPresets.ts b/packages/core/src/audioFxPresets.ts
index 4f49d45772..ceb3fdfd15 100644
--- a/packages/core/src/audioFxPresets.ts
+++ b/packages/core/src/audioFxPresets.ts
@@ -292,6 +292,22 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
{ type: "peaking", label: "Panel Honk", params: { frequency: 2000, gain: 6, q: 2 } },
{ type: "bitcrush", label: "Crunch", params: { bits: 11, samples: 1, mix: 0.3 } },
]),
+ // The chorus with its wobble dialled up until it stops being width and starts
+ // being the effect: fast (10 Hz, the top of the range) and fully wet, so none
+ // of the straight signal is left to anchor the pitch.
+ preset(
+ "doofus-worble",
+ "character",
+ "Doofus Worble",
+ "Seasick and wobbling — no straight signal left.",
+ [
+ {
+ type: "chorus",
+ label: "Worble",
+ params: { delay: 14.6, depth: 2.57, speed: 10, mix: 1 },
+ },
+ ],
+ ),
// ---------------------------------------------------------------- space --
preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [
From 2e70999948efbe95245765adb9e1a3873b5a8da2 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 14:15:11 -0700
Subject: [PATCH 17/21] fix(core): make the character presets sound like
different things
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Megaphone sounds just like AM Radio" — correct, and the measurement
found a bigger problem behind it. A log sweep through each preset showed
BOTH flattened to a dead -19 dB line across the whole midband: the
saturation thresholds were low enough that hard clipping erased the
resonances underneath. The same chain with its clipper removed showed the
horn peaks standing +12 dB proud, so the shaping had always been there
and was being destroyed on the way out.
Every character preset's clipper is backed off to where its shaping
survives — telephone -18→-9, tannoy -16→-10, radio -15→-8, megaphone
-14→-5. That alone is most of the fix.
Then the ones that remained alike, by what physically distinguishes them:
- **AM Radio** was a second telephone: same band, same mid honk. A
receiver DIPS where a phone honks (the IF droop) and is boxy where a
phone is thin, so it gets a -5 dB scoop at 1.2 kHz and a low shelf, and
its band moves down and darker. The telephone keeps its band unchanged
— 300-3400 IS the G.712 passband, and it is the one preset whose
identity is the band itself.
- **Megaphone** sat inside the telephone's band. A bullhorn has no low end
at all: the band moves up to 700 Hz and the honk gets narrower and
louder at 1.9 kHz.
- **Tannoy** was a telephone with reverb. A concourse horn is bigger in
every direction — band out to 5 kHz, honk higher and harder at 2.4 kHz.
Measured every character preset against every other, before and after. No
pair now sounds more alike than the signal itself; the worst were
telephone/AM at -5.9 dB and AM/tannoy at -4.8 dB, and telephone/megaphone
reached -3.2 mid-fix. Now every pair is positive — telephone/megaphone
-3.2 → +5.6, AM/megaphone -0.2 → +11.3.
Also sets Tannoy's Concourse reverb to the requested size 0.54, damping
0.48 (was 0.5 / 0.7).
Worth recording for whoever tunes these next: measuring on narration is
almost useless here. Speech has little energy above 3 kHz, so a spectrum
probe on it reported the two presets as near-identical whatever I changed.
A log sweep is what showed the flattening, and an A-vs-B difference on
speech is what confirms it.
core 1770 passing.
---
packages/core/src/audioFxPresets.ts | 53 +++++++++++++++++++++--------
1 file changed, 39 insertions(+), 14 deletions(-)
diff --git a/packages/core/src/audioFxPresets.ts b/packages/core/src/audioFxPresets.ts
index ceb3fdfd15..0a20738a15 100644
--- a/packages/core/src/audioFxPresets.ts
+++ b/packages/core/src/audioFxPresets.ts
@@ -197,19 +197,32 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
{
type: "saturate",
label: "Circuit Grit",
- params: { type: "tanh", threshold: -18, output: -2 },
+ params: { type: "tanh", threshold: -9, output: -2 },
},
],
),
preset("radio-am", "character", "AM Radio", "Narrow, gritty and a little crushed.", [
- { type: "highpass", label: "Strip the Bass", params: { frequency: 400, q: 0.707, poles: "2" } },
+ // Narrower than the megaphone at BOTH ends, which is most of the difference
+ // between them: an AM channel is a few kHz wide and the receiver rolls off
+ // well before a horn does.
+ { type: "highpass", label: "Strip the Bass", params: { frequency: 220, q: 0.707, poles: "2" } },
{
type: "lowpass",
label: "Strip the Treble",
- params: { frequency: 3000, q: 0.707, poles: "2" },
+ params: { frequency: 2200, q: 0.707, poles: "2" },
},
- { type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -15, output: -2 } },
- { type: "bitcrush", label: "Crunch", params: { bits: 10, samples: 1, mix: 0.25 } },
+ // Where the telephone honks, a receiver DIPS: the IF filter's droop, and the
+ // reason the two stop sounding alike. Telephone's band is its identity (it
+ // is the G.712 passband), so the radio is what moves.
+ { type: "peaking", label: "IF Droop", params: { frequency: 1200, gain: -5, q: 0.9 } },
+ // And a lift at the bottom of the band — AM is boxy where a phone is thin.
+ { type: "lowshelf", label: "Boxy", params: { frequency: 500, gain: 4 } },
+ // Soft — a receiver compressing, not a driver being overdriven. `tanh`
+ // rounds the peaks where the megaphone's `hard` clips them flat.
+ { type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -8, output: -1 } },
+ // The crush is the AM signature: quantisation noise reads as carrier hiss,
+ // and it is the one thing the megaphone has none of.
+ { type: "bitcrush", label: "Carrier Hiss", params: { bits: 8, samples: 1, mix: 0.45 } },
]),
preset(
"megaphone",
@@ -220,20 +233,30 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
{
type: "highpass",
label: "Strip the Bass",
- params: { frequency: 500, q: 0.707, poles: "2" },
+ params: { frequency: 700, q: 0.707, poles: "2" },
},
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 4000, q: 0.707, poles: "2" },
},
- { type: "peaking", label: "Horn Honk", params: { frequency: 1800, gain: 8, q: 1.5 } },
+ // A horn is a resonant tube and that resonance IS the sound: one narrow
+ // peak with a second ringing above it, where the radio has none at all.
+ { type: "peaking", label: "Horn Honk", params: { frequency: 1900, gain: 14, q: 3 } },
+ { type: "peaking", label: "Horn Ring", params: { frequency: 3200, gain: 6, q: 3 } },
+ // A driver pushed past its limit — flat-topped, not rounded. Measured on a
+ // log sweep, the threshold is the whole ballgame: at -14 the clipper
+ // flattened the response to a dead -19 dB line and ERASED the horn peaks
+ // above, leaving this indistinguishable from AM Radio. Backed off until
+ // the resonance survives the clipping that is supposed to sit on top of it.
{
type: "saturate",
label: "Overdrive",
- params: { type: "hard", threshold: -12, output: -3 },
+ params: { type: "hard", threshold: -5, output: -3 },
},
- { type: "delay", label: "Horn Slap", params: { time: 40, feedback: 0.15, mix: 0.15 } },
+ // The outdoor reflection that comes back off whatever is being shouted at.
+ // Far enough to be a slap rather than a thickening.
+ { type: "delay", label: "Horn Slap", params: { time: 65, feedback: 0.2, mix: 0.28 } },
],
),
preset(
@@ -259,22 +282,24 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
],
),
preset("pa-system", "character", "Tannoy", "Announced across a concourse.", [
- { type: "highpass", label: "Strip the Bass", params: { frequency: 350, q: 0.707, poles: "2" } },
+ { type: "highpass", label: "Strip the Bass", params: { frequency: 250, q: 0.707, poles: "2" } },
{
type: "lowpass",
label: "Strip the Treble",
- params: { frequency: 3500, q: 0.707, poles: "2" },
+ params: { frequency: 5000, q: 0.707, poles: "2" },
},
- { type: "peaking", label: "Tannoy Honk", params: { frequency: 1500, gain: 5, q: 1.2 } },
+ // Higher and harder than the telephone's honk, which is what a big horn
+ // does — and it keeps the top the phone throws away.
+ { type: "peaking", label: "Tannoy Honk", params: { frequency: 2400, gain: 9, q: 2 } },
{
type: "saturate",
label: "Driver Grit",
- params: { type: "tanh", threshold: -16, output: -1 },
+ params: { type: "tanh", threshold: -10, output: -1 },
},
{
type: "reverb",
label: "Concourse",
- params: { size: 0.5, damping: 0.7, wet: 0.25, dry: 0.8 },
+ params: { size: 0.54, damping: 0.48, wet: 0.25, dry: 0.8 },
},
]),
preset("intercom", "character", "Intercom", "Buzzed through a door panel, squelch and all.", [
From 89603598ddb9bcc2795010d54bfb8bea661d1a25 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 14:27:29 -0700
Subject: [PATCH 18/21] feat(studio): fold a preset shut, and give each one its
own title
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Collapse.** A preset is one thing the author added, and once it is set
the seven modules inside are detail — two presets in a rack was thirteen
cards deep before anything hand-built appeared. The title is now the
disclosure, and folded it carries the node count, which is what keeps a
collapsed preset reading as a chain rather than one opaque effect.
Open by default: a preset that arrives already hidden is one nobody
learns they can edit. Collapsed by preset id rather than by index, so it
survives a reorder. The whole-preset controls stay reachable while
folded — collapsing hides the detail, not the preset.
**A title treatment per preset.** The rack already letters by FAMILY,
which answers "what kind of thing is this". This answers a different
question: a preset is a character, and the point of Telephone or
Megaphone is that you know what it sounds like before you play it. A
phone's band is narrow, so is its tracking; a bullhorn is heavy, tight
and leaning forward; a tape is worn and slightly off. The bracket's left
edge carries the same colour.
Deliberately not a colour free-for-all. Every hue sits in the same narrow
lightness band as the family tints — a test enforces saturation ≤ 50%
and lightness 66–78% — because the panel already spends saturation on
"automated" and "bypassed", and a per-preset colour outside that reads as
status. The corrective families are plainer than the character ones on
purpose: a costume on "Cut Rumble" promises a character it does not add.
`fxPresetStyle` falls back rather than failing, so the catalogue can grow
without this file — but tests assert every shipped preset has an entry
and no entry outlives its preset, and that no two character presets share
a treatment.
Verified in a running studio: Telephone renders mono/400/3px in cyan and
Megaphone sans/900/italic/tight in warm orange; folding Telephone left 0
of its 7 nodes showing with the count reading 7, and left Megaphone's 6
alone.
Falsified: a collapse that hides nothing, two presets sharing a
treatment, and a colour outside the band each fail a test.
studio 3725 passing, 18 todo · core 1770.
---
.../editor/propertyPanelFxPresetStyle.test.ts | 50 ++++++++++
.../editor/propertyPanelFxPresetStyle.ts | 93 +++++++++++++++++++
.../editor/propertyPanelFxSection.test.tsx | 59 +++++++++++-
.../editor/propertyPanelFxSection.tsx | 58 +++++++++++-
4 files changed, 255 insertions(+), 5 deletions(-)
create mode 100644 packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
create mode 100644 packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
new file mode 100644
index 0000000000..4198be4e86
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
@@ -0,0 +1,50 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from "vitest";
+import { HF_AUDIO_FX_PRESETS } from "@hyperframes/core/audio-fx-presets";
+import {
+ FX_PRESET_STYLE,
+ FX_PRESET_STYLE_DEFAULT,
+ fxPresetStyle,
+} from "./propertyPanelFxPresetStyle.js";
+
+describe("per-preset title treatments", () => {
+ it("styles every preset the catalogue ships", () => {
+ // An unstyled preset is not broken — it falls back — but it is a row that
+ // silently opts out of the design, which nobody would notice.
+ const missing = HF_AUDIO_FX_PRESETS.filter((p) => !FX_PRESET_STYLE[p.id]).map((p) => p.id);
+ expect(missing).toEqual([]);
+ });
+
+ it("styles no preset the catalogue does not", () => {
+ // Dead entries for renamed or removed presets read as coverage.
+ const shipped = new Set(HF_AUDIO_FX_PRESETS.map((p) => p.id));
+ expect(Object.keys(FX_PRESET_STYLE).filter((id) => !shipped.has(id))).toEqual([]);
+ });
+
+ it("gives the character presets treatments that differ from each other", () => {
+ // The whole point: a preset is a character, and Telephone should not look
+ // like Megaphone. The corrective families may legitimately share a look.
+ const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character");
+ const looks = new Set(character.map((p) => `${fxPresetStyle(p.id).type}`));
+ expect(looks.size).toBe(character.length);
+ });
+
+ it("keeps every colour in one narrow lightness band", () => {
+ // A per-preset colour free-for-all would read as status. These sit where the
+ // family tints do, and the panel already spends saturation on "automated"
+ // and "bypassed".
+ for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
+ const match = /hsl\(\s*\d+,\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
+ expect(match, `${id} is not a plain hsl() colour`).toBeTruthy();
+ const saturation = Number(match?.[1]);
+ const lightness = Number(match?.[2]);
+ expect(saturation, `${id} is too saturated to be type`).toBeLessThanOrEqual(50);
+ expect(lightness, `${id} is too dark to read on the panel`).toBeGreaterThanOrEqual(66);
+ expect(lightness, `${id} is too light to sit beside the others`).toBeLessThanOrEqual(78);
+ }
+ });
+
+ it("falls back rather than failing for a preset it does not know", () => {
+ expect(fxPresetStyle("not-a-preset")).toBe(FX_PRESET_STYLE_DEFAULT);
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
new file mode 100644
index 0000000000..6aee7a8e5c
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
@@ -0,0 +1,93 @@
+/**
+ * A title treatment per preset — the label as a small piece of design rather
+ * than eighteen rows of the same condensed caps.
+ *
+ * The rack already letters by FAMILY (`propertyPanelFxFamily.ts`), which answers
+ * "what kind of thing is this". This answers a different question: a preset is a
+ * character, and the point of Telephone or Megaphone is that you know what it
+ * sounds like before you play it. Type can carry that — a bullhorn's name should
+ * look shouted, a tape's should look worn.
+ *
+ * Deliberately NOT a per-preset colour free-for-all. Each hue sits in the same
+ * narrow lightness band as the family tints so nothing reads as a status colour,
+ * and the panel already spends saturation on "automated" and "bypassed".
+ *
+ * A preset with no entry falls back to the neutral treatment, so the catalogue
+ * can grow without this file — an unstyled preset looks plain, not broken.
+ */
+
+export interface FxPresetStyle {
+ /** Tailwind classes for the title itself. */
+ type: string;
+ /** The title's colour, and the bracket's left edge. */
+ color: string;
+}
+
+/** Plain, and what any preset without its own entry gets. */
+export const FX_PRESET_STYLE_DEFAULT: FxPresetStyle = {
+ type: "font-mono uppercase tracking-wide",
+ color: "hsl(220, 12%, 68%)",
+};
+
+export const FX_PRESET_STYLE: Record = {
+ // --- voice: the ones you reach for to sound like yourself, only better -----
+ // Nothing stylised. These are corrective, and a costume on the title would
+ // promise a character they deliberately do not add.
+ "voice-clean": { type: "font-sans font-medium tracking-tight", color: "hsl(155, 34%, 70%)" },
+ "voice-broadcast": {
+ type: "font-sans font-bold uppercase tracking-[0.16em]",
+ color: "hsl(155, 34%, 74%)",
+ },
+ "voice-warm": { type: "font-serif italic tracking-normal", color: "hsl(28, 40%, 74%)" },
+
+ // --- repair: workshop labels. Mono, plain, nothing decorative -------------
+ "rumble-cut": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
+ "room-gate": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
+ "boom-tame": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
+ "harsh-tame": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
+
+ // --- character: the costumes. This is where type does the work ------------
+ // A phone's band is narrow; so is the tracking.
+ telephone: { type: "font-mono uppercase tracking-[0.3em]", color: "hsl(190, 34%, 70%)" },
+ // Period radio lettering — a serif, spaced like a dial face.
+ "radio-am": { type: "font-serif uppercase tracking-[0.22em]", color: "hsl(38, 40%, 72%)" },
+ // Shouted: heavy, tight, leaning forward.
+ megaphone: {
+ type: "font-sans font-black italic uppercase tracking-tight",
+ color: "hsl(14, 46%, 70%)",
+ },
+ // Worn and slightly off — the italic serif is the closest thing to a wobble.
+ "lofi-tape": { type: "font-serif italic tracking-wide", color: "hsl(36, 30%, 68%)" },
+ // Institutional signage: wide, even, impersonal.
+ "pa-system": {
+ type: "font-sans font-semibold uppercase tracking-[0.26em]",
+ color: "hsl(210, 26%, 72%)",
+ },
+ // Small, boxed-in, squeezed through a grille.
+ intercom: { type: "font-mono font-bold uppercase tracking-tighter", color: "hsl(96, 26%, 68%)" },
+ // The name is a joke and the type should be in on it.
+ "doofus-worble": {
+ type: "font-serif font-bold italic tracking-[0.12em]",
+ color: "hsl(286, 38%, 74%)",
+ },
+
+ // --- space: rooms. Light and wide, because that is what space looks like ---
+ "room-tight": {
+ type: "font-sans font-light uppercase tracking-[0.2em]",
+ color: "hsl(250, 26%, 72%)",
+ },
+ "room-natural": {
+ type: "font-sans font-light uppercase tracking-[0.24em]",
+ color: "hsl(250, 26%, 74%)",
+ },
+ hall: {
+ type: "font-sans font-extralight uppercase tracking-[0.34em]",
+ color: "hsl(250, 28%, 76%)",
+ },
+ "slap-echo": { type: "font-mono uppercase tracking-[0.28em]", color: "hsl(268, 30%, 72%)" },
+ "dub-throw": { type: "font-mono italic tracking-[0.3em]", color: "hsl(268, 34%, 74%)" },
+};
+
+export function fxPresetStyle(presetId: string): FxPresetStyle {
+ return FX_PRESET_STYLE[presetId] ?? FX_PRESET_STYLE_DEFAULT;
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index e395dd8787..281946a1bc 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -11,6 +11,7 @@ import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
import { audioFxProfileStrength } from "@hyperframes/core/audio-fx-profiles";
+import { fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
/**
@@ -413,7 +414,8 @@ describe("FxSection chain", () => {
const run = after.querySelector("[data-fx-preset='telephone']");
expect(run).toBeTruthy();
- expect(run?.querySelector(".hf-fx-preset-run-label")?.textContent).toBe("Telephone");
+ // The label carries a disclosure caret, so match the name inside it.
+ expect(run?.querySelector(".hf-fx-preset-run-label")?.textContent).toContain("Telephone");
expect(run?.querySelectorAll(".hf-fx-node")).toHaveLength(written.length);
});
@@ -696,6 +698,61 @@ describe("FxSection chain", () => {
expect(item?.querySelector(".hf-fx-preset-name")?.textContent).toBe("Telephone");
});
+ describe("folding a preset shut", () => {
+ const applied = (): HfAudioFxChain => {
+ const preset = getAudioFxPreset("telephone");
+ if (!preset) throw new Error("no telephone preset");
+ return applyAudioFxPreset({ version: 1, nodes: [] }, preset);
+ };
+ const bracket = (host: HTMLElement) => host.querySelector("[data-fx-preset='telephone']");
+
+ it("hides what it contains, and says how much is in there", () => {
+ // A preset is one thing the author added; once it is set, the seven
+ // modules inside are detail. Two presets in a rack was thirteen cards
+ // deep before anything hand-built appeared.
+ const { host } = mount({ chain: applied() });
+ const nodes = bracket(host)?.querySelectorAll(".hf-fx-node").length ?? 0;
+ expect(nodes).toBeGreaterThan(1);
+
+ click(bracket(host)?.querySelector(".hf-fx-preset-run-label"));
+ expect(bracket(host)?.querySelectorAll(".hf-fx-node")).toHaveLength(0);
+ // The count is what says it is still a chain rather than one opaque effect.
+ expect(bracket(host)?.querySelector(".hf-fx-preset-run-count")?.textContent).toBe(
+ String(nodes),
+ );
+ });
+
+ it("arrives open, so nobody has to discover it is a chain", () => {
+ const { host } = mount({ chain: applied() });
+ expect(bracket(host)?.hasAttribute("data-collapsed")).toBe(false);
+ expect(
+ bracket(host)?.querySelector(".hf-fx-preset-run-label")?.getAttribute("aria-expanded"),
+ ).toBe("true");
+ });
+
+ it("keeps the whole-preset controls reachable while folded", () => {
+ // Collapsing hides the detail, not the preset — switching it off or
+ // taking it out has to stay possible without unfolding first.
+ const { host } = mount({ chain: applied() });
+ click(bracket(host)?.querySelector(".hf-fx-preset-run-label"));
+ expect(bracket(host)?.querySelector(".hf-fx-preset-run-toggle")).toBeTruthy();
+ expect(bracket(host)?.querySelector(".hf-fx-preset-run-remove")).toBeTruthy();
+ });
+
+ it("gives each preset its own title treatment", () => {
+ // A preset is a character, and the point of Telephone or Megaphone is
+ // that you know what it sounds like before you play it. Type carries that.
+ const { host } = mount({ chain: applied() });
+ const label = bracket(host)?.querySelector(".hf-fx-preset-run-label");
+ const styled = fxPresetStyle("telephone");
+ expect(label?.className).toContain("tracking-[0.3em]");
+ expect(label?.style.color).toBeTruthy();
+ // And it differs from another preset's, or it is not a treatment.
+ expect(styled.type).not.toBe(fxPresetStyle("megaphone").type);
+ expect(styled.color).not.toBe(fxPresetStyle("megaphone").color);
+ });
+ });
+
describe("auditioning while the transport is paused", () => {
it("starts playback so a paused author can hear the preset at all", () => {
// The audition is written to the running graph, which is silent while the
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 6853759fca..f31e2a6c11 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -35,6 +35,7 @@ import {
type HfAudioFxJob,
} from "@hyperframes/core/audio-fx-jobs";
import { FxParamRow } from "./propertyPanelFxControls.js";
+import { fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -462,6 +463,17 @@ export function FxSection({
return out;
}, [handBuilt]);
+ /**
+ * Preset runs the author has folded shut.
+ *
+ * A preset is one thing they added, and once it is set the seven modules
+ * inside are detail — a rack with two presets in it was thirteen cards deep
+ * before anything hand-built appeared. Collapsed by id rather than by index so
+ * it survives a reorder, and open by default: a preset that arrives already
+ * hidden is one nobody learns is a chain they can edit.
+ */
+ const [collapsedRuns, setCollapsedRuns] = useState>(new Set());
+
const eqIds = useMemo(() => audioEqIds(chain), [chain]);
/**
@@ -634,16 +646,54 @@ export function FxSection({
const amount = run.items[0]?.node.presetAmount;
const runAmount = typeof amount === "number" ? amount : 1;
const runOn = runAmount > 0;
+ const runKey = `${run.preset}-${run.items[0]?.i ?? 0}`;
+ const collapsed = collapsedRuns.has(runKey);
+ const style = fxPresetStyle(run.preset ?? "");
return (
-
+
+ setCollapsedRuns((was) => {
+ const next = new Set(was);
+ if (collapsed) next.delete(runKey);
+ else next.add(runKey);
+ return next;
+ })
+ }
+ >
+
+ {collapsed ? "\u25B8" : "\u25BE"}
+
{preset.label}
-
+ {/* Collapsed, the count is what says the preset is still a
+ chain rather than one opaque effect. */}
+ {collapsed ? (
+
+ {run.items.length}
+
+ ) : null}
+
{/* The whole preset, on or off. Partly-bypassed reads as off,
because "some of it is running" is not a state an author
set — it is one they arrived at, and the switch is how they
@@ -688,7 +738,7 @@ export function FxSection({
: undefined
}
/>
- {rows}
+ {collapsed ? null : rows}
);
})
From 05c108dd07d8b25dc9518baaffb45ef38a1c3c47 Mon Sep 17 00:00:00 2001
From: Vance Ingalls
Date: Mon, 10 Aug 2026 14:39:56 -0700
Subject: [PATCH 19/21] feat(studio): real faces for the preset titles, at a
size worth reading
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first pass styled titles with Tailwind's three generic families, so
eighteen presets came out as variations of the same two faces — weight
and tracking doing all the work. And at 10px a title was the same size as
the parameter rows under it, which is not a title.
Titles are 12–17px now, sized by character rather than uniformly: the
Megaphone is the loudest thing in the rack at 17px, Hall is 15px with
0.4em tracking so the word itself opens out, and the workshop presets
stay at 12px because a costume on "Cut Rumble" promises a character it
does not add.
Eight named faces, grouped by what they read as rather than by name —
condensed signage, geometric, typewriter, editorial serif, bookish serif,
theatrical display, engraved caps, terminal. Telephone gets the face with
no warmth, the tape gets one that looks struck, the Tannoy gets something
bolted to a wall, and Doofus Worble gets a display face with no restraint
at all.
They are SYSTEM faces, and that is a constraint rather than a preference:
the studio has no webfont pipeline. The 169 Google Fonts cached under
`~/.cache/hyperframes` belong to the CLI's composition build, and reaching
into them from a panel would be inventing a second one. Every stack ends
in a generic keyword and carries at least two fallbacks, which a test
enforces — a machine without Haettenschweiler lands on Arial Narrow, then
Impact, then sans-serif, rather than on the browser's default serif.
The face is applied as data rather than a class: Tailwind cannot name a
stack its config does not know, and adding eight to the config to style
one panel would put them in every autocomplete in the studio.
Verified in a running studio — five presets applied at once resolved to
five distinct faces (SF Mono, Haettenschweiler, Luminari, American
Typewriter, Copperplate) at 13–17px. Tests can assert the stack is
well-formed; they cannot tell you a face exists on the machine.
Falsified: a stack with no generic fallback, a title back at panel-row
size, and two character presets sharing a face each fail a test.
studio 3727 passing, 18 todo.
---
.../editor/propertyPanelFxPresetStyle.test.ts | 31 +++-
.../editor/propertyPanelFxPresetStyle.ts | 156 ++++++++++++++----
.../editor/propertyPanelFxSection.tsx | 11 +-
3 files changed, 163 insertions(+), 35 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
index 4198be4e86..22f1248ade 100644
--- a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
@@ -25,10 +25,39 @@ describe("per-preset title treatments", () => {
// The whole point: a preset is a character, and Telephone should not look
// like Megaphone. The corrective families may legitimately share a look.
const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character");
- const looks = new Set(character.map((p) => `${fxPresetStyle(p.id).type}`));
+ const looks = new Set(
+ character.map((p) => `${fxPresetStyle(p.id).type}|${fxPresetStyle(p.id).family}`),
+ );
expect(looks.size).toBe(character.length);
});
+ it("names a real font stack, ending in a generic the browser always has", () => {
+ // The studio has no webfont pipeline, so these are system faces — and a
+ // machine without the named one has to land somewhere deliberate rather
+ // than on the browser's default serif.
+ const generic = /(sans-serif|serif|monospace|fantasy|cursive|ui-monospace)\s*$/;
+ for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
+ expect(style.family, `${id} names no face`).toBeTruthy();
+ expect(style.family, `${id} has no generic fallback`).toMatch(generic);
+ // More than one option, or it is a single point of failure.
+ expect((style.family ?? "").split(",").length, `${id} has no fallback chain`).toBeGreaterThan(
+ 2,
+ );
+ }
+ });
+
+ it("sets a title size on every preset, and keeps it legible", () => {
+ // The panel's own rows are 10px; a title at that size is not a title. The
+ // ceiling is what still fits the bracket without wrapping.
+ for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
+ const size = /text-\[(\d+)px\]/.exec(style.type);
+ expect(size, `${id} sets no title size`).toBeTruthy();
+ const px = Number(size?.[1]);
+ expect(px, `${id} is too small to read as a title`).toBeGreaterThanOrEqual(12);
+ expect(px, `${id} is too large for the bracket`).toBeLessThanOrEqual(18);
+ }
+ });
+
it("keeps every colour in one narrow lightness band", () => {
// A per-preset colour free-for-all would read as status. These sit where the
// family tints do, and the panel already spends saturation on "automated"
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
index 6aee7a8e5c..e7cdc6e476 100644
--- a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
@@ -17,75 +17,167 @@
*/
export interface FxPresetStyle {
- /** Tailwind classes for the title itself. */
+ /** Tailwind classes for the title: weight, case, tracking, size. */
type: string;
/** The title's colour, and the bracket's left edge. */
color: string;
+ /**
+ * A real font stack, when the character wants one.
+ *
+ * Tailwind ships three generic families, and a set of presets styled only
+ * with those ends up variations of the same two faces. These are system
+ * faces with a documented fallback chain: the studio has no webfont
+ * pipeline, and the Google Fonts cache under `~/.cache/hyperframes` belongs
+ * to the CLI's composition build — reaching into it from the panel would be
+ * inventing a second one.
+ *
+ * Every stack ends in a generic keyword, so a machine without the named face
+ * still lands somewhere deliberate.
+ */
+ family?: string;
}
+/** Faces that ship with macOS and/or Windows, grouped by what they read as. */
+const FACE = {
+ /** Narrow industrial caps — signage, stencils, equipment panels. */
+ condensed: '"Haettenschweiler", "Arial Narrow", Impact, sans-serif',
+ /** Geometric and mechanical; reads as a machine rather than a voice. */
+ geometric: '"Futura", "Century Gothic", "Avenir Next", sans-serif',
+ /** Typewriter — struck, worn, slightly irregular. */
+ typewriter: '"American Typewriter", "Courier New", Courier, monospace',
+ /** High-contrast editorial serif; broadcast and print authority. */
+ editorial: '"Didot", "Bodoni 72", "Playfair Display", Georgia, serif',
+ /** Old-style serif with real warmth — books, not headlines. */
+ bookish: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
+ /** Display face with no restraint at all. Theatrical, and a bit absurd. */
+ theatrical: '"Luminari", "Papyrus", "Comic Sans MS", fantasy',
+ /** Engraved caps — plaques, institutions, things bolted to walls. */
+ engraved: '"Copperplate", "Optima", "Perpetua Titling MT", serif',
+ /** Terminal type: fixed, plain, no personality of its own. */
+ terminal: '"SF Mono", Consolas, Menlo, ui-monospace, monospace',
+} as const;
+
/** Plain, and what any preset without its own entry gets. */
export const FX_PRESET_STYLE_DEFAULT: FxPresetStyle = {
- type: "font-mono uppercase tracking-wide",
+ type: "text-[12px] uppercase tracking-wide",
color: "hsl(220, 12%, 68%)",
+ family: FACE.terminal,
};
export const FX_PRESET_STYLE: Record = {
- // --- voice: the ones you reach for to sound like yourself, only better -----
- // Nothing stylised. These are corrective, and a costume on the title would
- // promise a character they deliberately do not add.
- "voice-clean": { type: "font-sans font-medium tracking-tight", color: "hsl(155, 34%, 70%)" },
+ // --- voice: reach for these to sound like yourself, only better -----------
+ // Restrained rather than plain. These are corrective, and a costume would
+ // promise a character they deliberately do not add — but they still get a
+ // face, because "no styling at all" is what every other row in the panel has.
+ "voice-clean": {
+ type: "text-[13px] font-medium tracking-tight",
+ color: "hsl(155, 34%, 70%)",
+ family: FACE.geometric,
+ },
"voice-broadcast": {
- type: "font-sans font-bold uppercase tracking-[0.16em]",
+ type: "text-[13px] font-bold uppercase tracking-[0.14em]",
color: "hsl(155, 34%, 74%)",
+ family: FACE.editorial,
+ },
+ "voice-warm": {
+ type: "text-[14px] italic tracking-normal",
+ color: "hsl(28, 40%, 74%)",
+ family: FACE.bookish,
},
- "voice-warm": { type: "font-serif italic tracking-normal", color: "hsl(28, 40%, 74%)" },
- // --- repair: workshop labels. Mono, plain, nothing decorative -------------
- "rumble-cut": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
- "room-gate": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
- "boom-tame": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
- "harsh-tame": { type: "font-mono uppercase tracking-[0.18em]", color: "hsl(205, 28%, 70%)" },
+ // --- repair: workshop labels. Fixed, plain, nothing decorative ------------
+ "rumble-cut": {
+ type: "text-[12px] uppercase tracking-[0.18em]",
+ color: "hsl(205, 28%, 70%)",
+ family: FACE.terminal,
+ },
+ "room-gate": {
+ type: "text-[12px] uppercase tracking-[0.18em]",
+ color: "hsl(205, 28%, 70%)",
+ family: FACE.terminal,
+ },
+ "boom-tame": {
+ type: "text-[12px] uppercase tracking-[0.18em]",
+ color: "hsl(205, 28%, 70%)",
+ family: FACE.terminal,
+ },
+ "harsh-tame": {
+ type: "text-[12px] uppercase tracking-[0.18em]",
+ color: "hsl(205, 28%, 70%)",
+ family: FACE.terminal,
+ },
// --- character: the costumes. This is where type does the work ------------
- // A phone's band is narrow; so is the tracking.
- telephone: { type: "font-mono uppercase tracking-[0.3em]", color: "hsl(190, 34%, 70%)" },
- // Period radio lettering — a serif, spaced like a dial face.
- "radio-am": { type: "font-serif uppercase tracking-[0.22em]", color: "hsl(38, 40%, 72%)" },
- // Shouted: heavy, tight, leaning forward.
+ // A phone's band is narrow; so is the tracking, on a face with no warmth.
+ telephone: {
+ type: "text-[13px] uppercase tracking-[0.3em]",
+ color: "hsl(190, 34%, 70%)",
+ family: FACE.terminal,
+ },
+ // A dial face: high-contrast serif caps, spaced like printed frequencies.
+ "radio-am": {
+ type: "text-[14px] uppercase tracking-[0.24em]",
+ color: "hsl(38, 40%, 72%)",
+ family: FACE.editorial,
+ },
+ // Shouted through a horn — the heaviest, narrowest thing available, leaning.
megaphone: {
- type: "font-sans font-black italic uppercase tracking-tight",
+ type: "text-[17px] font-black italic uppercase tracking-tight",
color: "hsl(14, 46%, 70%)",
+ family: FACE.condensed,
+ },
+ // Struck on a machine, played back years later.
+ "lofi-tape": {
+ type: "text-[13px] tracking-wide",
+ color: "hsl(36, 30%, 68%)",
+ family: FACE.typewriter,
},
- // Worn and slightly off — the italic serif is the closest thing to a wobble.
- "lofi-tape": { type: "font-serif italic tracking-wide", color: "hsl(36, 30%, 68%)" },
- // Institutional signage: wide, even, impersonal.
+ // Bolted to a wall in a station concourse.
"pa-system": {
- type: "font-sans font-semibold uppercase tracking-[0.26em]",
+ type: "text-[13px] uppercase tracking-[0.26em]",
color: "hsl(210, 26%, 72%)",
+ family: FACE.engraved,
},
- // Small, boxed-in, squeezed through a grille.
- intercom: { type: "font-mono font-bold uppercase tracking-tighter", color: "hsl(96, 26%, 68%)" },
- // The name is a joke and the type should be in on it.
+ // Small, squeezed through a grille, no room for anything but the letters.
+ intercom: {
+ type: "text-[12px] font-bold uppercase tracking-tighter",
+ color: "hsl(96, 26%, 68%)",
+ family: FACE.terminal,
+ },
+ // The name is a joke and the type is in on it.
"doofus-worble": {
- type: "font-serif font-bold italic tracking-[0.12em]",
+ type: "text-[16px] tracking-[0.1em]",
color: "hsl(286, 38%, 74%)",
+ family: FACE.theatrical,
},
// --- space: rooms. Light and wide, because that is what space looks like ---
"room-tight": {
- type: "font-sans font-light uppercase tracking-[0.2em]",
+ type: "text-[13px] uppercase tracking-[0.2em]",
color: "hsl(250, 26%, 72%)",
+ family: FACE.geometric,
},
"room-natural": {
- type: "font-sans font-light uppercase tracking-[0.24em]",
+ type: "text-[13px] uppercase tracking-[0.26em]",
color: "hsl(250, 26%, 74%)",
+ family: FACE.geometric,
},
+ // The biggest room gets the widest setting — the word itself opens out.
hall: {
- type: "font-sans font-extralight uppercase tracking-[0.34em]",
+ type: "text-[15px] uppercase tracking-[0.4em]",
color: "hsl(250, 28%, 76%)",
+ family: FACE.engraved,
+ },
+ "slap-echo": {
+ type: "text-[13px] uppercase tracking-[0.28em]",
+ color: "hsl(268, 30%, 72%)",
+ family: FACE.geometric,
+ },
+ "dub-throw": {
+ type: "text-[14px] italic tracking-[0.3em]",
+ color: "hsl(268, 34%, 74%)",
+ family: FACE.editorial,
},
- "slap-echo": { type: "font-mono uppercase tracking-[0.28em]", color: "hsl(268, 30%, 72%)" },
- "dub-throw": { type: "font-mono italic tracking-[0.3em]", color: "hsl(268, 34%, 74%)" },
};
export function fxPresetStyle(presetId: string): FxPresetStyle {
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index f31e2a6c11..9fa9aea0fb 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -662,8 +662,15 @@ export function FxSection({
Date: Mon, 10 Aug 2026 14:50:55 -0700
Subject: [PATCH 20/21] feat(studio): vibrant preset colours, each with its own
background
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Saturation roughly doubles — the muted 26-46% band was chosen to stay out
of the way, and stayed so far out of it that the titles read as grey text
with a hint of tint. They are 68-92% now, and light enough (58-78%) to
carry on the panel's near-black ground.
Two hues moved rather than brightened. The voice presets sat at hue 155,
which is the accent (#3CE6AC is hue 160) — saturating them there would
have made "Clean Voice" read as automated or playing, which is what that
green means everywhere else in the panel. They are blues now, and a test
keeps every title at least 20° clear of the accent.
**Each preset also gets its own background**, derived from its title hue
rather than picked: nineteen hand-chosen pairs is nineteen chances for
one to clash with its own title, and a hue rotation cannot. Same hue at
22% saturation and 11% lightness, so a rack with several presets reads as
several regions instead of one long list, while staying dark enough that
every control on top of it is unaffected.
The repair family deliberately shares one colour and therefore one
background — they are workshop tools, not characters — so the uniqueness
test covers the character family only.
Verified in a running studio with five presets applied: five distinct
title colours over five matching washes, and the accent still reads as
the only green in the panel.
Falsified: a washed-out colour, a title on the accent's hue, a background
bright enough to fight the controls, and a background hue unhooked from
its title each fail a test.
studio 3730 passing, 18 todo.
---
.../editor/propertyPanelFxPresetStyle.test.ts | 57 ++++++++++++++---
.../editor/propertyPanelFxPresetStyle.ts | 62 +++++++++++++------
.../editor/propertyPanelFxSection.tsx | 12 +++-
3 files changed, 98 insertions(+), 33 deletions(-)
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
index 22f1248ade..61c3f10e10 100644
--- a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts
@@ -4,6 +4,7 @@ import { HF_AUDIO_FX_PRESETS } from "@hyperframes/core/audio-fx-presets";
import {
FX_PRESET_STYLE,
FX_PRESET_STYLE_DEFAULT,
+ fxPresetBackground,
fxPresetStyle,
} from "./propertyPanelFxPresetStyle.js";
@@ -58,21 +59,57 @@ describe("per-preset title treatments", () => {
}
});
- it("keeps every colour in one narrow lightness band", () => {
- // A per-preset colour free-for-all would read as status. These sit where the
- // family tints do, and the panel already spends saturation on "automated"
- // and "bypassed".
+ it("keeps every colour vibrant, and light enough to read on the panel", () => {
+ // The rack sits on #0C0C0E. A title has to carry real colour to be worth
+ // having, and still clear contrast against near-black.
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
- const match = /hsl\(\s*\d+,\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
+ const match = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
expect(match, `${id} is not a plain hsl() colour`).toBeTruthy();
- const saturation = Number(match?.[1]);
- const lightness = Number(match?.[2]);
- expect(saturation, `${id} is too saturated to be type`).toBeLessThanOrEqual(50);
- expect(lightness, `${id} is too dark to read on the panel`).toBeGreaterThanOrEqual(66);
- expect(lightness, `${id} is too light to sit beside the others`).toBeLessThanOrEqual(78);
+ const saturation = Number(match?.[1 + 1]);
+ const lightness = Number(match?.[3]);
+ expect(saturation, `${id} is too washed out to read as a colour`).toBeGreaterThanOrEqual(60);
+ expect(lightness, `${id} is too dark against the panel`).toBeGreaterThanOrEqual(58);
+ expect(lightness, `${id} is so light the hue disappears`).toBeLessThanOrEqual(78);
}
});
+ it("keeps the title hues clear of the accent, which means something else", () => {
+ // The panel spends #3CE6AC (hue 160) on "automated" and "playing". A title
+ // sitting on that hue reads as a status the preset does not have.
+ for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
+ const hue = Number(/hsl\(\s*(\d+),/.exec(style.color)?.[1]);
+ const distance = Math.min(Math.abs(hue - 160), 360 - Math.abs(hue - 160));
+ expect(distance, `${id} sits on the accent's hue`).toBeGreaterThan(20);
+ }
+ });
+
+ it("backs each preset with its own hue, dark enough to sit under the panel", () => {
+ // Derived from the title rather than picked, so a background cannot drift
+ // away from the title it belongs to.
+ const seen = new Set();
+ for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
+ const bg = fxPresetBackground(id);
+ expect(bg, `${id} has no background`).toBeTruthy();
+ const titleHue = /hsl\(\s*(\d+),/.exec(style.color)?.[1];
+ expect(bg, `${id}'s background is a different hue from its title`).toContain(
+ `hsl(${titleHue},`,
+ );
+ const lightness = Number(/,\s*(\d+)%\s*\)/.exec(bg ?? "")?.[1]);
+ expect(lightness, `${id}'s background would fight the controls on it`).toBeLessThanOrEqual(
+ 16,
+ );
+ seen.add(bg ?? "");
+ }
+ // Presets that share a title colour share a background — the repair family
+ // is deliberately uniform — but the character ones must not.
+ const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character");
+ expect(new Set(character.map((p) => fxPresetBackground(p.id))).size).toBe(character.length);
+ });
+
+ it("has no background for a preset it does not know", () => {
+ expect(fxPresetBackground("not-a-preset")).toBeNull();
+ });
+
it("falls back rather than failing for a preset it does not know", () => {
expect(fxPresetStyle("not-a-preset")).toBe(FX_PRESET_STYLE_DEFAULT);
});
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
index e7cdc6e476..65a46d0e4d 100644
--- a/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts
@@ -60,7 +60,7 @@ const FACE = {
/** Plain, and what any preset without its own entry gets. */
export const FX_PRESET_STYLE_DEFAULT: FxPresetStyle = {
type: "text-[12px] uppercase tracking-wide",
- color: "hsl(220, 12%, 68%)",
+ color: "hsl(220, 24%, 72%)",
family: FACE.terminal,
};
@@ -71,39 +71,39 @@ export const FX_PRESET_STYLE: Record = {
// face, because "no styling at all" is what every other row in the panel has.
"voice-clean": {
type: "text-[13px] font-medium tracking-tight",
- color: "hsl(155, 34%, 70%)",
+ color: "hsl(202, 82%, 66%)",
family: FACE.geometric,
},
"voice-broadcast": {
type: "text-[13px] font-bold uppercase tracking-[0.14em]",
- color: "hsl(155, 34%, 74%)",
+ color: "hsl(214, 84%, 70%)",
family: FACE.editorial,
},
"voice-warm": {
type: "text-[14px] italic tracking-normal",
- color: "hsl(28, 40%, 74%)",
+ color: "hsl(32, 88%, 66%)",
family: FACE.bookish,
},
// --- repair: workshop labels. Fixed, plain, nothing decorative ------------
"rumble-cut": {
type: "text-[12px] uppercase tracking-[0.18em]",
- color: "hsl(205, 28%, 70%)",
+ color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
"room-gate": {
type: "text-[12px] uppercase tracking-[0.18em]",
- color: "hsl(205, 28%, 70%)",
+ color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
"boom-tame": {
type: "text-[12px] uppercase tracking-[0.18em]",
- color: "hsl(205, 28%, 70%)",
+ color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
"harsh-tame": {
type: "text-[12px] uppercase tracking-[0.18em]",
- color: "hsl(205, 28%, 70%)",
+ color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
@@ -111,71 +111,71 @@ export const FX_PRESET_STYLE: Record = {
// A phone's band is narrow; so is the tracking, on a face with no warmth.
telephone: {
type: "text-[13px] uppercase tracking-[0.3em]",
- color: "hsl(190, 34%, 70%)",
+ color: "hsl(186, 85%, 62%)",
family: FACE.terminal,
},
// A dial face: high-contrast serif caps, spaced like printed frequencies.
"radio-am": {
type: "text-[14px] uppercase tracking-[0.24em]",
- color: "hsl(38, 40%, 72%)",
+ color: "hsl(42, 92%, 62%)",
family: FACE.editorial,
},
// Shouted through a horn — the heaviest, narrowest thing available, leaning.
megaphone: {
type: "text-[17px] font-black italic uppercase tracking-tight",
- color: "hsl(14, 46%, 70%)",
+ color: "hsl(12, 90%, 64%)",
family: FACE.condensed,
},
// Struck on a machine, played back years later.
"lofi-tape": {
type: "text-[13px] tracking-wide",
- color: "hsl(36, 30%, 68%)",
+ color: "hsl(28, 72%, 62%)",
family: FACE.typewriter,
},
// Bolted to a wall in a station concourse.
"pa-system": {
type: "text-[13px] uppercase tracking-[0.26em]",
- color: "hsl(210, 26%, 72%)",
+ color: "hsl(222, 80%, 70%)",
family: FACE.engraved,
},
// Small, squeezed through a grille, no room for anything but the letters.
intercom: {
type: "text-[12px] font-bold uppercase tracking-tighter",
- color: "hsl(96, 26%, 68%)",
+ color: "hsl(96, 68%, 60%)",
family: FACE.terminal,
},
// The name is a joke and the type is in on it.
"doofus-worble": {
type: "text-[16px] tracking-[0.1em]",
- color: "hsl(286, 38%, 74%)",
+ color: "hsl(288, 85%, 72%)",
family: FACE.theatrical,
},
// --- space: rooms. Light and wide, because that is what space looks like ---
"room-tight": {
type: "text-[13px] uppercase tracking-[0.2em]",
- color: "hsl(250, 26%, 72%)",
+ color: "hsl(252, 74%, 72%)",
family: FACE.geometric,
},
"room-natural": {
type: "text-[13px] uppercase tracking-[0.26em]",
- color: "hsl(250, 26%, 74%)",
+ color: "hsl(258, 78%, 72%)",
family: FACE.geometric,
},
// The biggest room gets the widest setting — the word itself opens out.
hall: {
type: "text-[15px] uppercase tracking-[0.4em]",
- color: "hsl(250, 28%, 76%)",
+ color: "hsl(246, 84%, 74%)",
family: FACE.engraved,
},
"slap-echo": {
type: "text-[13px] uppercase tracking-[0.28em]",
- color: "hsl(268, 30%, 72%)",
+ color: "hsl(274, 76%, 70%)",
family: FACE.geometric,
},
"dub-throw": {
type: "text-[14px] italic tracking-[0.3em]",
- color: "hsl(268, 34%, 74%)",
+ color: "hsl(312, 78%, 70%)",
family: FACE.editorial,
},
};
@@ -183,3 +183,25 @@ export const FX_PRESET_STYLE: Record = {
export function fxPresetStyle(presetId: string): FxPresetStyle {
return FX_PRESET_STYLE[presetId] ?? FX_PRESET_STYLE_DEFAULT;
}
+
+/**
+ * The wash behind a preset's bracket, derived from its title colour.
+ *
+ * Derived rather than picked: nineteen hand-chosen pairs is nineteen chances
+ * for one to clash with its own title, and a hue rotation cannot. Same hue,
+ * saturation pulled right down and lightness taken to near-black, so the panel
+ * reads as tinted rather than coloured — the rack sits on `#0C0C0E` and
+ * anything with real lightness here would fight every control on top of it.
+ *
+ * Returns a CSS colour, or null when the preset has no character of its own.
+ */
+export function fxPresetBackground(presetId: string): string | null {
+ const style = FX_PRESET_STYLE[presetId];
+ if (!style) return null;
+ const hsl = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
+ if (!hsl) return null;
+ const hue = hsl[1];
+ // 22% saturation at 11% lightness: present enough to tell two brackets apart
+ // at a glance, dark enough that white body text still clears WCAG AA on it.
+ return `hsl(${hue}, 22%, 11%)`;
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 9fa9aea0fb..23453a68bb 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -35,7 +35,7 @@ import {
type HfAudioFxJob,
} from "@hyperframes/core/audio-fx-jobs";
import { FxParamRow } from "./propertyPanelFxControls.js";
-import { fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
+import { fxPresetBackground, fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -649,6 +649,7 @@ export function FxSection({
const runKey = `${run.preset}-${run.items[0]?.i ?? 0}`;
const collapsed = collapsedRuns.has(runKey);
const style = fxPresetStyle(run.preset ?? "");
+ const background = fxPresetBackground(run.preset ?? "");
return (
Date: Mon, 10 Aug 2026 15:35:47 -0700
Subject: [PATCH 21/21] docs(plans): drop the README's pointers to the removed
preview generator
---
plans/audio-fx-ux/README.md | 23 ++++++++++-------------
1 file changed, 10 insertions(+), 13 deletions(-)
diff --git a/plans/audio-fx-ux/README.md b/plans/audio-fx-ux/README.md
index 5e3ebc2eb7..63589a49c2 100644
--- a/plans/audio-fx-ux/README.md
+++ b/plans/audio-fx-ux/README.md
@@ -5,17 +5,16 @@ routing, what is driven versus set. But information a casual author cannot read
is decoration, and the rack speaks entirely in Hz, dB and ratios. So the drawing
stays and the **language changes**.
-The plain-language layer over every effect in the registry now ships as
-`packages/core/src/audioFxCopy.ts`, with the coverage that used to gate this
-page — every effect, parameter and preset must have copy — as
-`audioFxCopy.test.ts`. `build-preview.mts` renders the review page from it
-**plus the real registry and preset catalogue**, including the shipped one-knob
-profiles — sampled off their real curves, so the page cannot disagree with what
-the knob does.
-
-```bash
-bun plans/audio-fx-ux/build-preview.mts /tmp/rack-ux.html
-```
+The plain-language layer over every effect in the registry ships as
+`packages/core/src/audioFxCopy.ts`, and the coverage that used to gate a review
+page — every effect, parameter and preset must have copy — is now
+`audioFxCopy.test.ts`, so it runs on every commit rather than when somebody
+remembers to regenerate a page.
+
+This document is the design record. What it describes is built: read it against
+the FX rack in the studio, or against `audioFxCopy.ts`, `audioFxJobs.ts`,
+`audioFxProfiles.ts` and the panel components under
+`packages/studio/src/components/editor/propertyPanelFx*`.
## The three rules
@@ -256,5 +255,3 @@ track quieter at full evenness, saturation's trim went the wrong way and made
"Warmth" mean "much quieter", and the gate did essentially nothing because
`release` was not in the profile at all. Measurements, method and the sweep that
found the last one are in `~/audio-fx-profiles-ab/README.md`.
-
-The stub that used to hold them, `plans/audio-fx-ux/copy.mts`, is gone.