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 }) => (
{GROUP_LABEL[group]} + {jobs.map((job) => ( + + ))} {defs.map((d) => ( + ) : ( +

+ Details — {registryDef.label} +

+ )} + {details || !primary ? ( + + ) : null} ) : null}
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 (
+ +
{rows}
); From 373cdbbcb81ada3f224857cd103b452eb344b5fd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 10 Aug 2026 13:04:52 -0700 Subject: [PATCH 12/21] feat: ramp a whole preset with one lane, and make Off its zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset's nodes share no automatable parameter, and its worklet effects (compressor, limiter) expose no AudioParams at all — Clean Voice could only ever have automated 3 of its 5 nodes. So there was nothing to aim a lane at, and no way to bring a preset in gradually. **The graph wraps each preset's run in a wet/dry pair.** The rest of the chain stays a strict series, which is right for an effect the author placed: it is either in the path or it is not. A preset is not one effect — it is several added as a unit, and "how much of it is applied" is a question about the unit. One crossfade answers it for every preset including the worklet ones, and it cannot go half-wrong the way seven lanes can. The dry leg bridges the whole run, so amount 0 is the untouched signal rather than a quieter version of the processed one. Consecutive nodes only, matching what the rack already brackets: a preset pulled apart by a reorder is no longer a unit, and wrapping across the gap would route the effect between its members through the dry leg too. **`fx.preset.` is a new lane target.** Parsed before the 3-part fx form, which it structurally is — with a reserved node id an effect can never have, since ids are minted `n1`, `n2`, …. It resolves only for a preset the chain actually carries, so a lane left behind by a removed preset is dropped at read time, the same contract an orphaned node lane has. **Off is amount 0, not `enabled: false`.** The switch and the lane are now the same value — one notion of how much of a preset is applied, with the switch at its two ends. Writing `enabled` would take the nodes out of the graph, which a lane cannot do part-way and cannot do without a rebuild that restarts the audio. The panel also grows an Amount slider, so half-applied is something an author can just set. Changing the amount is a values-only update, pushed into the running graph. A `presetAmount` in a chain is clamped to 0..1 on the way in: two gains in opposition, so past 1 the dry leg goes negative rather than the preset getting louder. Falsified: a blend ignoring the stored amount, an amount not pushed on update, and the clamp each fail a test. The round-trip — the §4 invariant that a new node field must be in BOTH the parser and the writer — had no test until a mutation survived one; dropping it from either half now fails. core 1770 (113 files) · studio 3703 + 18 todo · engine services 739 + 3. --- packages/core/src/audio/audioFxAutomation.ts | 15 ++- packages/core/src/audio/audioFxGraph.test.ts | 74 ++++++++++++ packages/core/src/audio/audioFxGraph.ts | 110 +++++++++++++++++- packages/core/src/audioAutomation.ts | 46 +++++++- packages/core/src/audioFx.ts | 23 ++++ packages/core/src/audioFxPresets.test.ts | 39 +++++++ packages/core/src/runtime/audioFx.ts | 4 +- packages/core/stubs/audio-fx-runtime-entry.ts | 12 +- .../editor/propertyPanelAudioFxGroup.tsx | 28 +++++ .../editor/propertyPanelFxSection.test.tsx | 89 ++++++++++++-- .../editor/propertyPanelFxSection.tsx | 88 ++++++++++++-- 11 files changed, 490 insertions(+), 38 deletions(-) diff --git a/packages/core/src/audio/audioFxAutomation.ts b/packages/core/src/audio/audioFxAutomation.ts index 94e7cca6b2..d818b961b6 100644 --- a/packages/core/src/audio/audioFxAutomation.ts +++ b/packages/core/src/audio/audioFxAutomation.ts @@ -245,13 +245,24 @@ export function scheduleChainAutomation( chain: HfAudioFxChain, nodes: readonly AutomatableNode[], timing: AutomationTiming, + /** The wet/dry blend around each preset run, from `FxChainHandle.presets`. */ + presets?: Record, ): FxParamTarget[] { const byId = new Map(nodes.filter((n) => n.id).map((n) => [n.id as string, n.handle])); const scheduled: FxParamTarget[] = []; for (const lane of automation.lanes) { const parsed = parseAutomationTarget(lane.target); - if (!parsed || parsed.kind !== "fx") continue; - const targets = byId.get(parsed.nodeId)?.automation?.[parsed.param]; + if (!parsed) continue; + // A whole-preset lane drives the wet/dry blend the graph wrapped its run in, + // rather than any node's parameter — which is the point of it: a preset's + // nodes share no automatable parameter, and its worklet effects expose none + // at all. + const targets = + parsed.kind === "preset" + ? presets?.[parsed.presetId] + : parsed.kind === "fx" + ? byId.get(parsed.nodeId)?.automation?.[parsed.param] + : undefined; if (!targets || targets.length === 0) continue; const range = resolveAutomationRange(lane.target, chain); if (!range) continue; diff --git a/packages/core/src/audio/audioFxGraph.test.ts b/packages/core/src/audio/audioFxGraph.test.ts index 181bf08269..06abfe92f8 100644 --- a/packages/core/src/audio/audioFxGraph.test.ts +++ b/packages/core/src/audio/audioFxGraph.test.ts @@ -624,3 +624,77 @@ describe("chain update keeps ids with their effects", () => { expect(handle.nodes.map((n) => n.id)).toEqual(["n2", "n1"]); }); }); + +describe("a preset's run is wrapped in a wet/dry blend", () => { + /** Two nodes from one preset, with an ordinary effect after them. */ + const chainWith = (amount?: number): HfAudioFxChain => ({ + version: 1, + nodes: [ + { + type: "highpass", + id: "p1", + fromPreset: "telephone", + enabled: true, + ...(amount === undefined ? {} : { presetAmount: amount }), + params: defaultAudioFxParams("highpass"), + }, + { + type: "lowpass", + id: "p2", + fromPreset: "telephone", + enabled: true, + params: defaultAudioFxParams("lowpass"), + }, + { type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") }, + ], + }); + + it("exposes one blend for the whole preset, not one per node", () => { + // The reason this exists: a preset's nodes share no automatable parameter, + // and its worklet effects expose no AudioParams at all, so there is nothing + // to aim a lane at node-by-node. + const built = buildFxChain(asCtx(ctx()), chainWith()); + expect(Object.keys(built.presets)).toEqual(["telephone"]); + // Two gains in opposition, the same shape an effect's own mix knob has. + expect(built.presets.telephone).toHaveLength(2); + }); + + it("blends dry against wet at the stored amount", () => { + const built = buildFxChain(asCtx(ctx()), chainWith(0.25)); + const [wet, dry] = built.presets.telephone ?? []; + expect(wet?.param.value).toBeCloseTo(0.25, 6); + expect(dry?.param.value).toBeCloseTo(0.75, 6); + }); + + it("is fully applied when nothing says otherwise", () => { + // Every chain written before this shipped means "all of it". + const [wet, dry] = buildFxChain(asCtx(ctx()), chainWith()).presets.telephone ?? []; + expect(wet?.param.value).toBe(1); + expect(dry?.param.value).toBe(0); + }); + + it("pushes a changed amount into the running graph rather than rebuilding", () => { + // Switching a preset off is a value change, and a rebuild would restart the + // audio underneath it. + const built = buildFxChain(asCtx(ctx()), chainWith(1)); + expect(built.update(chainWith(0))).toBe(true); + const [wet, dry] = built.presets.telephone ?? []; + expect(wet?.param.value).toBe(0); + expect(dry?.param.value).toBe(1); + }); + + it("wraps nothing around effects the author placed themselves", () => { + const built = buildFxChain(asCtx(ctx()), chain("peaking", "reverb")); + expect(Object.keys(built.presets)).toEqual([]); + }); + + it("unwires the blend on dispose", () => { + // The wrap belongs to the chain rather than to any effect, so it is not in + // `handles` — without this a rebuild leaves a crossfade connected to the + // graph it used to bridge. + const c = ctx(); + buildFxChain(asCtx(c), chainWith(0.5)).dispose(); + const live = c.created.filter((n) => n.kind === "gain" && !n.disconnected); + expect(live).toEqual([]); + }); +}); diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts index c49db5e468..b61b014352 100644 --- a/packages/core/src/audio/audioFxGraph.ts +++ b/packages/core/src/audio/audioFxGraph.ts @@ -13,6 +13,7 @@ import { getAudioFxDef, normalizeAudioFxParams, type HfAudioFxChain, + type HfAudioFxNode, type HfAudioFxParamValues, } from "../audioFx.js"; import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js"; @@ -535,11 +536,45 @@ export interface FxChainHandle { output: AudioNode; /** Built effects in chain order, carrying the node ids lanes address. */ nodes: { id?: string; type: string; handle: FxNodeHandle }[]; + /** + * The wet/dry blend around each preset run, by preset id — where a + * whole-preset lane writes. Two gains in opposition, the same shape + * `mixTargets` builds for an effect's own mix knob. + */ + presets: Record; /** Re-parameterise in place when the shape is unchanged; false if a rebuild is needed. */ update(chain: HfAudioFxChain): boolean; dispose(): void; } +/** + * Consecutive nodes grouped by the preset that wrote them. + * + * `amount` comes off the nodes themselves — a preset is bypassed by setting its + * members' `enabled` to false everywhere else in the codebase, and the wrap has + * to agree with that or the switch and the lane would fight. Absent means fully + * applied, which is what every chain written before this shipped means. + */ +function presetRuns( + nodes: readonly HfAudioFxNode[], +): { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] { + const out: { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] = []; + for (const node of nodes) { + const preset = node.fromPreset; + const last = out.at(-1); + if (last && last.preset === preset) last.nodes.push(node); + else { + const amount = typeof node.presetAmount === "number" ? node.presetAmount : 1; + out.push({ + ...(preset ? { preset } : {}), + amount: Math.min(1, Math.max(0, amount)), + nodes: [node], + }); + } + } + return out; +} + /** * A signature of everything that changes the graph's *shape* rather than its * parameter values. When this is unchanged an update can just push new values @@ -581,21 +616,67 @@ export function buildFxChain( const input = ctx.createGain(); const output = ctx.createGain(); const handles: { id?: string; type: string; handle: FxNodeHandle }[] = []; + const presets: { id: string; entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode }[] = + []; + + /** + * A preset's consecutive nodes, wrapped in a wet/dry pair. + * + * The rest of the chain is a strict series, which is right for an effect the + * author placed: it is either in the path or it is not. A preset is not one + * effect, though — it is several the author added as a unit, and "how much of + * it is applied" is a question about the unit. Its nodes share no automatable + * parameter, and the worklet ones expose no AudioParams at all, so there is + * nothing to aim a lane at node-by-node. One crossfade around the run is the + * whole answer, and it cannot go half-wrong the way seven lanes can. + * + * Consecutive only, matching what the rack brackets: a preset pulled apart by + * a reorder is no longer a unit, and wrapping across the gap would route the + * effect between its members through the dry leg too. + */ + const runs = presetRuns(enabledAudioFxNodes(chain)); let tail: AudioNode = input; - for (const node of enabledAudioFxNodes(chain)) { - const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed); - tail.connect(handle.input); - tail = handle.output; - handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle }); + for (const run of runs) { + let wrap: { entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode } | null = null; + if (run.preset) { + const entry = ctx.createGain(); + const dry = ctx.createGain(); + const wet = ctx.createGain(); + const join = ctx.createGain(); + wet.gain.value = run.amount; + dry.gain.value = 1 - run.amount; + tail.connect(entry); + // The dry leg bridges the whole run: it leaves before the first effect and + // rejoins after the last, which is what makes amount 0 the untouched + // signal rather than a quieter version of the processed one. + entry.connect(dry).connect(join); + wrap = { entry, wet, dry, join }; + tail = entry; + } + for (const node of run.nodes) { + const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed); + tail.connect(handle.input); + tail = handle.output; + handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle }); + } + if (wrap && run.preset) { + tail.connect(wrap.wet).connect(wrap.join); + presets.push({ id: run.preset, ...wrap }); + tail = wrap.join; + } } tail.connect(output); const shape = shapeOf(chain); + const presetTargets: Record = {}; + for (const p of presets) presetTargets[p.id] = mixTargets(p.wet.gain, p.dry.gain); + return { input, output, + presets: presetTargets, nodes: handles, update(next) { if (shapeOf(next) !== shape) return false; @@ -612,6 +693,16 @@ export function buildFxChain( if (node.id === undefined) delete held.id; else held.id = node.id; }); + // The blend is a value like any other: switching a preset off writes + // `presetAmount`, and pushing it into the running graph is what keeps that + // from being a rebuild — and from restarting the audio underneath it. + for (const run of presetRuns(enabledAudioFxNodes(next))) { + if (!run.preset) continue; + const wrap = presets.find((p) => p.id === run.preset); + if (!wrap) continue; + wrap.wet.gain.value = run.amount; + wrap.dry.gain.value = 1 - run.amount; + } // `shape` is not reassigned: the early return above already established // that `shapeOf(next)` equals it, so recomputing was a whole normalise + // join per observer tick to write back the string that was already there. @@ -619,6 +710,15 @@ export function buildFxChain( }, dispose() { for (const { handle } of handles) handle.dispose(); + // The wrap is not one of `handles` — it belongs to the chain rather than + // to any effect — so it has to be unwired here or a rebuild leaves a + // crossfade still connected to the graph it used to bridge. + for (const { entry, wet, dry, join } of presets) { + entry.disconnect(); + wet.disconnect(); + dry.disconnect(); + join.disconnect(); + } input.disconnect(); output.disconnect(); }, diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts index fe6909b719..5c5bb7c194 100644 --- a/packages/core/src/audioAutomation.ts +++ b/packages/core/src/audioAutomation.ts @@ -79,12 +79,22 @@ export class AudioAutomationError extends Error { export const VOLUME_TARGET = "volume"; -export type HfAutomationTarget = { kind: "volume" } | { kind: "fx"; nodeId: string; param: string }; +export type HfAutomationTarget = + | { kind: "volume" } + | { kind: "fx"; nodeId: string; param: string } + | { kind: "preset"; presetId: string }; /** Split a target string. Returns null for anything unrecognised. */ export function parseAutomationTarget(target: string): HfAutomationTarget | null { if (target === VOLUME_TARGET) return { kind: "volume" }; const parts = target.split("."); + // `fx.preset.` before the 3-part fx form, because it IS a 3-part fx form + // with a reserved node id — an effect can never be called "preset", since ids + // are minted `n1`, `n2`, …. + if (parts.length === 3 && parts[0] === "fx" && parts[1] === PRESET_TARGET_KEY) { + const presetId = parts[2]; + return presetId ? { kind: "preset", presetId } : null; + } if (parts.length !== 3 || parts[0] !== "fx") return null; const [, nodeId, param] = parts; if (!nodeId || !param) return null; @@ -95,6 +105,33 @@ export function fxAutomationTarget(nodeId: string, param: string): string { return `fx.${nodeId}.${param}`; } +/** The reserved node-id slot that marks a whole-preset target. */ +const PRESET_TARGET_KEY = "preset"; + +/** + * How much of a preset is applied, 0..1. + * + * A preset's nodes share no automatable parameter — and its worklet effects + * expose no AudioParams at all — so there is nothing to aim a lane at + * node-by-node. The graph wraps a preset's run in a wet/dry pair instead, and + * this drives the blend: 0 is the dry signal untouched, 1 is the preset fully + * applied, and between them it crossfades. + */ +export function presetAutomationTarget(presetId: string): string { + return `fx.${PRESET_TARGET_KEY}.${presetId}`; +} + +/** 0..1 blend, the same shape as a wet/dry mix knob. */ +export const PRESET_RANGE: AutomationRange = { + min: 0, + max: 1, + step: 0.01, + unit: "", + label: "Amount", + scale: "linear", + default: 1, +}; + /** * The value range a lane is drawn and clamped against. * @@ -136,6 +173,13 @@ export function resolveAutomationRange( const parsed = parseAutomationTarget(target); if (!parsed) return null; if (parsed.kind === "volume") return VOLUME_RANGE; + if (parsed.kind === "preset") { + // Only for a preset the chain actually carries, so a lane left behind by a + // removed preset resolves to nothing and is dropped at read time — the same + // contract an orphaned node lane has. + const present = chain?.nodes.some((n) => n.fromPreset === parsed.presetId); + return present ? { ...PRESET_RANGE, label: `${parsed.presetId} · Amount` } : null; + } const node = chain?.nodes.find((n) => n.id === parsed.nodeId); if (!node) return null; const def = getAudioFxDef(node.type); diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 1c9f08c0b4..0e13828648 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -832,6 +832,18 @@ export interface HfAudioFxNode { * effect type and its bands stay ordinary filters underneath. */ fromEq?: string; + + /** + * How much of this node's preset is applied, 0..1 — the wet/dry blend the + * graph wraps its run in. + * + * On every node of the run rather than beside the chain, because the chain has + * nowhere else to put it: `HfAudioFxChain` is a version and a list of nodes, + * and a preset is defined by which nodes carry its tag. The graph reads it off + * the first node of each run. Absent means fully applied, which is what every + * chain written before this means. + */ + presetAmount?: number; /** * Set on the gain stage the leveller writes, so re-running replaces it rather * than stacking a second one — the same contract `fromCarve` has. @@ -890,6 +902,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { label?: unknown; fromEq?: unknown; fromLeveller?: unknown; + presetAmount?: unknown; }; if (typeof node.type !== "string" || !BY_ID.has(node.type)) { throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`); @@ -907,6 +920,11 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { ...(typeof node.label === "string" && node.label ? { label: node.label } : {}), ...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}), ...(node.fromLeveller === true ? { fromLeveller: true as const } : {}), + // Clamped on the way in: the blend is two gains in opposition, and a value + // outside 0..1 makes the dry leg negative rather than simply loud. + ...(typeof node.presetAmount === "number" && Number.isFinite(node.presetAmount) + ? { presetAmount: Math.min(1, Math.max(0, node.presetAmount)) } + : {}), enabled: node.enabled !== false, params: normalizeAudioFxParams( node.type, @@ -934,6 +952,11 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string { ...(node.label ? { label: node.label } : {}), ...(node.fromEq ? { fromEq: node.fromEq } : {}), ...(node.fromLeveller === true ? { fromLeveller: true } : {}), + // Omitted when fully applied, so an untouched preset does not grow a field + // in every chain that carries one. + ...(typeof node.presetAmount === "number" && node.presetAmount !== 1 + ? { presetAmount: node.presetAmount } + : {}), ...(node.enabled === false ? { enabled: false } : {}), params: normalizeAudioFxParams(node.type, node.params), })), diff --git a/packages/core/src/audioFxPresets.test.ts b/packages/core/src/audioFxPresets.test.ts index 2f187ce814..11e0a1160e 100644 --- a/packages/core/src/audioFxPresets.test.ts +++ b/packages/core/src/audioFxPresets.test.ts @@ -120,6 +120,45 @@ describe("the catalogue is internally valid", () => { } }); + it("round-trips how much of the preset is applied", () => { + // `presetAmount` is what the switch writes and what a lane ramps, so losing + // it on reload silently turns every part-applied or switched-off preset back + // on — the same failure `fromPreset` had, one field along. The INVARIANT: a + // new HfAudioFxNode field goes in BOTH parseAudioFxChain and + // serializeAudioFxChain. + const preset = HF_AUDIO_FX_PRESETS[0]; + if (!preset) throw new Error("empty catalogue"); + for (const amount of [0, 0.4, 1]) { + const chain = applyAudioFxPreset(empty(), preset); + const withAmount = { + ...chain, + nodes: chain.nodes.map((n) => ({ ...n, presetAmount: amount })), + }; + const back = parseAudioFxChain(serializeAudioFxChain(withAmount)); + // 1 is the absent case — a fully applied preset should not grow a field in + // every chain that carries one — and reads back as fully applied. + const expected = amount === 1 ? undefined : amount; + expect( + back.nodes.map((n) => n.presetAmount), + `amount ${amount} did not survive`, + ).toEqual(chain.nodes.map(() => expected)); + } + }); + + it("refuses an amount outside the blend it drives", () => { + // Two gains in opposition: past 1 the dry leg goes negative rather than the + // preset simply getting louder. + const preset = HF_AUDIO_FX_PRESETS[0]; + if (!preset) throw new Error("empty catalogue"); + const chain = applyAudioFxPreset(empty(), preset); + const raw = JSON.parse(serializeAudioFxChain(chain)) as { + nodes: { presetAmount?: number }[]; + }; + raw.nodes = raw.nodes.map((n) => ({ ...n, presetAmount: 4 })); + const back = parseAudioFxChain(JSON.stringify(raw)); + for (const node of back.nodes) expect(node.presetAmount).toBe(1); + }); + it("names every node for the job it is doing", () => { for (const p of HF_AUDIO_FX_PRESETS) { for (const node of p.nodes) { diff --git a/packages/core/src/runtime/audioFx.ts b/packages/core/src/runtime/audioFx.ts index 22de1dc2e1..07486de1e6 100644 --- a/packages/core/src/runtime/audioFx.ts +++ b/packages/core/src/runtime/audioFx.ts @@ -179,7 +179,9 @@ export function attachElementFxChain( const scheduleFor = (next: HfAudioFxChain, at: AutomationTiming | null): void => { automated = - at && handle ? scheduleChainAutomation(readAutomation(el, next), next, handle.nodes, at) : []; + at && handle + ? scheduleChainAutomation(readAutomation(el, next), next, handle.nodes, at, handle.presets) + : []; }; // The reference frame every later reschedule measures from. Mutable because a diff --git a/packages/core/stubs/audio-fx-runtime-entry.ts b/packages/core/stubs/audio-fx-runtime-entry.ts index d6a78b9439..b8ed1e8e16 100644 --- a/packages/core/stubs/audio-fx-runtime-entry.ts +++ b/packages/core/stubs/audio-fx-runtime-entry.ts @@ -103,11 +103,13 @@ async function render( // time is offline time — the envelope needs no offset here. Same scheduler as // preview, which is what makes the two agree. if (parsedAutomation) { - scheduleChainAutomation(parsedAutomation, chain, fx.nodes, { - scheduledAt: 0, - elapsed: 0, - rate: 1, - }); + scheduleChainAutomation( + parsedAutomation, + chain, + fx.nodes, + { scheduledAt: 0, elapsed: 0, rate: 1 }, + fx.presets, + ); } source.connect(fx.input); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 8585c8c880..a5a5afe953 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -34,6 +34,8 @@ import { } from "@hyperframes/core/audio-carve"; import { fxAutomationTarget, + parseAutomationTarget, + presetAutomationTarget, sampleAutomationLane, type HfAutomation, type HfAutomationLane, @@ -192,6 +194,29 @@ export function AudioFxGroup({ writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey))); }; + /** + * Automate a whole preset's amount — the wet/dry blend around its run. + * + * Seeded where the preset already sits, so switching to a lane never changes + * the sound; the author then draws the ramp in the timeline. Same contract as + * automating one parameter, one level up. + */ + const automatePreset = (presetId: string, amount: number): void => { + writeAutomation(withSeededLane(automation, presetAutomationTarget(presetId), amount)); + }; + + const removePresetAutomation = (presetId: string): void => { + writeAutomation(withoutLane(automation, presetAutomationTarget(presetId))); + }; + + /** Presets a lane already drives, so the panel shows a readout not a slider. */ + const automatedPresets = new Set( + automation.lanes + .map((lane) => parseAutomationTarget(lane.target)) + .filter((t): t is { kind: "preset"; presetId: string } => t?.kind === "preset") + .map((t) => t.presetId), + ); + /** * Turn carve on or off. * @@ -796,6 +821,9 @@ export function AudioFxGroup({ onLevel={() => void runLeveller()} onRemoveLevel={removeLeveller} levelled={chain.nodes.some((n) => n.fromLeveller)} + onAutomatePreset={automatePreset} + onRemovePresetAutomation={removePresetAutomation} + automatedPresets={automatedPresets} onAuditionLevel={(on) => void auditionLevel(on)} auditioningLevel={auditioningLevel} carvedAgainstBy={carvedAgainstBy} diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 1cdfad9b20..ddf7fcda9d 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -88,6 +88,9 @@ function mount(overrides: Partial[0]> = {}) { onAutomateParam={overrides.onAutomateParam} onRemoveParamAutomation={overrides.onRemoveParamAutomation} onRemoveNodeAutomation={overrides.onRemoveNodeAutomation} + onAutomatePreset={overrides.onAutomatePreset} + onRemovePresetAutomation={overrides.onRemovePresetAutomation} + automatedPresets={overrides.automatedPresets} onLevel={overrides.onLevel} onRemoveLevel={overrides.onRemoveLevel} levelled={overrides.levelled} @@ -414,6 +417,22 @@ describe("FxSection chain", () => { }); describe("a preset is one thing to switch off or take away", () => { + /** + * The run's own Amount row — not a member module's. + * + * It is a direct child of the bracket; a member's rows are nested inside its + * own card, which is what makes the distinction structural rather than + * positional. + */ + const amountRow = (host: HTMLElement): HTMLElement | null => { + const bracket = host.querySelector("[data-fx-preset='telephone']"); + // A direct-child walk rather than `:scope >`, which happy-dom's matcher + // does not support — it returns nothing rather than erroring, which reads + // as "the control is missing". + return (Array.from(bracket?.children ?? []).find((c) => c.classList.contains("hf-fx-row")) ?? + null) as HTMLElement | null; + }; + /** A telephone preset applied, plus one hand-built effect beside it. */ const applied = (): HfAudioFxChain => { const preset = getAudioFxPreset("telephone"); @@ -430,7 +449,7 @@ describe("FxSection chain", () => { }; }; - it("bypasses every node it wrote, in one gesture", () => { + it("switches the whole preset off 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() }); @@ -438,17 +457,37 @@ describe("FxSection chain", () => { 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, - ); + // Amount, not `enabled`: the switch and the lane are the same value, so + // Off is one end of the control a ramp moves along rather than a second + // way of silencing the preset. Writing `enabled` would take the nodes out + // of the graph, which a lane cannot do part-way. + const members = next.nodes.filter((n) => n.fromPreset === "telephone"); + expect(members.every((n) => n.presetAmount === 0)).toBe(true); + // Still in the graph, so the settings survive and it can come back. + expect(members.every((n) => n.enabled !== false)).toBe(true); // And leaves what the author added themselves alone. - expect(next.nodes.find((n) => n.id === "own")?.enabled).toBe(true); + expect(next.nodes.find((n) => n.id === "own")?.presetAmount).toBeUndefined(); + }); + + it("puts the whole preset half in", () => { + // The point of the blend: a preset is not only on or off, and the same + // value a lane ramps is one an author can just set. + const { host, onChainChange } = mount({ chain: applied() }); + const input = amountRow(host)?.querySelector(".hf-fx-number"); + if (!input) throw new Error("no amount control"); + typeInto(input, "0.4"); + act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }))); + + const next = onChainChange.mock.calls.at(-1)?.[0] as HfAudioFxChain; + expect( + next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => n.presetAmount === 0.4), + ).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, + n.fromPreset === "telephone" ? { ...n, presetAmount: 0 } : n, ); const { host, onChainChange } = mount({ chain: off }); const toggle = host @@ -459,17 +498,18 @@ describe("FxSection chain", () => { 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); + expect(back.every((n) => n.presetAmount === 1)).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. + it("reads as on while any of it is still applied", () => { + // Anything above zero is a preset that is doing something, 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)); + partial.nodes = partial.nodes.map((n) => + n.fromPreset === "telephone" ? { ...n, presetAmount: 0.2 } : n, + ); const { host } = mount({ chain: partial }); expect( host @@ -479,6 +519,31 @@ describe("FxSection chain", () => { ).toBe("true"); }); + it("hands the whole preset to a lane, seeded where it already sits", () => { + // The reason a preset needs its own target at all: its nodes share no + // automatable parameter, and its worklet effects expose none. One lane on + // the blend is what lets a preset ramp in over time. + const onAutomatePreset = vi.fn(); + const half = applied(); + half.nodes = half.nodes.map((n) => + n.fromPreset === "telephone" ? { ...n, presetAmount: 0.6 } : n, + ); + const { host } = mount({ chain: half, onAutomatePreset }); + click(amountRow(host)?.querySelector(".hf-fx-automate")); + // Seeded where it sits, so switching to a lane never changes the sound. + expect(onAutomatePreset).toHaveBeenCalledWith("telephone", 0.6); + }); + + it("shows an automated preset as driven rather than offering a slider", () => { + const { host } = mount({ + chain: applied(), + automatedPresets: new Set(["telephone"]), + }); + const row = amountRow(host); + expect(row?.hasAttribute("data-automated")).toBe(true); + expect(row?.querySelector('input[type="range"]')?.disabled).toBe(true); + }); + it("takes the preset back out whole, with its lanes", () => { const onRemoveNodeAutomation = vi.fn(); const { host, onChainChange } = mount({ chain: applied(), onRemoveNodeAutomation }); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index 83c96b4818..a1b8fabe4a 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -14,6 +14,7 @@ import { type HfAudioFxChain, type HfAudioFxGroup, type HfAudioFxNode, + type HfAudioFxParam, type HfAudioFxParamValues, } from "@hyperframes/core/audio-fx"; import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve"; @@ -33,6 +34,7 @@ import { HF_AUDIO_FX_JOB_TYPES, type HfAudioFxJob, } from "@hyperframes/core/audio-fx-jobs"; +import { FxParamRow } from "./propertyPanelFxControls.js"; import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js"; import { FxEqModule } from "./propertyPanelFxEqModule.js"; import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js"; @@ -48,6 +50,25 @@ const GROUP_LABEL: Record = { time: "Time", }; +/** + * The one control over a whole preset: how much of it is applied. + * + * Not in the effect registry — a preset is not an effect — so the row is + * fabricated the same way the derived one-knob control is, and rendered by the + * ordinary controls. + */ +const PRESET_AMOUNT_PARAM: HfAudioFxParam = { + kind: "number", + key: "amount", + label: "Amount", + unit: "", + min: 0, + max: 1, + step: 0.01, + default: 1, + hint: "How much of this preset is applied. Automate it to bring the whole preset in or out over time.", +}; + export interface FxSectionProps { chain: HfAudioFxChain; /** Targets this track already automates, as `fx..` strings. */ @@ -67,6 +88,12 @@ export interface FxSectionProps { onRemoveParamAutomation?(nodeId: string, paramKey: string): void; /** Delete every lane belonging to a node that is being removed. */ onRemoveNodeAutomation?(nodeId: string): void; + /** Add a lane for a whole preset's amount, seeded where it sits now. */ + onAutomatePreset?(presetId: string, amount: number): void; + /** Delete that lane. */ + onRemovePresetAutomation?(presetId: string): void; + /** Presets whose amount a lane already drives. */ + automatedPresets?: ReadonlySet; /** Measure this track and write the levelling lane. Absent when unavailable. */ onLevel?(): void; /** Take the levelling stage and its lane back out. */ @@ -126,7 +153,11 @@ export function FxSection({ levelled, onAuditionLevel, auditioningLevel, + onAutomatePreset, + onRemovePresetAutomation, + automatedPresets, }: FxSectionProps) { + const presetAutomated = automatedPresets ?? new Set(); // Falls back to the persisting write when no preview handler is supplied, which // keeps the control working rather than going dead. const previewCarve = onCarvePreview ?? onCarveChange; @@ -315,19 +346,28 @@ export function FxSection({ ); /** - * Bypass or restore every node a preset wrote, as one gesture. + * How much of a preset is applied, 0..1. * - * 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. + * The switch and the lane are the same value, not two ways of silencing a + * preset: `presetAmount` drives the wet/dry blend the graph wraps the run in, + * so Off is amount 0 and a lane ramping 0 → 1 is the same control moving + * continuously. Writing `enabled` instead would take the nodes out of the + * graph, which a lane cannot do part-way and cannot do without a rebuild. + * + * On every node of the run because that is where the chain can hold it — see + * `HfAudioFxNode.presetAmount`. */ - const toggleRun = useCallback( - (items: { node: HfAudioFxNode; i: number }[], on: boolean) => { + const setRunAmount = useCallback( + (items: { node: HfAudioFxNode; i: number }[], amount: number, persist = true) => { const slots = new Set(items.map((item) => item.i)); - mutate(chain.nodes.map((n, i) => (slots.has(i) ? { ...n, enabled: on } : n))); + const next = { + ...chain, + nodes: chain.nodes.map((n, i) => (slots.has(i) ? { ...n, presetAmount: amount } : n)), + }; + if (persist) mutate(next.nodes); + else onChainPreview?.(next); }, - [chain.nodes, mutate], + [chain, mutate, onChainPreview], ); /** @@ -539,7 +579,11 @@ export function FxSection({ // 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); + // How much of it is applied. Any node of the run carries it, and the + // first is the one the graph reads. + const amount = run.items[0]?.node.presetAmount; + const runAmount = typeof amount === "number" ? amount : 1; + const runOn = runAmount > 0; return (
toggleRun(run.items, !runOn)} + onClick={() => setRunAmount(run.items, runOn ? 0 : 1)} > {runOn ? "On" : "Off"} @@ -574,6 +618,26 @@ export function FxSection({ ×
+ {/* The same value the switch sets, so an author can put the + preset half in — and the lane below ramps it continuously. */} + setRunAmount(run.items, Number(v), false)} + onCommit={(_k, v) => setRunAmount(run.items, Number(v))} + onAutomate={ + run.preset && onAutomatePreset && !presetAutomated.has(run.preset) + ? () => onAutomatePreset(run.preset ?? "", runAmount) + : undefined + } + onRemoveAutomation={ + run.preset && onRemovePresetAutomation && presetAutomated.has(run.preset) + ? () => onRemovePresetAutomation(run.preset ?? "") + : undefined + } + /> {rows} ); 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) { ))} 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. */} +
+ + +
); } 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 (
- + {/* 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({