feat(studio): fold a preset shut, and give each one its own title design (#3191)

* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* fix(studio): stop the single-candidate auto-apply carve firing twice

Two auto-apply effects both fire when sourceOptions.length === 1: the
multi-candidate effect only guards length === 0, so a single candidate
passes it too, and the single-candidate effect passes its own guard right
after — both compute the same sources list and both call setCarve, so the
common case (one narrator, one bed) triggered two decodes, two FFT runs, and
two concurrent attribute writes for one decision.

The multi-candidate effect now defers to its sibling for exactly one
candidate, which already has its own detailed handling for that case.

Review by Miga (PR #3213).

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

* chore: fix markdown formatting

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-13 13:53:29 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 6bcf739390
commit e3ec48adce
27 changed files with 1430 additions and 119 deletions
@@ -435,6 +435,109 @@ 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.
*/
it("levels the part of the file the clip plays, not the file from its start", async () => {
// A lane's `t` is seconds from the start of the CLIP, but the decode is the
// whole file — so a trimmed clip got an envelope offset by exactly
// `media-start`, and every correction landed early.
//
// The file is loud 0-2s, quiet 2-5s, loud again 5-8s, and the clip trims the
// first 2s. Measured from the clip's own zero, t=0.5 sits in the quiet
// passage and wants a real lift; measured from the file's zero it sits in
// the loud head and wants none. That gap is the bug.
const sampleRate = 48000;
const data = new Float32Array(sampleRate * 8);
for (let i = 0; i < data.length; i++) {
const t = i / sampleRate;
const amp = t < 2 ? 0.5 : t < 5 ? 0.05 : 0.5;
data[i] = amp * Math.sin(2 * Math.PI * 300 * t);
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
);
vi.stubGlobal(
"OfflineAudioContext",
class {
decodeAudioData = async () => ({ sampleRate, getChannelData: () => data });
},
);
const { host, onSetAttributeQuiet } = mount({
"fx-chain": CHAIN,
"media-start": "2",
duration: "6",
});
document.getElementById("bed")?.setAttribute("src", "bed.wav");
act(() => byTextButton(host, "Audio FX")?.click());
act(() => byTextButton(host, "Add effect")?.click());
await act(async () => {
byTextButton(host, "Even Out Levels")?.click();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
const write = onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-automation").at(-1);
if (!write) throw new Error("no levelling lane written");
const lane = (
JSON.parse(String(write[1])).lanes as { target: string; points: { t: number; v: number }[] }[]
).find((l) => l.target.startsWith("fx."));
if (!lane) throw new Error("no fx lane");
const near = (t: number) =>
lane.points.reduce((best, p) => (Math.abs(p.t - t) < Math.abs(best.t - t) ? p : best));
// The quiet passage, from the clip's zero, gets its lift.
expect(near(0.5).v).toBeGreaterThan(4);
});
it("removes every lane a preset owned, not just the last node's", () => {
// Each write is computed from the same render-time snapshot and replaces the
// whole attribute, so removing lanes one node at a time kept only the final
// write — the earlier nodes' lanes survived as orphans, and with ids minted
// lowest-free the next effect added inherited one, arriving "Automated" with
// an envelope nobody drew and baked into the render.
const chain = {
version: 1,
nodes: [
{
type: "highpass",
id: "n1",
fromPreset: "telephone",
params: { frequency: 300, q: 0.707, poles: "2" },
},
{
type: "peaking",
id: "n2",
fromPreset: "telephone",
params: { frequency: 1200, gain: 6, q: 1.2 },
},
],
};
const automation = {
version: 1,
lanes: [
{ target: "fx.n1.frequency", points: [{ t: 0, v: 300 }] },
{ target: "fx.n2.gain", points: [{ t: 0, v: 6 }] },
{ target: "fx.preset.telephone", points: [{ t: 0, v: 1 }] },
{ target: "volume", points: [{ t: 0, v: 0.5 }] },
],
};
const { host, onSetAttributeQuiet } = mount({
"fx-chain": JSON.stringify(chain),
automation: JSON.stringify(automation),
});
act(() => byTextButton(host, "Audio FX")?.click());
act(() => host.querySelector<HTMLElement>(".hf-fx-preset-run-remove")?.click());
const write = onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-automation").at(-1);
const lanes = JSON.parse(String(write?.[1] ?? '{"lanes":[]}')).lanes as { target: string }[];
const targets = lanes.map((l) => l.target);
// Both nodes gone, and the whole-preset lane with them.
expect(targets).not.toContain("fx.n1.frequency");
expect(targets).not.toContain("fx.n2.gain");
expect(targets).not.toContain("fx.preset.telephone");
// The track's own volume lane is untouched.
expect(targets).toContain("volume");
});
describe("auditioning starts the transport when it has to", () => {
const store = () => usePlayerStore.getState();
@@ -277,14 +277,30 @@ export function AudioFxGroup({
};
/** Every lane belonging to a node that is going away. */
const removeNodeAutomation = (nodeId: string): void => {
const prefix = `fx.${nodeId}.`;
const kept = automation.lanes.filter((lane) => !lane.target.startsWith(prefix));
/**
* Drop every lane belonging to these nodes, and optionally a whole-preset one.
*
* Takes a LIST rather than one id, because each call recomputes from the same
* render-time `automation` snapshot and writes the whole attribute: calling it
* per node in a loop meant every write but the last was discarded, and only
* the final node's lanes were actually removed. The rest survived as orphans,
* and with ids minted lowest-free the next effect added inherited one
* arriving "Automated" with an envelope nobody drew, baked into the render.
*/
const removeNodesAutomation = (nodeIds: readonly string[], presetId?: string): void => {
const prefixes = nodeIds.map((id) => `fx.${id}.`);
const kept = automation.lanes.filter(
(lane) =>
!prefixes.some((p) => lane.target.startsWith(p)) &&
!(presetId !== undefined && lane.target === presetAutomationTarget(presetId)),
);
if (kept.length !== automation.lanes.length) {
writeAutomation({ version: 1, lanes: kept });
}
};
const removeNodeAutomation = (nodeId: string): void => removeNodesAutomation([nodeId]);
const carve = ((): HfCarveSettings | null => {
const raw = element.dataAttributes?.["fx-carve"];
if (!raw) return null;
@@ -552,12 +568,37 @@ export function AudioFxGroup({
return next;
};
/**
* The part of the decoded file this clip actually plays.
*
* A lane's `t` is seconds from the start of the CLIP, but the decode is the
* whole file from its first sample so measuring a trimmed clip produced an
* envelope offset by the trim, and every correction landed early by exactly
* `media-start`. Slicing here is what puts the two clocks back on the same
* zero.
*/
const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
const mediaStart = Number(element.dataAttributes?.["media-start"] ?? 0);
const duration = Number(element.dataAttributes?.["duration"] ?? Number.NaN);
const from =
Number.isFinite(mediaStart) && mediaStart > 0
? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
: 0;
const to =
Number.isFinite(duration) && duration > 0
? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
: audio.samples.length;
return from === 0 && to === audio.samples.length
? audio.samples
: audio.samples.subarray(from, to);
};
const runLeveller = async (): Promise<void> => {
setAnalysing(true);
try {
const audio = await decodeTrack();
if (!audio) return;
const result = levellingResult(chain, audio.samples, audio.sampleRate);
const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
if (!result) return;
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
// Merged by target, never written wholesale: the script describes its own
@@ -648,7 +689,7 @@ export function AudioFxGroup({
const audio = await decodeTrack();
// Gone, or superseded by a later hover. Either way this result is stale.
if (!audio || run !== auditionRun.current) return;
const result = levellingResult(chain, audio.samples, audio.sampleRate);
const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
if (!result || run !== auditionRun.current) return;
void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
const lane = result.automation.lanes[0];
@@ -837,6 +878,7 @@ export function AudioFxGroup({
onAutomateParam={automateParam}
onRemoveParamAutomation={removeParamAutomation}
onRemoveNodeAutomation={removeNodeAutomation}
onRemoveNodesAutomation={removeNodesAutomation}
onChainChange={(next) =>
// Live for the same reason as automation above: adding, removing or
// bypassing an effect is applied to the running graph, so a reload would
@@ -857,6 +899,10 @@ export function AudioFxGroup({
onCarveChange={(next) => void setCarve(next)}
onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))}
sourceOptions={sourceOptions}
// What this track sounds like, by the same reading that decides which
// OTHER tracks a carve may listen to. The shelf uses it to stop offering
// "My voice sounds amateur" on a music bed.
trackKind={classifyAudioName(element.id, element.element?.getAttribute("src"))}
onLevel={() => void runLeveller()}
onRemoveLevel={removeLeveller}
levelled={chain.nodes.some((n) => n.fromLeveller)}
@@ -64,7 +64,7 @@ export function FxBandRuler({ band, at }: FxBandRulerProps) {
);
})}
</div>
<p className="hf-fx-ruler-label truncate pt-0.5 text-[9px] text-panel-text-4">
<p className="hf-fx-ruler-label truncate pt-0.5 text-[9px] text-panel-text-2">
<span className="hf-fx-ruler-name text-panel-text-1">{here.name}</span> {here.says}
</p>
</div>
@@ -18,6 +18,7 @@ import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-car
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
import { FxParamRow } from "./propertyPanelFxControls.js";
import { FX_FAMILY_TYPE, fxFamilyTint } from "./propertyPanelFxFamily.js";
import { fxTintWash } from "./propertyPanelFxPresetStyle.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";
@@ -96,11 +97,11 @@ function FxCarveMember({
return (
<span
key={param.key}
className="flex items-baseline gap-1 font-mono text-[9px] text-panel-text-4"
className="flex items-baseline gap-1 font-mono text-[9px] text-panel-text-2"
{...(automated ? { "data-automated": "" } : {})}
{...(driven ? { "data-automation-live": "" } : {})}
>
<span className="text-panel-text-4">{param.label}</span>
<span className="text-panel-text-2">{param.label}</span>
<span
className="tabular-nums text-panel-text-1"
style={{ minWidth: `${paramValueWidthCh(param)}ch` }}
@@ -200,6 +201,14 @@ export function FxCarveModule({
: carve.sources.length > 0
? "no analysis yet"
: "pick a voice";
// The carve's own colour, used three ways: the module's left edge, the title,
// and the wash behind it. A preset gets a title treatment because it is a
// character; the carve gets one because it is the only module in the rack
// that LISTENS to another track, and a plain row understates that. It stays
// in the smart family's monospace — what it shows is a readout, and a
// display face would promise settings the author chose.
const tint = fxFamilyTint({ type: "carve", fromCarve: true });
const wash = fxTintWash(tint);
return (
<div
className={`hf-fx-node hf-fx-carve-module hf-fx-carve rounded-[4px] border border-l-2 border-panel-border-input${
@@ -210,19 +219,23 @@ export function FxCarveModule({
// Smart, like the Tone EQ and the leveller: it measures the audio and
// writes its own settings, and what it shows is a readout of what it
// decided rather than controls the author set.
style={{ borderLeftColor: fxFamilyTint({ type: "carve", fromCarve: true }) }}
style={{ borderLeftColor: tint, ...(wash ? { backgroundColor: wash } : {}) }}
data-carve-enabled={on ? "" : undefined}
>
<div className="hf-fx-node-head flex min-h-7 items-center gap-1 px-1.5">
<button
type="button"
className={`hf-fx-node-name min-w-0 flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${FX_FAMILY_TYPE.smart}`}
className={`hf-fx-node-name min-w-0 flex-1 truncate text-left text-[13px] uppercase hover:opacity-80 ${FX_FAMILY_TYPE.smart}`}
// Tracking goes here rather than in a class: the smart family already
// sets `tracking-normal`, and two Tailwind tracking utilities on one
// element resolve by stylesheet order, not by the order written.
style={{ color: tint, letterSpacing: "0.16em" }}
aria-expanded={open}
onClick={onToggleOpen}
>
Voiceover carve
</button>
<span className="hf-fx-carve-summary shrink-0 font-mono text-[9px] text-panel-text-4">
<span className="hf-fx-carve-summary shrink-0 font-mono text-[9px] text-panel-text-2">
{summary}
</span>
{/* One switch, not a bypass and a delete. Off drops the effects and the
@@ -230,7 +243,7 @@ export function FxCarveModule({
re-apply the carve the next time this clip was selected. */}
<button
type="button"
className="hf-fx-bypass hf-fx-carve-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
className="hf-fx-bypass hf-fx-carve-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
aria-pressed={on}
title={on ? "Switch the carve off" : "Switch the carve on"}
disabled={disabled}
@@ -243,7 +256,7 @@ export function FxCarveModule({
<div className="hf-fx-carve-body border-t border-panel-border-input">
<div className="hf-fx-carve-controls space-y-0.5 px-1.5 py-1.5">
<div className="hf-fx-row flex min-h-6 items-center gap-2">
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-4">
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2">
Listen to
</span>
{soleVoice ? (
@@ -318,7 +331,7 @@ export function FxCarveModule({
settings that are in force when they are already history, and the one
honest thing to say is that the work is happening. */}
{analysing ? (
<p className="hf-fx-carve-working flex items-center justify-center gap-1.5 border-t border-panel-border-input py-2 text-[10px] text-panel-text-4">
<p className="hf-fx-carve-working flex items-center justify-center gap-1.5 border-t border-panel-border-input py-2 text-[10px] text-panel-text-2">
<svg
className="hf-fx-carve-spinner h-3 w-3 animate-spin motion-reduce:animate-none"
viewBox="0 0 24 24"
@@ -343,7 +356,7 @@ export function FxCarveModule({
</p>
) : nodes.length > 0 ? (
<div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
<div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
analysed
</div>
{nodes.map((node, i) => (
@@ -356,7 +369,7 @@ export function FxCarveModule({
))}
</div>
) : (
<p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-4">
<p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-2">
{carve.sources.length > 0
? "Nothing analysed yet."
: "Pick the voices this bed should make room for."}
@@ -91,7 +91,7 @@ export function AutomationToggle({
className={`hf-fx-automate w-[16px] flex-shrink-0 rounded-[3px] border font-mono text-[9px] leading-none ${
automated
? "border-panel-accent text-panel-accent"
: "border-panel-border-input text-panel-text-4 hover:text-panel-text-0"
: "border-panel-border-input text-panel-text-2 hover:text-panel-text-0"
}`}
aria-pressed={automated}
aria-label={automated ? `Remove ${label} automation` : `Automate ${label}`}
@@ -188,7 +188,7 @@ export function FxParamRow({
if (param.kind === "enum") {
return (
<label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-4">
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2">
{param.label}
</span>
<select
@@ -229,7 +229,7 @@ export function FxParamRow({
>
<span
className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${
automated ? "text-panel-accent" : "text-panel-text-4"
automated ? "text-panel-accent" : "text-panel-text-2"
}`}
>
{param.label}
@@ -279,7 +279,7 @@ export function FxParamRow({
}}
/>
{param.unit ? (
<span className="hf-fx-unit w-[22px] flex-shrink-0 font-mono text-[9px] text-panel-text-4">
<span className="hf-fx-unit w-[22px] flex-shrink-0 font-mono text-[9px] text-panel-text-2">
{param.unit}
</span>
) : null}
@@ -115,7 +115,7 @@ function Fader({
</span>
<span
className={`hf-fx-eq-value font-mono text-[9px] tabular-nums ${
moved ? "text-panel-accent" : "text-panel-text-4"
moved ? "text-panel-accent" : "text-panel-text-2"
}`}
>
{moved ? shown(value) : "0"}
@@ -154,10 +154,10 @@ export function FxEqModule({
>
Tone
</button>
<span className="font-mono text-[9px] text-panel-text-4">{bands.length}-band</span>
<span className="font-mono text-[9px] text-panel-text-2">{bands.length}-band</span>
<button
type="button"
className="hf-fx-remove px-1 text-[11px] text-panel-text-4 hover:text-panel-danger"
className="hf-fx-remove px-1 text-[11px] text-panel-text-2 hover:text-panel-danger"
aria-label="Remove Tone"
disabled={disabled}
onClick={onRemove}
@@ -179,7 +179,7 @@ export function FxEqModule({
/>
))}
</div>
<div className="mt-1.5 flex justify-between font-mono text-[8px] tracking-wide text-panel-text-4">
<div className="mt-1.5 flex justify-between font-mono text-[8px] tracking-wide text-panel-text-2">
<span>CUT</span>
<span>BOOST</span>
</div>
@@ -135,7 +135,7 @@ function FxMoveButton({
return (
<button
type="button"
className="hf-fx-move px-1 font-mono text-[10px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-25"
className="hf-fx-move px-1 font-mono text-[10px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-25"
title={label}
disabled={disabled}
onClick={onClick}
@@ -180,7 +180,7 @@ function FxNodeHeader({
and as a list when they are not and the difference decides whether an
author thinks the order matters. It does; it is audible. */}
{position !== undefined ? (
<span className="hf-fx-node-index shrink-0 font-mono text-[9px] tabular-nums text-panel-text-4">
<span className="hf-fx-node-index shrink-0 font-mono text-[9px] tabular-nums text-panel-text-2">
{String(position).padStart(2, "0")}
</span>
) : null}
@@ -194,7 +194,7 @@ function FxNodeHeader({
</button>
<button
type="button"
className="hf-fx-bypass rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
className="hf-fx-bypass rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
aria-pressed={bypassed}
title={bypassed ? "Enable" : "Bypass"}
disabled={disabled}
@@ -216,7 +216,7 @@ function FxNodeHeader({
/>
<button
type="button"
className="hf-fx-remove px-1 font-mono text-[11px] text-panel-text-4 hover:text-red-400 disabled:opacity-40"
className="hf-fx-remove px-1 font-mono text-[11px] text-panel-text-2 hover:text-red-400 disabled:opacity-40"
title="Remove"
disabled={disabled}
onClick={onRemove}
@@ -377,7 +377,7 @@ export function FxNodeRow({
onRemove={() => onRemove(index)}
/>
{summary ? (
<p className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-4">
<p className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2">
{summary}
</p>
) : null}
@@ -385,7 +385,7 @@ export function FxNodeRow({
<>
{/* What it is for, before what it is made of. */}
{copy?.does ? (
<p className="hf-fx-node-does border-t border-panel-border-input px-1.5 py-1 text-[10px] text-panel-text-4">
<p className="hf-fx-node-does border-t border-panel-border-input px-1.5 py-1 text-[10px] text-panel-text-2">
{copy.does}
</p>
) : null}
@@ -409,7 +409,7 @@ export function FxNodeRow({
/>
</div>
{profile ? (
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-4">
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-2">
<span className="truncate">{profile.ends.low}</span>
<span className="truncate text-right">{profile.ends.high}</span>
</p>
@@ -434,7 +434,7 @@ export function FxNodeRow({
author where the control is; this tells them which way to move
it, which is the question they actually have. */}
{copy?.primaryEnds ? (
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-4">
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-2">
<span className="truncate">{copy.primaryEnds.low}</span>
<span className="truncate text-right">{copy.primaryEnds.high}</span>
</p>
@@ -452,7 +452,7 @@ export function FxNodeRow({
{oneKnob ? (
<button
type="button"
className="hf-fx-node-details flex w-full items-center gap-1 border-t border-panel-border-input px-1.5 py-1 text-left font-mono text-[9px] uppercase tracking-wide text-panel-text-4 hover:text-panel-text-0"
className="hf-fx-node-details flex w-full items-center gap-1 border-t border-panel-border-input px-1.5 py-1 text-left font-mono text-[9px] uppercase tracking-wide text-panel-text-2 hover:text-panel-text-0"
aria-expanded={details}
onClick={() => setDetails((was) => !was)}
>
@@ -460,7 +460,7 @@ export function FxNodeRow({
Details {registryDef.label}
</button>
) : (
<p className="hf-fx-node-mechanism border-t border-panel-border-input px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<p className="hf-fx-node-mechanism border-t border-panel-border-input px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
Details {registryDef.label}
</p>
)}
@@ -13,6 +13,7 @@ import {
type HfAudioFxPresetFamily,
} from "@hyperframes/core/audio-fx-presets";
import { PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
/**
* Shelf names in the author's language, which is deliberately not the effect
@@ -28,6 +29,17 @@ const FAMILY_LABEL: Record<HfAudioFxPresetFamily, string> = {
};
export interface FxPresetMenuProps {
/**
* What the track reads as, from its id and filename.
*
* Only a confident "music" or "sfx" hides anything. `unknown` keeps the whole
* shelf, on the same principle the carve's source picker follows: a name is a
* hint, and a shelf that hides what somebody came for is worse than a long
* one. Nothing here is irreversible either the cost of a wrong guess is one
* shelf the author has to scroll past, against the cost of "My voice sounds
* amateur" sitting on a music bed.
*/
trackKind?: HfAudioNameKind;
onPick(id: string): void;
/**
* Play this preset on the running audio without persisting it, and revert on
@@ -46,7 +58,15 @@ export interface FxPresetMenuProps {
* in a column is a wall, and they are already the author's grouping rather than
* the registry's. See `plans/audio-fx-ux/README.md` §Decided.
*/
export function FxPresetMenu({ onPick, onAudition }: FxPresetMenuProps) {
export function FxPresetMenu({ trackKind, onPick, onAudition }: FxPresetMenuProps) {
// The voice presets all begin by cutting rumble out of a human voice and end
// in a compressor set for speech. On a music bed that is not a mild mismatch,
// it is the wrong instrument — and the shelf leads with the complaint, so it
// would be offering the author a problem they do not have.
const families =
trackKind === "music" || trackKind === "sfx"
? HF_AUDIO_FX_PRESET_FAMILIES.filter((f) => f !== "voice")
: HF_AUDIO_FX_PRESET_FAMILIES;
return (
<div
className="hf-fx-preset-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
@@ -59,9 +79,9 @@ export function FxPresetMenu({ onPick, onAudition }: FxPresetMenuProps) {
// button's focus, so it reverts and re-auditions rather than sticking.
onBlur={onAudition ? () => onAudition(null) : undefined}
>
{HF_AUDIO_FX_PRESET_FAMILIES.map((family) => (
{families.map((family) => (
<div key={family} className="hf-fx-preset-group space-y-0.5">
<span className="hf-fx-preset-group-label block font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<span className="hf-fx-preset-group-label block font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
{FAMILY_LABEL[family]}
</span>
{audioFxPresetsByFamily(family).map((preset) => (
@@ -85,7 +105,7 @@ export function FxPresetMenu({ onPick, onAudition }: FxPresetMenuProps) {
<span className="hf-fx-preset-problem block truncate text-[10px]">
{PRESET_PROBLEM[preset.id] ?? preset.description}
</span>
<span className="hf-fx-preset-name block truncate font-mono text-[9px] text-panel-text-4">
<span className="hf-fx-preset-name block truncate font-mono text-[9px] text-panel-text-2">
{preset.label}
</span>
{/* Hovering a preset plays it, and playing is otherwise invisible:
@@ -0,0 +1,142 @@
// @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,
fxPresetBackground,
fxPresetStyle,
fxTintWash,
} 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}|${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 vibrant, and light enough to read on the panel", () => {
// The rack sits on #0C0C0E. A title has to carry real colour to be worth
// having, and still clear contrast against near-black.
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
const match = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
expect(match, `${id} is not a plain hsl() colour`).toBeTruthy();
const saturation = Number(match?.[1 + 1]);
const lightness = Number(match?.[3]);
expect(saturation, `${id} is too washed out to read as a colour`).toBeGreaterThanOrEqual(60);
expect(lightness, `${id} is too dark against the panel`).toBeGreaterThanOrEqual(58);
expect(lightness, `${id} is so light the hue disappears`).toBeLessThanOrEqual(78);
}
});
it("keeps the title hues clear of the accent, which means something else", () => {
// The panel spends #3CE6AC (hue 160) on "automated" and "playing". A title
// sitting on that hue reads as a status the preset does not have.
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
const hue = Number(/hsl\(\s*(\d+),/.exec(style.color)?.[1]);
const distance = Math.min(Math.abs(hue - 160), 360 - Math.abs(hue - 160));
expect(distance, `${id} sits on the accent's hue`).toBeGreaterThan(20);
}
});
it("backs each preset with its own hue, dark enough to sit under the panel", () => {
// Derived from the title rather than picked, so a background cannot drift
// away from the title it belongs to.
const seen = new Set<string>();
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
const bg = fxPresetBackground(id);
expect(bg, `${id} has no background`).toBeTruthy();
const titleHue = /hsl\(\s*(\d+),/.exec(style.color)?.[1];
expect(bg, `${id}'s background is a different hue from its title`).toContain(
`hsl(${titleHue},`,
);
const lightness = Number(/,\s*(\d+)%\s*\)/.exec(bg ?? "")?.[1]);
expect(lightness, `${id}'s background would fight the controls on it`).toBeLessThanOrEqual(
16,
);
seen.add(bg ?? "");
}
// Presets that share a title colour share a background — the repair family
// is deliberately uniform — but the character ones must not.
const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character");
expect(new Set(character.map((p) => fxPresetBackground(p.id))).size).toBe(character.length);
});
it("has no background for a preset it does not know", () => {
expect(fxPresetBackground("not-a-preset")).toBeNull();
});
it("falls back rather than failing for a preset it does not know", () => {
expect(fxPresetStyle("not-a-preset")).toBe(FX_PRESET_STYLE_DEFAULT);
});
});
/**
* The wash is shared with the smart modules, which do not have preset entries
* to look up the carve derives its colour from `fxFamilyTint`, and that one
* COMPUTES its lightness, so it emits `62.0%` where every preset colour is a
* whole number. An integer-only pattern reads those as no match and returns
* null, which renders as no wash at all rather than as an error.
*/
describe("the tint wash", () => {
it("reads a computed colour, decimals and all", () => {
expect(fxTintWash("hsl(95, 32%, 62.0%)")).toBe("hsl(95, 22%, 11%)");
expect(fxTintWash("hsl(95, 32.5%, 76%)")).toBe("hsl(95, 22%, 11%)");
});
it("keeps the hue and takes everything else to near-black", () => {
// Same hue in, same hue out: the wash cannot clash with the title it sits
// behind, because it IS the title's hue.
expect(fxTintWash("hsl(288, 85%, 72%)")).toBe("hsl(288, 22%, 11%)");
});
it("declines a colour it cannot read rather than guessing", () => {
expect(fxTintWash("#52525B")).toBeNull();
expect(fxTintWash("rebeccapurple")).toBeNull();
});
});
@@ -0,0 +1,216 @@
/**
* 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: 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: "text-[12px] uppercase tracking-wide",
color: "hsl(220, 24%, 72%)",
family: FACE.terminal,
};
export const FX_PRESET_STYLE: Record<string, FxPresetStyle> = {
// --- 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(202, 82%, 66%)",
family: FACE.geometric,
},
"voice-broadcast": {
type: "text-[13px] font-bold uppercase tracking-[0.14em]",
color: "hsl(214, 84%, 70%)",
family: FACE.editorial,
},
"voice-warm": {
type: "text-[14px] italic tracking-normal",
color: "hsl(32, 88%, 66%)",
family: FACE.bookish,
},
// --- repair: workshop labels. Fixed, plain, nothing decorative ------------
"rumble-cut": {
type: "text-[12px] uppercase tracking-[0.18em]",
color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
"room-gate": {
type: "text-[12px] uppercase tracking-[0.18em]",
color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
"boom-tame": {
type: "text-[12px] uppercase tracking-[0.18em]",
color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
"harsh-tame": {
type: "text-[12px] uppercase tracking-[0.18em]",
color: "hsl(196, 78%, 64%)",
family: FACE.terminal,
},
// --- character: the costumes. This is where type does the work ------------
// 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(186, 85%, 62%)",
family: FACE.terminal,
},
// A dial face: high-contrast serif caps, spaced like printed frequencies.
"radio-am": {
type: "text-[14px] uppercase tracking-[0.24em]",
color: "hsl(42, 92%, 62%)",
family: FACE.editorial,
},
// Shouted through a horn — the heaviest, narrowest thing available, leaning.
megaphone: {
type: "text-[17px] font-black italic uppercase tracking-tight",
color: "hsl(12, 90%, 64%)",
family: FACE.condensed,
},
// Struck on a machine, played back years later.
"lofi-tape": {
type: "text-[13px] tracking-wide",
color: "hsl(28, 72%, 62%)",
family: FACE.typewriter,
},
// Bolted to a wall in a station concourse.
"pa-system": {
type: "text-[13px] uppercase tracking-[0.26em]",
color: "hsl(222, 80%, 70%)",
family: FACE.engraved,
},
// Small, squeezed through a grille, no room for anything but the letters.
intercom: {
type: "text-[12px] font-bold uppercase tracking-tighter",
color: "hsl(96, 68%, 60%)",
family: FACE.terminal,
},
// The name is a joke and the type is in on it.
"doofus-worble": {
type: "text-[16px] tracking-[0.1em]",
color: "hsl(288, 85%, 72%)",
family: FACE.theatrical,
},
// --- space: rooms. Light and wide, because that is what space looks like ---
"room-tight": {
type: "text-[13px] uppercase tracking-[0.2em]",
color: "hsl(252, 74%, 72%)",
family: FACE.geometric,
},
"room-natural": {
type: "text-[13px] uppercase tracking-[0.26em]",
color: "hsl(258, 78%, 72%)",
family: FACE.geometric,
},
// The biggest room gets the widest setting — the word itself opens out.
hall: {
type: "text-[15px] uppercase tracking-[0.4em]",
color: "hsl(246, 84%, 74%)",
family: FACE.engraved,
},
"slap-echo": {
type: "text-[13px] uppercase tracking-[0.28em]",
color: "hsl(274, 76%, 70%)",
family: FACE.geometric,
},
"dub-throw": {
type: "text-[14px] italic tracking-[0.3em]",
color: "hsl(312, 78%, 70%)",
family: FACE.editorial,
},
};
export function fxPresetStyle(presetId: string): FxPresetStyle {
return FX_PRESET_STYLE[presetId] ?? FX_PRESET_STYLE_DEFAULT;
}
/**
* The wash behind a titled module, derived from the colour of its own title.
*
* Derived rather than picked: twenty hand-chosen pairs is twenty chances for
* one to clash with its own title, and a hue rotation cannot. Same hue,
* saturation pulled right down and lightness taken to near-black, so the panel
* reads as tinted rather than coloured the rack sits on `#0C0C0E` and
* anything with real lightness here would fight every control on top of it.
*
* Takes a colour rather than an id so the smart modules can use it too: the
* carve has a title worth setting and no preset entry to look up. Returns null
* for a colour it cannot read, which is how a caller opts out.
*/
export function fxTintWash(color: string): string | null {
// Decimals allowed: the preset colours are whole numbers, but `fxFamilyTint`
// computes its lightness and emits `62.0%`, which an integer-only pattern
// silently declines — the module then renders with no wash at all.
const num = String.raw`\d+(?:\.\d+)?`;
const hsl = new RegExp(`hsl\\(\\s*(${num}),\\s*${num}%,\\s*${num}%\\s*\\)`).exec(color);
if (!hsl) return null;
// 22% saturation at 11% lightness: present enough to tell two brackets apart
// at a glance, dark enough that white body text still clears WCAG AA on it.
return `hsl(${hsl[1]}, 22%, 11%)`;
}
/** The wash behind a preset's bracket. Null when the preset has no character. */
export function fxPresetBackground(presetId: string): string | null {
const style = FX_PRESET_STYLE[presetId];
return style ? fxTintWash(style.color) : null;
}
@@ -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";
/**
@@ -88,6 +89,7 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
onAutomateParam={overrides.onAutomateParam}
onRemoveParamAutomation={overrides.onRemoveParamAutomation}
onRemoveNodeAutomation={overrides.onRemoveNodeAutomation}
onRemoveNodesAutomation={overrides.onRemoveNodesAutomation}
onAutomatePreset={overrides.onAutomatePreset}
onRemovePresetAutomation={overrides.onRemovePresetAutomation}
onAuditionTransport={overrides.onAuditionTransport}
@@ -97,6 +99,7 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
levelled={overrides.levelled}
onAuditionLevel={overrides.onAuditionLevel}
auditioningLevel={overrides.auditioningLevel}
trackKind={overrides.trackKind}
/>,
);
return { host, root, onChainChange, onChainPreview, onCarveChange };
@@ -336,6 +339,42 @@ describe("FxSection chain", () => {
expect(openFrequency().value).toBe("1600");
});
/**
* The shelf leads with the complaint a preset answers, so offering the voice
* family on a music bed offers the author a problem they cannot have. The
* hiding is deliberately timid: only a name that plainly reads as music or as
* an effect loses anything, because a name is a hint and hiding what somebody
* came for costs more than one extra shelf to scroll past.
*/
const shelfFamilies = (host: HTMLElement): string[] =>
Array.from(host.querySelectorAll(".hf-fx-preset-group-label")).map((e) =>
(e.textContent ?? "").trim(),
);
it("keeps the voice presets on a track whose name says nothing", () => {
const { host } = mount({ trackKind: "unknown" });
click(byText(host, "button", "Presets"));
expect(shelfFamilies(host)).toEqual(["Voice", "Fix", "Character", "Space"]);
});
it("keeps them when nothing classified the track at all", () => {
const { host } = mount({});
click(byText(host, "button", "Presets"));
expect(shelfFamilies(host)).toContain("Voice");
});
it("drops the voice shelf on a music bed, and on an effect", () => {
for (const kind of ["music", "sfx"] as const) {
const { host } = mount({ trackKind: kind });
click(byText(host, "button", "Presets"));
expect(shelfFamilies(host)).toEqual(["Fix", "Character", "Space"]);
// The rest of the shelf is untouched — this hides one family, it does not
// narrow the panel down to "repair".
expect(presetButton(host, "telephone")).toBeTruthy();
expect(presetButton(host, "voice-clean")).toBeUndefined();
}
});
it("applies a preset as ordinary nodes, tagged with where they came from", () => {
const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
click(byText(host, "button", "Presets"));
@@ -415,7 +454,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);
});
@@ -548,8 +588,8 @@ describe("FxSection chain", () => {
});
it("takes the preset back out whole, with its lanes", () => {
const onRemoveNodeAutomation = vi.fn();
const { host, onChainChange } = mount({ chain: applied(), onRemoveNodeAutomation });
const onRemoveNodesAutomation = vi.fn();
const { host, onChainChange } = mount({ chain: applied(), onRemoveNodesAutomation });
click(
host
.querySelector("[data-fx-preset='telephone']")
@@ -559,9 +599,16 @@ describe("FxSection chain", () => {
const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
expect(next.nodes.filter((n) => n.fromPreset === "telephone")).toEqual([]);
expect(next.nodes.map((n) => n.id)).toEqual(["own"]);
// An orphaned lane keeps driving a parameter that is no longer in the
// graph, and the next effect added inherits it with the id.
expect(onRemoveNodeAutomation).toHaveBeenCalled();
// ONE call carrying every node id, not one call per node: each write is
// computed from the same snapshot and replaces the whole attribute, so a
// loop kept only its last write and left the rest as orphans — which the
// next effect added would inherit along with the id.
expect(onRemoveNodesAutomation).toHaveBeenCalledTimes(1);
const [ids, presetId] = onRemoveNodesAutomation.mock.calls[0] ?? [];
expect((ids as string[]).length).toBeGreaterThan(1);
// And the whole-preset amount lane, which belongs to no node and would
// otherwise survive to be resurrected by re-applying the preset.
expect(presetId).toBe("telephone");
});
});
@@ -698,6 +745,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<HTMLElement>(".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
@@ -17,7 +17,11 @@ import {
type HfAudioFxParam,
type HfAudioFxParamValues,
} from "@hyperframes/core/audio-fx";
import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
import {
DEFAULT_CARVE,
type HfAudioNameKind,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
import {
addAudioEq,
@@ -35,6 +39,7 @@ import {
type HfAudioFxJob,
} from "@hyperframes/core/audio-fx-jobs";
import { FxParamRow } from "./propertyPanelFxControls.js";
import { fxPresetBackground, fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -88,6 +93,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;
/**
* Delete the lanes of SEVERAL nodes at once, plus the whole-preset lane when
* a preset id is given. One call, because each write is computed from the same
* snapshot and replaces the whole attribute a loop keeps only its last write.
*/
onRemoveNodesAutomation?(nodeIds: readonly string[], presetId?: 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. */
@@ -136,6 +147,12 @@ export interface FxSectionProps {
carvedAgainstBy?: string | null;
/** Other audio elements that could act as the carve source. */
sourceOptions: AudioTrackOption[];
/**
* What this track reads as, from its id and filename. Passed through to the
* preset shelf, which hides the Voice family on a track that is plainly music
* or an effect. Absent means unknown, and unknown keeps everything.
*/
trackKind?: HfAudioNameKind;
analysing?: boolean;
disabled?: boolean;
}
@@ -147,6 +164,7 @@ export function FxSection({
onAutomateParam,
onRemoveParamAutomation,
onRemoveNodeAutomation,
onRemoveNodesAutomation,
onChainChange,
onChainPreview,
carve,
@@ -154,6 +172,7 @@ export function FxSection({
onCarveChange,
onCarvePreview,
sourceOptions,
trackKind,
analysing,
disabled,
onLevel,
@@ -401,15 +420,20 @@ export function FxSection({
* the next effect added inherits it.
*/
const removeRun = useCallback(
(items: { node: HfAudioFxNode; i: number }[]) => {
for (const { node } of items) {
if (node.id) onRemoveNodeAutomation?.(node.id);
}
(items: { node: HfAudioFxNode; i: number }[], presetId?: string) => {
// One call, not a loop: every write is computed from the same snapshot and
// replaces the whole attribute, so a loop kept only its last write and left
// the other nodes' lanes behind as orphans. The preset id goes with it —
// the `fx.preset.<id>` amount lane belongs to the preset, not to any node,
// so nothing else would ever collect it, and re-applying the preset later
// resurrected the old ramp.
const ids = items.map(({ node }) => node.id).filter((id): id is string => Boolean(id));
if (ids.length > 0 || presetId) onRemoveNodesAutomation?.(ids, presetId);
const slots = new Set(items.map((item) => item.i));
mutate(chain.nodes.filter((_, i) => !slots.has(i)));
setOpenNode(null);
},
[chain.nodes, mutate, onRemoveNodeAutomation],
[chain.nodes, mutate, onRemoveNodesAutomation],
);
const removeNode = useCallback(
@@ -462,6 +486,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<ReadonlySet<string>>(new Set());
const eqIds = useMemo(() => audioEqIds(chain), [chain]);
/**
@@ -502,12 +537,17 @@ export function FxSection({
);
const removeEq = useCallback(
(eqId: string) => {
for (const node of chain.nodes) {
if (node.fromEq === eqId && node.id) onRemoveNodeAutomation?.(node.id);
}
// Batched for the same reason as `removeRun` — this loop had the identical
// last-write-wins bug and was only unreachable because an EQ band row
// offers no automation toggle today.
const ids = chain.nodes
.filter((node) => node.fromEq === eqId)
.map((node) => node.id)
.filter((id): id is string => Boolean(id));
if (ids.length > 0) onRemoveNodesAutomation?.(ids);
mutate(removeAudioEq(chain, eqId).nodes);
},
[chain, mutate, onRemoveNodeAutomation],
[chain, mutate, onRemoveNodesAutomation],
);
const moveNode = useCallback(
@@ -555,7 +595,7 @@ export function FxSection({
{/* The rack IS the signal path, and saying so costs two lines. Without
them the order reads as a list, which is the one reading that makes
"move up" look cosmetic it is the most consequential control here. */}
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">In</span>
<span>this track</span>
</p>
@@ -593,7 +633,7 @@ export function FxSection({
/>
))}
{handBuilt.length === 0 && eqIds.length === 0 ? (
<p className="hf-fx-empty py-1 text-[11px] text-panel-text-4">
<p className="hf-fx-empty py-1 text-[11px] text-panel-text-2">
{showCarve ? "No other effects on this track." : "No effects on this track."}
</p>
) : (
@@ -634,23 +674,74 @@ 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 ?? "");
const background = fxPresetBackground(run.preset ?? "");
return (
<div
key={`preset-${run.preset}-${run.items[0]?.i}`}
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-dashed border-panel-border-input p-1"
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-l-2 border-dashed border-panel-border-input p-1"
data-fx-preset={run.preset}
data-collapsed={collapsed ? "" : undefined}
// The bracket's edge carries the preset's own colour, the way a
// module's carries its family's — and the wash behind it is the
// same hue taken to near-black, so a rack with three presets in
// it reads as three regions rather than one long list.
style={{
borderLeftColor: style.color,
...(background ? { backgroundColor: background } : {}),
}}
>
<div className="hf-fx-preset-run-head flex min-h-6 items-center gap-1 px-0.5">
<span className="hf-fx-preset-run-label min-w-0 flex-1 truncate font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<button
type="button"
className={`hf-fx-preset-run-label min-w-0 flex-1 truncate text-left leading-tight hover:opacity-80 ${style.type}`}
// The face and the colour are data, not classes: a Tailwind
// class cannot name a font stack the config does not know,
// and adding eight to the config to style one panel would
// put them in every autocomplete in the studio.
style={{
color: style.color,
...(style.family ? { fontFamily: style.family } : {}),
}}
aria-expanded={!collapsed}
title={
collapsed
? `Show what ${preset.label} contains`
: `Hide ${preset.label}'s effects`
}
onClick={() =>
setCollapsedRuns((was) => {
const next = new Set(was);
if (collapsed) next.delete(runKey);
else next.add(runKey);
return next;
})
}
>
<span
className="hf-fx-preset-run-caret pr-1 font-mono opacity-60"
aria-hidden="true"
>
{collapsed ? "\u25B8" : "\u25BE"}
</span>
{preset.label}
</span>
{/* Collapsed, the count is what says the preset is still a
chain rather than one opaque effect. */}
{collapsed ? (
<span className="hf-fx-preset-run-count pl-1.5 font-mono text-[9px] opacity-60">
{run.items.length}
</span>
) : null}
</button>
{/* 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
get back out of it. */}
<button
type="button"
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
aria-pressed={runOn}
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
disabled={disabled}
@@ -660,10 +751,10 @@ export function FxSection({
</button>
<button
type="button"
className="hf-fx-preset-run-remove px-1 font-mono text-[11px] text-panel-text-4 hover:text-red-400 disabled:opacity-40"
className="hf-fx-preset-run-remove px-1 font-mono text-[11px] text-panel-text-2 hover:text-red-400 disabled:opacity-40"
title={`Remove ${preset.label}`}
disabled={disabled}
onClick={() => removeRun(run.items)}
onClick={() => removeRun(run.items, run.preset)}
>
&times;
</button>
@@ -688,12 +779,12 @@ export function FxSection({
: undefined
}
/>
{rows}
{collapsed ? null : rows}
</div>
);
})
)}
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">Out</span>
<span>to mix</span>
</p>
@@ -716,7 +807,7 @@ export function FxSection({
}}
>
<div className="hf-fx-add-group flex flex-wrap items-center gap-1">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
Tone
</span>
{onLevel ? (
@@ -771,7 +862,7 @@ export function FxSection({
</div>
{grouped.map(({ group, defs, jobs }) => (
<div key={group} className="hf-fx-add-group flex flex-wrap items-center gap-1">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
{GROUP_LABEL[group]}
</span>
{jobs.map((job) => (
@@ -829,6 +920,7 @@ export function FxSection({
{picking ? (
<FxPresetMenu
trackKind={trackKind}
onPick={applyPreset}
onAudition={
onChainPreview
@@ -848,7 +940,7 @@ export function FxSection({
<div className="flex gap-1">
<button
type="button"
className="hf-fx-preset w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
className="hf-fx-preset w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
aria-expanded={picking}
disabled={disabled}
onClick={() => {
@@ -864,7 +956,7 @@ export function FxSection({
</button>
<button
type="button"
className="hf-fx-add w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-4 hover:text-panel-text-0 disabled:opacity-40"
className="hf-fx-add w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
aria-expanded={adding}
disabled={disabled}
onClick={() => {