Skip to content

Commit fa461bf

Browse files
committed
feat: the shared frequency ruler, so the vocabulary gets taught
`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.
1 parent 6e5c5a6 commit fa461bf

5 files changed

Lines changed: 158 additions & 2 deletions

File tree

packages/core/src/audioFxCopy.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import { defaultAudioFxParams, HF_AUDIO_FX } from "./audioFx.js";
33
import { HF_AUDIO_FX_PRESETS } from "./audioFxPresets.js";
4-
import { BANDS, EFFECT_COPY, PRESET_PROBLEM, SUMMARY } from "./audioFxCopy.js";
4+
import { audioBandAt, BANDS, EFFECT_COPY, PRESET_PROBLEM, SUMMARY } from "./audioFxCopy.js";
55

66
/**
77
* 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", () => {
7373
expect(BANDS[i]?.from, `gap or overlap before ${BANDS[i]?.name}`).toBe(BANDS[i - 1]?.to);
7474
}
7575
});
76+
77+
describe("audioBandAt", () => {
78+
it("names the range a frequency sits in", () => {
79+
expect(audioBandAt(50)?.name).toBe("Rumble");
80+
expect(audioBandAt(250)?.name).toBe("Mud");
81+
expect(audioBandAt(3000)?.name).toBe("Presence");
82+
expect(audioBandAt(12000)?.name).toBe("Air");
83+
});
84+
85+
it("puts a boundary in the band it opens, not the one it closes", () => {
86+
// Off by one here means a filter at exactly 250 Hz reads as "Weight" while
87+
// the ruler beside it highlights Mud.
88+
for (let i = 1; i < BANDS.length; i++) {
89+
const edge = BANDS[i]?.from;
90+
if (edge === undefined) continue;
91+
expect(audioBandAt(edge)?.name).toBe(BANDS[i]?.name);
92+
}
93+
});
94+
95+
it("clamps past both ends rather than going nameless", () => {
96+
// A filter parked at the edge of its range still has to say where it works.
97+
expect(audioBandAt(5)?.name).toBe(BANDS[0]?.name);
98+
expect(audioBandAt(30000)?.name).toBe(BANDS.at(-1)?.name);
99+
expect(audioBandAt(20000)?.name).toBe(BANDS.at(-1)?.name);
100+
});
101+
102+
it("has no answer for a value that is not a frequency", () => {
103+
expect(audioBandAt(Number.NaN)).toBeUndefined();
104+
});
105+
});

packages/core/src/audioFxCopy.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,24 @@ export const BANDS: { from: number; to: number; name: string; says: string }[] =
294294
{ from: 10000, to: 20000, name: "Air", says: "sparkle, openness" },
295295
];
296296

297+
/**
298+
* Which named range a frequency falls in.
299+
*
300+
* The whole point of `BANDS` is that the words get taught, and they only get
301+
* taught if a module can say which one it is working in. Below the first band
302+
* and above the last both clamp rather than returning nothing: 15 Hz is still
303+
* rumble to anybody who can hear it, and the alternative is a filter at the edge
304+
* of its range having no name at all.
305+
*/
306+
export function audioBandAt(hz: number): (typeof BANDS)[number] | undefined {
307+
if (!Number.isFinite(hz)) return undefined;
308+
const first = BANDS[0];
309+
const last = BANDS.at(-1);
310+
if (first && hz < first.from) return first;
311+
if (last && hz >= last.to) return last;
312+
return BANDS.find((band) => hz >= band.from && hz < band.to);
313+
}
314+
297315
/** Which everyday complaint each preset answers. Presets ARE the product here. */
298316
export const PRESET_PROBLEM: Record<string, string> = {
299317
"voice-clean": "My voice sounds amateur",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* The shared frequency ruler.
3+
*
4+
* Frequencies mean nothing to somebody who has not been taught them, and the
5+
* rack speaks entirely in them. `BANDS` names the ranges in the words the same
6+
* person would use unprompted — rumble, weight, mud, middle, presence, edge,
7+
* air — and every spectral module shows where it acts on this one ruler, so
8+
* naming them once teaches them everywhere they appear.
9+
*
10+
* Two things at once, deliberately. The bar says where this module works
11+
* relative to everything else, which is the spatial fact; the caption under it
12+
* names the range and what lives there, which is the vocabulary. A bar alone
13+
* would be decoration and a caption alone would not teach the shape.
14+
*
15+
* Log-spaced, because hearing is: 20–200 Hz is as much of the range to an ear as
16+
* 2–20 kHz, and a linear ruler would crush six of the seven bands into a corner.
17+
*/
18+
19+
import { audioBandAt, BANDS } from "@hyperframes/core/audio-fx-copy";
20+
21+
const LOW = BANDS[0]?.from ?? 20;
22+
const HIGH = BANDS.at(-1)?.to ?? 20000;
23+
24+
/** Where a frequency sits across the ruler, 0..1. */
25+
function positionOf(hz: number): number {
26+
const span = Math.log10(HIGH) - Math.log10(LOW);
27+
const at = (Math.log10(Math.min(HIGH, Math.max(LOW, hz))) - Math.log10(LOW)) / span;
28+
return Math.min(1, Math.max(0, at));
29+
}
30+
31+
export interface FxBandRulerProps {
32+
/** The range this effect can act over, from its copy. */
33+
band: readonly [number, number];
34+
/** Where it is acting right now. */
35+
at: number;
36+
}
37+
38+
export function FxBandRuler({ band, at }: FxBandRulerProps) {
39+
const here = audioBandAt(at);
40+
if (!here) return null;
41+
const [from, to] = band;
42+
return (
43+
<div className="hf-fx-ruler px-1.5 pb-1" data-band={here.name}>
44+
<div className="hf-fx-ruler-bar relative flex h-1 w-full overflow-hidden rounded-[1px]">
45+
{BANDS.map((range) => {
46+
// Reachable at all, and where it is now: a module that can only work in
47+
// the bottom three bands should not look like it could move anywhere.
48+
const reachable = range.to > from && range.from < to;
49+
return (
50+
<span
51+
key={range.name}
52+
title={`${range.name}${range.says}`}
53+
className={
54+
range.name === here.name
55+
? "hf-fx-ruler-band bg-panel-accent"
56+
: reachable
57+
? "hf-fx-ruler-band bg-panel-text-4/50"
58+
: "hf-fx-ruler-band bg-panel-text-4/15"
59+
}
60+
style={{
61+
width: `${(positionOf(range.to) - positionOf(range.from)) * 100}%`,
62+
}}
63+
/>
64+
);
65+
})}
66+
</div>
67+
<p className="hf-fx-ruler-label truncate pt-0.5 text-[9px] text-panel-text-4">
68+
<span className="hf-fx-ruler-name text-panel-text-1">{here.name}</span>{here.says}
69+
</p>
70+
</div>
71+
);
72+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { EFFECT_COPY, SUMMARY } from "@hyperframes/core/audio-fx-copy";
2222
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
2323
import { FxParams } from "./propertyPanelFxControls.js";
24+
import { FxBandRuler } from "./propertyPanelFxBandRuler.js";
2425

2526
/**
2627
* The one control that carries the module, if it has one.
@@ -347,6 +348,12 @@ export function FxNodeRow({
347348
<span className="truncate text-right">{copy.primaryEnds.high}</span>
348349
</p>
349350
) : null}
351+
{/* Where it is working, in the words the rack shares. Only for a
352+
module that acts on a range at all — there is nothing spectral
353+
about a limiter, and a ruler under one would be noise. */}
354+
{copy?.band && typeof params.frequency === "number" ? (
355+
<FxBandRuler band={copy.band} at={params.frequency} />
356+
) : null}
350357
</>
351358
) : null}
352359
{/* The DSP name lives on the disclosure, so it is read at the moment

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type HfAudioFxChain,
99
} from "@hyperframes/core/audio-fx";
1010
import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
11-
import { EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
11+
import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
1212
import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
1313
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
1414

@@ -362,6 +362,35 @@ describe("FxSection chain", () => {
362362
);
363363
});
364364

365+
it("says where a filter is working, in the words the rack shares", () => {
366+
// Frequencies mean nothing to somebody who has not been taught them, and the
367+
// rack speaks entirely in them. The ruler is where they get taught.
368+
const { host } = mount({
369+
chain: {
370+
version: 1,
371+
nodes: [{ type: "highpass", params: { frequency: 250, q: 0.707, poles: "2" } }],
372+
} as unknown as HfAudioFxChain,
373+
});
374+
const ruler = fxCard(host).querySelector(".hf-fx-ruler");
375+
expect(ruler?.getAttribute("data-band")).toBe("Mud");
376+
expect(ruler?.querySelector(".hf-fx-ruler-name")?.textContent).toBe("Mud");
377+
// Every named range is on the bar, or it is not a shared ruler.
378+
const segments = Array.from(ruler?.querySelectorAll<HTMLElement>(".hf-fx-ruler-band") ?? []);
379+
expect(segments).toHaveLength(BANDS.length);
380+
// Log-spaced, because hearing is. Rumble is 20-80 Hz — three tenths of one
381+
// percent of the range linearly, and a fifth of it by ear. Laid out linearly
382+
// the bottom six bands collapse into a sliver and the ruler teaches nothing.
383+
const rumble = Number.parseFloat(segments[0]?.style.width ?? "0");
384+
expect(rumble).toBeGreaterThan(10);
385+
});
386+
387+
it("puts no ruler under an effect that does not act on a range", () => {
388+
// A limiter has no frequency to place, and a bar under one would be a
389+
// decoration claiming to be information.
390+
const { host } = mount({ chain: chainOf("limiter") });
391+
expect(fxCard(host).querySelector(".hf-fx-ruler")).toBeNull();
392+
});
393+
365394
it("opens a module on all of its controls when its one knob does not exist yet", () => {
366395
// Five effects want a single DERIVED control over several parameters — the
367396
// `PROFILES` idea, whose figures are proposed rather than measured. Until it

0 commit comments

Comments
 (0)