Skip to content

Commit 8388acb

Browse files
committed
feat: offer the job, not the machine — the range IS the module
"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.
1 parent 1356447 commit 8388acb

6 files changed

Lines changed: 309 additions & 7 deletions

File tree

packages/core/package-subpaths.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@
9898
"types": "./dist/audioFxCopy.d.ts",
9999
"environments": ["browser", "bun", "node"]
100100
},
101+
"./audio-fx-jobs": {
102+
"source": "./src/audioFxJobs.ts",
103+
"runtime": "./dist/audioFxJobs.js",
104+
"types": "./dist/audioFxJobs.d.ts",
105+
"environments": ["browser", "bun", "node"]
106+
},
101107
"./audio-fx-eq": {
102108
"source": "./src/audioFxEq.ts",
103109
"runtime": "./dist/audioFxEq.js",

packages/core/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@
112112
"import": "./src/audioFxCopy.ts",
113113
"types": "./src/audioFxCopy.ts"
114114
},
115+
"./audio-fx-jobs": {
116+
"bun": "./src/audioFxJobs.ts",
117+
"node": "./dist/audioFxJobs.js",
118+
"import": "./src/audioFxJobs.ts",
119+
"types": "./src/audioFxJobs.ts"
120+
},
115121
"./audio-fx-eq": {
116122
"bun": "./src/audioFxEq.ts",
117123
"node": "./dist/audioFxEq.js",
@@ -420,6 +426,10 @@
420426
"import": "./dist/audioFxCopy.js",
421427
"types": "./dist/audioFxCopy.d.ts"
422428
},
429+
"./audio-fx-jobs": {
430+
"import": "./dist/audioFxJobs.js",
431+
"types": "./dist/audioFxJobs.d.ts"
432+
},
423433
"./audio-fx-eq": {
424434
"import": "./dist/audioFxEq.js",
425435
"types": "./dist/audioFxEq.d.ts"
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getAudioFxDef } from "./audioFx.js";
3+
import { HF_AUDIO_FX_PRESETS } from "./audioFxPresets.js";
4+
import { audioFxJobNode, getAudioFxJob, HF_AUDIO_FX_JOBS } from "./audioFxJobs.js";
5+
6+
const EMPTY = { version: 1 as const, nodes: [] };
7+
8+
describe("named jobs", () => {
9+
it("makes an ordinary effect node, named for the work", () => {
10+
const job = getAudioFxJob("reduce-mud");
11+
if (!job) throw new Error("no such job");
12+
const node = audioFxJobNode(job, EMPTY);
13+
// Ordinary underneath: the author can open it and find the filter they could
14+
// have added by hand, at the frequency the job picked for them.
15+
expect(node.type).toBe("peaking");
16+
expect(node.params?.frequency).toBe(250);
17+
expect(node.label).toBe("Reduce Mud");
18+
expect(node.enabled).toBe(true);
19+
expect(node.id).toBeTruthy();
20+
});
21+
22+
it("gives every job a value for every parameter its effect declares", () => {
23+
// A job names a range and leaves the rest alone, so the omitted parameters
24+
// have to come from the registry — a node missing `q` renders at whatever
25+
// the graph builder falls back to rather than what the panel shows.
26+
for (const job of HF_AUDIO_FX_JOBS) {
27+
const def = getAudioFxDef(job.type);
28+
expect(def, `${job.id} names an effect that does not exist`).toBeDefined();
29+
const node = audioFxJobNode(job, EMPTY);
30+
for (const param of def?.params ?? []) {
31+
expect(node.params?.[param.key], `${job.id} has no ${param.key}`).toBeDefined();
32+
}
33+
}
34+
});
35+
36+
it("mints an id that does not collide with what is already there", () => {
37+
const job = getAudioFxJob("add-clarity");
38+
if (!job) throw new Error("no such job");
39+
const first = audioFxJobNode(job, EMPTY);
40+
const second = audioFxJobNode(job, { version: 1, nodes: [first] });
41+
// Two of the same job is a real thing to want, and a shared id would give
42+
// them each other's automation lanes.
43+
expect(second.id).not.toBe(first.id);
44+
});
45+
46+
it("names jobs the preset catalogue already ships", () => {
47+
// The point of the list is to name the vocabulary the presets were written
48+
// in, not to invent a second one beside it. A job nobody's preset uses is a
49+
// guess about what authors want; one a preset uses has already been chosen.
50+
const shipped = new Set(
51+
HF_AUDIO_FX_PRESETS.flatMap((preset) => preset.nodes.map((node) => node.label)),
52+
);
53+
for (const job of HF_AUDIO_FX_JOBS) {
54+
expect(shipped, `${job.label} is not a job any preset does`).toContain(job.label);
55+
}
56+
});
57+
58+
it("has an id for every job and no duplicates", () => {
59+
const ids = HF_AUDIO_FX_JOBS.map((job) => job.id);
60+
expect(new Set(ids).size).toBe(ids.length);
61+
for (const id of ids) expect(getAudioFxJob(id)?.id).toBe(id);
62+
});
63+
});

packages/core/src/audioFxJobs.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Named jobs: the range IS the module.
3+
*
4+
* "Shape One Range" has three controls — where, how much, how wide — and no
5+
* honest way to nominate one of them as the knob that matters. Nominating *how
6+
* much* is incoherent, because boosting an unspecified frequency means nothing:
7+
* the range is the first decision, not the second.
8+
*
9+
* So the menu offers the decision instead of the machine. Each job is a peaking
10+
* filter with its frequency already chosen — Reduce Mud, Add Clarity, Soften
11+
* Harshness — and picking the module IS picking the range, which makes one knob
12+
* honest rather than a simplification hiding the real choice. It also dissolves
13+
* the duplicate-name problem at the root: a preset that cuts mud and then lifts
14+
* clarity used to read "Shape One Range" twice with nothing to tell them apart.
15+
*
16+
* Every job here is one the preset catalogue already ships, at the settings it
17+
* ships them with, so this names the vocabulary the presets were written in
18+
* rather than inventing a second one. Reasoning in
19+
* `plans/audio-fx-ux/README.md` §"The hole in the single-knob rule".
20+
*
21+
* The frequency is a starting point, not a cage — it is an ordinary peaking node
22+
* underneath, and Details opens on the same three controls it always had.
23+
*/
24+
25+
import {
26+
defaultAudioFxParams,
27+
mintAudioFxNodeId,
28+
normalizeAudioFxParams,
29+
type HfAudioFxChain,
30+
type HfAudioFxNode,
31+
type HfAudioFxParamValues,
32+
} from "./audioFx.js";
33+
34+
export interface HfAudioFxJob {
35+
id: string;
36+
/** What the rack calls the node this makes. */
37+
label: string;
38+
/** The complaint that leads here, in the author's words. */
39+
does: string;
40+
/** The registry effect underneath. */
41+
type: string;
42+
/** Where it acts, and how hard — the decision the author is spared. */
43+
params: HfAudioFxParamValues;
44+
}
45+
46+
/**
47+
* Ordered low to high, which is the order an author hears them in: weight and
48+
* mud at the bottom, clarity and harshness at the top.
49+
*/
50+
export const HF_AUDIO_FX_JOBS: readonly HfAudioFxJob[] = [
51+
{
52+
id: "tame-boominess",
53+
label: "Tame Boominess",
54+
does: "Too much chest — it booms.",
55+
type: "peaking",
56+
params: { frequency: 200, gain: -4, q: 1.4 },
57+
},
58+
{
59+
id: "reduce-mud",
60+
label: "Reduce Mud",
61+
does: "Muffled, like it is behind cardboard.",
62+
type: "peaking",
63+
params: { frequency: 250, gain: -3, q: 1.2 },
64+
},
65+
{
66+
id: "reduce-boxiness",
67+
label: "Reduce Boxiness",
68+
does: "Sounds like a small room, or a box.",
69+
type: "peaking",
70+
params: { frequency: 400, gain: -3, q: 1.4 },
71+
},
72+
{
73+
id: "add-clarity",
74+
label: "Add Clarity",
75+
does: "Words are hard to make out.",
76+
type: "peaking",
77+
params: { frequency: 3000, gain: 2.5, q: 1 },
78+
},
79+
{
80+
id: "soften-harshness",
81+
label: "Soften Harshness",
82+
does: "Harsh and tiring to listen to.",
83+
type: "peaking",
84+
params: { frequency: 3200, gain: -3, q: 1.6 },
85+
},
86+
];
87+
88+
export function getAudioFxJob(id: string): HfAudioFxJob | undefined {
89+
return HF_AUDIO_FX_JOBS.find((job) => job.id === id);
90+
}
91+
92+
/** Effect ids the job list covers, which the add menu offers as jobs instead. */
93+
export const HF_AUDIO_FX_JOB_TYPES: ReadonlySet<string> = new Set(
94+
HF_AUDIO_FX_JOBS.map((job) => job.type),
95+
);
96+
97+
/**
98+
* The node a job adds: an ordinary effect carrying the job's name.
99+
*
100+
* `label` is the field a preset already uses to name a node for the work it
101+
* does, so a job needs nothing new — and a job node and a preset node are
102+
* indistinguishable afterwards, which is right. They are the same idea.
103+
*/
104+
export function audioFxJobNode(job: HfAudioFxJob, chain: HfAudioFxChain): HfAudioFxNode {
105+
return {
106+
type: job.type,
107+
id: mintAudioFxNodeId(chain),
108+
enabled: true,
109+
label: job.label,
110+
// Through the registry, so a job cannot ship a value the effect would clamp
111+
// or a key it does not have.
112+
params: normalizeAudioFxParams(job.type, {
113+
...defaultAudioFxParams(job.type),
114+
...job.params,
115+
}),
116+
};
117+
}

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

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@hyperframes/core/audio-fx";
1010
import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
1111
import { EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
12+
import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
1213
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
1314

1415
/**
@@ -150,11 +151,59 @@ describe("FxSection chain", () => {
150151
const items = Array.from(host.querySelectorAll(".hf-fx-add-item")).map((e) =>
151152
e.textContent?.trim(),
152153
);
153-
expect(items).toHaveLength(HF_AUDIO_FX.length);
154-
// By the name the RACK will use, not the registry's — picking "High-pass"
155-
// and getting a module called "Remove Rumble" is the inconsistency this
156-
// whole layer exists to remove.
157-
for (const def of HF_AUDIO_FX) expect(items).toContain(EFFECT_COPY[def.id]?.title);
154+
// Every effect is reachable, but not every one under its own name: the jobs
155+
// stand in for the effect they are made of, because picking `peaking` is
156+
// picking a machine and leaving the real decision for afterwards.
157+
const standIns = HF_AUDIO_FX.filter((d) => HF_AUDIO_FX_JOB_TYPES.has(d.id));
158+
expect(items).toHaveLength(HF_AUDIO_FX.length - standIns.length + HF_AUDIO_FX_JOBS.length);
159+
for (const def of HF_AUDIO_FX) {
160+
if (HF_AUDIO_FX_JOB_TYPES.has(def.id)) {
161+
// Not offered as itself, and it must not be — two doors to the same
162+
// effect, one of them the incoherent one, is worse than either alone.
163+
expect(items).not.toContain(EFFECT_COPY[def.id]?.title);
164+
continue;
165+
}
166+
// By the name the RACK will use, not the registry's — picking "High-pass"
167+
// and getting a module called "Remove Rumble" is the inconsistency this
168+
// whole layer exists to remove.
169+
expect(items).toContain(EFFECT_COPY[def.id]?.title);
170+
}
171+
for (const job of HF_AUDIO_FX_JOBS) expect(items).toContain(job.label);
172+
});
173+
174+
it("adds a job as an ordinary effect, already named and already aimed", () => {
175+
// The range IS the module: one knob is honest here because the decision the
176+
// knob depends on has already been made.
177+
const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
178+
click(host.querySelector(".hf-fx-add"));
179+
click(byText(host, ".hf-fx-add-item", "Reduce Mud"));
180+
181+
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
182+
expect(next.nodes).toHaveLength(1);
183+
expect(next.nodes[0]?.type).toBe("peaking");
184+
expect(next.nodes[0]?.label).toBe("Reduce Mud");
185+
expect(next.nodes[0]?.params?.frequency).toBe(250);
186+
});
187+
188+
it("shows two jobs over the same effect as the different things they are", () => {
189+
// The whole point. Read down the rack, "Shape One Range" twice was two rows
190+
// an author could not tell apart — and one of them was cutting while the
191+
// other was boosting.
192+
const { host } = mount({
193+
chain: {
194+
version: 1,
195+
nodes: [
196+
{ type: "peaking", label: "Reduce Mud", params: { frequency: 250, gain: -3, q: 1.2 } },
197+
{ type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 2.5, q: 1 } },
198+
],
199+
} as unknown as HfAudioFxChain,
200+
});
201+
const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) =>
202+
e.textContent?.trim(),
203+
);
204+
expect(names).toContain("Reduce Mud");
205+
expect(names).toContain("Add Clarity");
206+
expect(names).not.toContain(EFFECT_COPY.peaking?.title);
158207
});
159208

160209
it("adds an effect seeded with its declared defaults", () => {

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
99
import {
1010
defaultAudioFxParams,
11+
getAudioFxDef,
1112
HF_AUDIO_FX,
1213
mintAudioFxNodeId,
1314
type HfAudioFxChain,
@@ -25,6 +26,12 @@ import {
2526
setAudioEqBandGain,
2627
} from "@hyperframes/core/audio-fx-eq";
2728
import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
29+
import {
30+
audioFxJobNode,
31+
HF_AUDIO_FX_JOBS,
32+
HF_AUDIO_FX_JOB_TYPES,
33+
type HfAudioFxJob,
34+
} from "@hyperframes/core/audio-fx-jobs";
2835
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
2936
import { FxEqModule } from "./propertyPanelFxEqModule.js";
3037
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -133,8 +140,20 @@ export function FxSection({
133140
const [picking, setPicking] = useState(false);
134141
const [openNode, setOpenNode] = useState<number | null>(0);
135142

143+
/**
144+
* The add menu, with the jobs standing in for the effect they are made of.
145+
*
146+
* `peaking` is not offered as itself: picking it is picking a machine and
147+
* leaving the real decision — which range — for afterwards. The jobs are that
148+
* decision, already made. See `audioFxJobs.ts`.
149+
*/
136150
const grouped = useMemo(
137-
() => GROUP_ORDER.map((g) => ({ group: g, defs: HF_AUDIO_FX.filter((d) => d.group === g) })),
151+
() =>
152+
GROUP_ORDER.map((g) => ({
153+
group: g,
154+
defs: HF_AUDIO_FX.filter((d) => d.group === g && !HF_AUDIO_FX_JOB_TYPES.has(d.id)),
155+
jobs: HF_AUDIO_FX_JOBS.filter((job) => getAudioFxDef(job.type)?.group === g),
156+
})),
138157
[],
139158
);
140159

@@ -242,6 +261,25 @@ export function FxSection({
242261
[],
243262
);
244263

264+
/** The same, for a job — an ordinary node that arrives already named and aimed. */
265+
const withJob = useCallback(
266+
(base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain => ({
267+
...base,
268+
nodes: [...base.nodes, audioFxJobNode(job, base)],
269+
}),
270+
[],
271+
);
272+
273+
const addJob = useCallback(
274+
(job: HfAudioFxJob) => {
275+
auditionBase.current = null;
276+
mutate(withJob(chain, job).nodes);
277+
setOpenNode(chain.nodes.length);
278+
setAdding(false);
279+
},
280+
[chain, mutate, withJob],
281+
);
282+
245283
const addEffect = useCallback(
246284
(type: string) => {
247285
auditionBase.current = null;
@@ -475,11 +513,30 @@ export function FxSection({
475513
Tone (EQ)
476514
</button>
477515
</div>
478-
{grouped.map(({ group, defs }) => (
516+
{grouped.map(({ group, defs, jobs }) => (
479517
<div key={group} className="hf-fx-add-group flex flex-wrap items-center gap-1">
480518
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
481519
{GROUP_LABEL[group]}
482520
</span>
521+
{jobs.map((job) => (
522+
<button
523+
key={job.id}
524+
type="button"
525+
// Same class as any other entry: a job IS an effect, and one
526+
// that looked special would read as a preset rather than as
527+
// the thing the author is about to add.
528+
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"
529+
title={job.does}
530+
onClick={() => addJob(job)}
531+
onMouseEnter={() => {
532+
onAuditionLevel?.(false);
533+
audition((base) => withJob(base, job));
534+
}}
535+
onFocus={() => audition((base) => withJob(base, job))}
536+
>
537+
{job.label}
538+
</button>
539+
))}
483540
{defs.map((d) => (
484541
<button
485542
key={d.id}

0 commit comments

Comments
 (0)