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
@@ -698,3 +698,66 @@ describe("a preset's run is wrapped in a wet/dry blend", () => {
expect(live).toEqual([]);
});
});
describe("a preset's wrap stays with the nodes it belongs to", () => {
const peak = (over: Partial<HfAudioFxNode>): HfAudioFxNode =>
({
type: "peaking",
enabled: true,
params: defaultAudioFxParams("peaking"),
...over,
}) as HfAudioFxNode;
it("rebuilds when a reorder moves a node across a preset boundary", () => {
// The type sequence is identical either way, so without preset identity in
// the shape the chain updated in place and left the wet/dry wrap bracketing
// the hand-added effect instead of the preset's — preview blending out the
// author's own node while the render, which rebuilds, blended out the
// preset's.
const before: HfAudioFxChain = {
version: 1,
nodes: [peak({ id: "p1", fromPreset: "boom-tame" }), peak({ id: "own" })],
};
const after: HfAudioFxChain = {
version: 1,
nodes: [peak({ id: "own" }), peak({ id: "p1", fromPreset: "boom-tame" })],
};
expect(buildFxChain(asCtx(ctx()), before).update(after)).toBe(false);
});
it("gives every run of one preset its own blend", () => {
// A preset pulled apart by a reorder occupies two runs. Keyed by id and
// assigned, the second wrap overwrote the first, so a whole-preset lane
// reached one fragment and the switch silently left the rest applied.
const split: HfAudioFxChain = {
version: 1,
nodes: [
peak({ id: "t1", fromPreset: "telephone" }),
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
peak({ id: "t2", fromPreset: "telephone" }),
],
};
const built = buildFxChain(asCtx(ctx()), split);
// Two wraps, so two wet/dry pairs — four params under the one id.
expect(built.presets.telephone).toHaveLength(4);
});
it("pushes an amount into each run of a split preset, not one of them twice", () => {
const at = (amount: number): HfAudioFxChain => ({
version: 1,
nodes: [
peak({ id: "t1", fromPreset: "telephone", presetAmount: amount }),
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
peak({ id: "t2", fromPreset: "telephone", presetAmount: amount }),
],
});
const built = buildFxChain(asCtx(ctx()), at(1));
expect(built.update(at(0))).toBe(true);
// Every wet leg off and every dry leg fully open: switching the preset off
// has to silence all of it, not the last fragment only.
const wets = (built.presets.telephone ?? []).filter((_, i) => i % 2 === 0);
const drys = (built.presets.telephone ?? []).filter((_, i) => i % 2 === 1);
for (const t of wets) expect(t.param.value).toBe(0);
for (const t of drys) expect(t.param.value).toBe(1);
});
});
+23 -4
View File
@@ -599,7 +599,16 @@ function shapeOf(chain: HfAudioFxChain): string {
// same reason: pushed into the running graph it would be a no-op, and
// preview would keep sweeping on a triangle while the render used a sine.
const wave = node.type === "phaser" ? `~${p.type}` : "";
return `${node.type}${poles}${fixedFreq}${wave}`;
// Which preset run this node belongs to, for the same reason again: the
// wet/dry wrap is WIRED around a run at construction, so moving a node
// across a preset boundary changes the graph's shape even when the type
// sequence is identical. Without this, reordering a hand-added peaking
// filter past a preset's peaking filter kept the in-place update path and
// left the wrap bracketing the wrong effect — preview blending out the
// author's own node while the render, which rebuilds, blended out the
// preset's.
const run = node.fromPreset ? `%${node.fromPreset}` : "";
return `${node.type}${poles}${fixedFreq}${wave}${run}`;
})
.join("|");
}
@@ -674,8 +683,14 @@ export function buildFxChain(
const shape = shapeOf(chain);
// Accumulated, not assigned. A preset pulled apart by a reorder occupies more
// than one run, and each run gets its own wrap — keying by id and assigning
// dropped every wrap but the last, so a whole-preset lane drove one fragment
// and left the rest at full strength while the switch read "Off".
const presetTargets: Record<string, FxParamTarget[]> = {};
for (const p of presets) presetTargets[p.id] = mixTargets(p.wet.gain, p.dry.gain);
for (const p of presets) {
(presetTargets[p.id] ??= []).push(...mixTargets(p.wet.gain, p.dry.gain));
}
return {
input,
output,
@@ -699,10 +714,14 @@ export function buildFxChain(
// The blend is a value like any other: switching a preset off writes
// `presetAmount`, and pushing it into the running graph is what keeps that
// from being a rebuild — and from restarting the audio underneath it.
// Walked in step with the build, not looked up by id: `find` returned the
// first wrap for every run sharing a preset id, so a split preset wrote
// one wrap twice and never touched the other.
let wrapIndex = 0;
for (const run of presetRuns(enabledAudioFxNodes(next))) {
if (!run.preset) continue;
const wrap = presets.find((p) => p.id === run.preset);
if (!wrap) continue;
const wrap = presets[wrapIndex++];
if (!wrap || wrap.id !== run.preset) continue;
wrap.wet.gain.value = run.amount;
wrap.dry.gain.value = 1 - run.amount;
}
+71
View File
@@ -103,3 +103,74 @@ describe("audioBandAt", () => {
expect(audioBandAt(Number.NaN)).toBeUndefined();
});
});
/**
* Copy that assumes the track is a voice.
*
* The rack sits on whatever the author selected a music bed, a sound effect,
* a room tone. Every effect, every named job and every profile is offered on
* all of them, so a control that reads "Thins the voice out" on a synth pad is
* describing something the author cannot hear and does not have. It is the same
* rule this file's header already states describe a control by what changes in
* THE SOUND applied to the words rather than to the mechanism.
*
* The voice presets are the deliberate exception: they are voice by definition,
* and the shelf hides them on a track that classifies as music or as an effect,
* so "My voice sounds amateur" is only ever read next to a voice.
*
* This is a lint on the words, not a judgement about mixing. It exists because
* the offending strings were written one at a time over months and read fine in
* isolation nobody notices the assumption until they apply Cut Rumble to a
* bass line and the panel tells them it will thin their voice out.
*/
describe("no copy assumes the track is a voice", () => {
/** Words that only mean something if the material is speech. */
const SPEECH =
/\b(voice|vocal|voices|speech|spoken|word|words|sentence|sentences|syllable|syllables|narration|narrator|talking|chest)\b/i;
/** Presets whose whole purpose is a voice, so their copy may say so. */
const VOICE_PRESETS = new Set(
HF_AUDIO_FX_PRESETS.filter((p) => p.family === "voice").map((p) => p.id),
);
const offenders = (entries: [string, string][]): string[] =>
entries.filter(([, text]) => SPEECH.test(text)).map(([where, text]) => `${where}: "${text}"`);
it("not in an effect's name, blurb, reach-for line or any knob", () => {
const entries: [string, string][] = [];
for (const [id, copy] of Object.entries(EFFECT_COPY)) {
entries.push([`${id}.title`, copy.title], [`${id}.does`, copy.does]);
entries.push([`${id}.reachFor`, copy.reachFor]);
entries.push([`${id}.primaryEnds.low`, copy.primaryEnds.low]);
entries.push([`${id}.primaryEnds.high`, copy.primaryEnds.high]);
for (const [key, param] of Object.entries(copy.params)) {
entries.push([`${id}.${key}.label`, param.label]);
if (param.hint) entries.push([`${id}.${key}.hint`, param.hint]);
if (param.ends) {
entries.push([`${id}.${key}.ends.low`, param.ends.low]);
entries.push([`${id}.${key}.ends.high`, param.ends.high]);
}
}
}
expect(offenders(entries)).toEqual([]);
});
it("not in the band vocabulary, which every spectral module shares", () => {
// These names get taught once and then reused everywhere, so a voice-only
// word here spreads to every filter in the rack.
expect(offenders(BANDS.map((b) => [b.name, b.says]))).toEqual([]);
});
it("not in the complaint a non-voice preset answers", () => {
const entries = Object.entries(PRESET_PROBLEM).filter(([id]) => !VOICE_PRESETS.has(id));
expect(offenders(entries as [string, string][])).toEqual([]);
});
it("still lets the voice presets say what they are for", () => {
// The exception has to be real, or the rule above is untested — a catalogue
// where nothing said "voice" would pass every assertion here vacuously.
const voiced = Object.entries(PRESET_PROBLEM).filter(([id]) => VOICE_PRESETS.has(id));
expect(voiced.length).toBeGreaterThan(0);
expect(voiced.some(([, text]) => SPEECH.test(text))).toBe(true);
});
});
+14 -13
View File
@@ -66,13 +66,13 @@ export const EFFECT_COPY: Record<string, EffectCopy> = {
does: "Cuts the very bottom — traffic, footsteps, air conditioning, hands on the mic.",
reachFor: "There's a low hum or thump under everything.",
primary: "frequency",
primaryEnds: { low: "Only the deepest", high: "Thins the voice out" },
primaryEnds: { low: "Only the deepest", high: "Thins it out" },
band: [20, 300],
params: {
frequency: {
label: "Cut below",
hint: "Everything under this is removed.",
ends: { low: "Only the deepest", high: "Thins the voice out" },
ends: { low: "Only the deepest", high: "Thins it out" },
},
q: { label: "Sharpness", hint: "How abruptly the cut starts." },
poles: { label: "Steepness", hint: "How fast it falls away below the point." },
@@ -134,7 +134,7 @@ export const EFFECT_COPY: Record<string, EffectCopy> = {
compressor: {
title: "Even Out Loudness",
does: "Brings the quiet parts up and holds the loud parts down, so nothing jumps out at the listener.",
reachFor: "Some words are much louder than others.",
reachFor: "Some parts are much louder than others.",
primary: "strength",
primaryEnds: { low: "Barely touched", high: "Very even, quite squashed" },
params: {
@@ -168,10 +168,10 @@ export const EFFECT_COPY: Record<string, EffectCopy> = {
},
gate: {
title: "Silence the Gaps",
does: "Mutes the pauses between words. Room tone under speech stays — this closes the silences, it does not remove noise.",
reachFor: "You can hear the room breathing between sentences.",
does: "Mutes the pauses. Whatever sits underneath stays — this closes the gaps, it does not remove noise.",
reachFor: "You can hear the room in the gaps.",
primary: "strength",
primaryEnds: { low: "Only true silence", high: "Cuts quiet words too" },
primaryEnds: { low: "Only true silence", high: "Cuts quiet parts too" },
params: {
threshold: { label: "Quieter than this is a gap" },
range: {
@@ -182,7 +182,7 @@ export const EFFECT_COPY: Record<string, EffectCopy> = {
attack: { label: "How fast it opens" },
release: {
label: "How fast it closes",
ends: { low: "Clips word endings", high: "Leaves tails intact" },
ends: { low: "Clips tails short", high: "Leaves tails intact" },
},
knee: { label: "How gradual" },
},
@@ -286,11 +286,11 @@ export const EFFECT_COPY: Record<string, EffectCopy> = {
*/
export const BANDS: { from: number; to: number; name: string; says: string }[] = [
{ from: 20, to: 80, name: "Rumble", says: "traffic, footsteps, handling" },
{ from: 80, to: 250, name: "Weight", says: "chest, body, warmth" },
{ from: 80, to: 250, name: "Weight", says: "body, warmth, low end" },
{ from: 250, to: 600, name: "Mud", says: "boxy, muffled, cardboard" },
{ from: 600, to: 2000, name: "Middle", says: "the body of a voice" },
{ from: 2000, to: 5000, name: "Presence", says: "consonants, intelligibility" },
{ from: 5000, to: 10000, name: "Edge", says: "sibilance, harshness" },
{ from: 600, to: 2000, name: "Middle", says: "the body of the sound" },
{ from: 2000, to: 5000, name: "Presence", says: "definition, consonants" },
{ from: 5000, to: 10000, name: "Edge", says: "harshness, sibilance" },
{ from: 10000, to: 20000, name: "Air", says: "sparkle, openness" },
];
@@ -318,8 +318,8 @@ export const PRESET_PROBLEM: Record<string, string> = {
"voice-broadcast": "I want it to sound like radio",
"voice-warm": "I want it intimate and close",
"rumble-cut": "There's a hum or thump underneath",
"room-gate": "I can hear the room between sentences",
"boom-tame": "My voice sounds boomy",
"room-gate": "I can hear the room in the gaps",
"boom-tame": "It sounds boomy",
"harsh-tame": "It's harsh and tiring to listen to",
telephone: "Make it sound like a phone call",
"radio-am": "Make it sound like an old radio",
@@ -327,6 +327,7 @@ export const PRESET_PROBLEM: Record<string, string> = {
"lofi-tape": "Make it sound like an old tape",
"pa-system": "Make it sound like a station announcement",
intercom: "Make it sound like a door intercom",
"doofus-worble": "Make it wobble like it is seasick",
"room-tight": "It sounds dry and stuck to the speaker",
"room-natural": "It should sound like a real place",
hall: "It should sound far away and big",
+17
View File
@@ -61,3 +61,20 @@ describe("named jobs", () => {
for (const id of ids) expect(getAudioFxJob(id)?.id).toBe(id);
});
});
/**
* A job's `does` is the complaint that leads to it, shown in the add menu on
* whatever track is selected so it is under the same rule as the effect copy
* in `audioFxCopy.test.ts`: it may not assume the material is speech. Reduce Mud
* is as right on a boxy guitar as on a boxy voice, and the menu should say so.
*/
describe("no job assumes the track is a voice", () => {
const SPEECH =
/\b(voice|vocal|speech|spoken|word|words|sentence|syllable|narration|talking|chest)\b/i;
it("names the symptom without naming the source", () => {
const bad = HF_AUDIO_FX_JOBS.filter((j) => SPEECH.test(`${j.label} ${j.does}`)).map(
(j) => `${j.id}: "${j.does}"`,
);
expect(bad).toEqual([]);
});
});
+2 -2
View File
@@ -51,7 +51,7 @@ export const HF_AUDIO_FX_JOBS: readonly HfAudioFxJob[] = [
{
id: "tame-boominess",
label: "Tame Boominess",
does: "Too much chest — it booms.",
does: "Too much low-end body — it booms.",
type: "peaking",
params: { frequency: 200, gain: -4, q: 1.4 },
},
@@ -72,7 +72,7 @@ export const HF_AUDIO_FX_JOBS: readonly HfAudioFxJob[] = [
{
id: "add-clarity",
label: "Add Clarity",
does: "Words are hard to make out.",
does: "It is hard to make out — it sits back.",
type: "peaking",
params: { frequency: 3000, gain: 2.5, q: 1 },
},
+55 -14
View File
@@ -197,19 +197,32 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
{
type: "saturate",
label: "Circuit Grit",
params: { type: "tanh", threshold: -18, output: -2 },
params: { type: "tanh", threshold: -9, output: -2 },
},
],
),
preset("radio-am", "character", "AM Radio", "Narrow, gritty and a little crushed.", [
{ type: "highpass", label: "Strip the Bass", params: { frequency: 400, q: 0.707, poles: "2" } },
// Narrower than the megaphone at BOTH ends, which is most of the difference
// between them: an AM channel is a few kHz wide and the receiver rolls off
// well before a horn does.
{ type: "highpass", label: "Strip the Bass", params: { frequency: 220, q: 0.707, poles: "2" } },
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 3000, q: 0.707, poles: "2" },
params: { frequency: 2200, q: 0.707, poles: "2" },
},
{ type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -15, output: -2 } },
{ type: "bitcrush", label: "Crunch", params: { bits: 10, samples: 1, mix: 0.25 } },
// Where the telephone honks, a receiver DIPS: the IF filter's droop, and the
// reason the two stop sounding alike. Telephone's band is its identity (it
// is the G.712 passband), so the radio is what moves.
{ type: "peaking", label: "IF Droop", params: { frequency: 1200, gain: -5, q: 0.9 } },
// And a lift at the bottom of the band — AM is boxy where a phone is thin.
{ type: "lowshelf", label: "Boxy", params: { frequency: 500, gain: 4 } },
// Soft — a receiver compressing, not a driver being overdriven. `tanh`
// rounds the peaks where the megaphone's `hard` clips them flat.
{ type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -8, output: -1 } },
// The crush is the AM signature: quantisation noise reads as carrier hiss,
// and it is the one thing the megaphone has none of.
{ type: "bitcrush", label: "Carrier Hiss", params: { bits: 8, samples: 1, mix: 0.45 } },
]),
preset(
"megaphone",
@@ -220,20 +233,30 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
{
type: "highpass",
label: "Strip the Bass",
params: { frequency: 500, q: 0.707, poles: "2" },
params: { frequency: 700, q: 0.707, poles: "2" },
},
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 4000, q: 0.707, poles: "2" },
},
{ type: "peaking", label: "Horn Honk", params: { frequency: 1800, gain: 8, q: 1.5 } },
// A horn is a resonant tube and that resonance IS the sound: one narrow
// peak with a second ringing above it, where the radio has none at all.
{ type: "peaking", label: "Horn Honk", params: { frequency: 1900, gain: 14, q: 3 } },
{ type: "peaking", label: "Horn Ring", params: { frequency: 3200, gain: 6, q: 3 } },
// A driver pushed past its limit — flat-topped, not rounded. Measured on a
// log sweep, the threshold is the whole ballgame: at -14 the clipper
// flattened the response to a dead -19 dB line and ERASED the horn peaks
// above, leaving this indistinguishable from AM Radio. Backed off until
// the resonance survives the clipping that is supposed to sit on top of it.
{
type: "saturate",
label: "Overdrive",
params: { type: "hard", threshold: -12, output: -3 },
params: { type: "hard", threshold: -5, output: -3 },
},
{ type: "delay", label: "Horn Slap", params: { time: 40, feedback: 0.15, mix: 0.15 } },
// The outdoor reflection that comes back off whatever is being shouted at.
// Far enough to be a slap rather than a thickening.
{ type: "delay", label: "Horn Slap", params: { time: 65, feedback: 0.2, mix: 0.28 } },
],
),
preset(
@@ -259,22 +282,24 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
],
),
preset("pa-system", "character", "Tannoy", "Announced across a concourse.", [
{ type: "highpass", label: "Strip the Bass", params: { frequency: 350, q: 0.707, poles: "2" } },
{ type: "highpass", label: "Strip the Bass", params: { frequency: 250, q: 0.707, poles: "2" } },
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 3500, q: 0.707, poles: "2" },
params: { frequency: 5000, q: 0.707, poles: "2" },
},
{ type: "peaking", label: "Tannoy Honk", params: { frequency: 1500, gain: 5, q: 1.2 } },
// Higher and harder than the telephone's honk, which is what a big horn
// does — and it keeps the top the phone throws away.
{ type: "peaking", label: "Tannoy Honk", params: { frequency: 2400, gain: 9, q: 2 } },
{
type: "saturate",
label: "Driver Grit",
params: { type: "tanh", threshold: -16, output: -1 },
params: { type: "tanh", threshold: -10, output: -1 },
},
{
type: "reverb",
label: "Concourse",
params: { size: 0.5, damping: 0.7, wet: 0.25, dry: 0.8 },
params: { size: 0.54, damping: 0.48, wet: 0.25, dry: 0.8 },
},
]),
preset("intercom", "character", "Intercom", "Buzzed through a door panel, squelch and all.", [
@@ -292,6 +317,22 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
{ type: "peaking", label: "Panel Honk", params: { frequency: 2000, gain: 6, q: 2 } },
{ type: "bitcrush", label: "Crunch", params: { bits: 11, samples: 1, mix: 0.3 } },
]),
// The chorus with its wobble dialled up until it stops being width and starts
// being the effect: fast (10 Hz, the top of the range) and fully wet, so none
// of the straight signal is left to anchor the pitch.
preset(
"doofus-worble",
"character",
"Doofus Worble",
"Seasick and wobbling — no straight signal left.",
[
{
type: "chorus",
label: "Worble",
params: { delay: 14.6, depth: 2.57, speed: 10, mix: 1 },
},
],
),
// ---------------------------------------------------------------- space --
preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [
+43 -1
View File
@@ -87,14 +87,32 @@ describe("derived one-knob profiles", () => {
// so it has to be authoritative. Reopening a project has to put the knob
// back where it was.
for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) {
// Tight, because the failure this guards against is small and cumulative:
// a piecewise curve read back with a straight line put the reverb's Space
// knob at 0.46 when the author set 0.5, and every reopen moved it again.
// One knob step is 0.01, so anything beyond that is a value the author
// did not choose.
for (const s of [0, 0.25, 0.5, 0.75, 1]) {
const params = applyAudioFxProfile(id, s, defaultAudioFxParams(id));
expect(audioFxProfileStrength(id, params), `${id} at ${s}`).toBeCloseTo(s, 1);
expect(
Math.abs(audioFxProfileStrength(id, params) - s),
`${id} at ${s} read back as ${audioFxProfileStrength(id, params)}`,
).toBeLessThanOrEqual(0.01);
}
void profile;
}
});
it("reads a piecewise curve back at the value that produced it", () => {
// The reverb's `size` is piecewise — the design's anchors 0.25/0.55/0.90 are
// not evenly spaced — so a linear inverse is wrong by construction, and it
// was: 0.5 in, 0.46 out. This is the case the general round-trip above
// cannot isolate.
const params = applyAudioFxProfile("reverb", 0.5, defaultAudioFxParams("reverb"));
expect(params.size).toBe(0.55);
expect(audioFxProfileStrength("reverb", params)).toBe(0.5);
});
it("passes through the figures the design proposed", () => {
// The curves replaced a three-point table, and these are the points it
// named. Continuous beats three settings, but not at the price of landing
@@ -120,3 +138,27 @@ describe("derived one-knob profiles", () => {
expect(audioFxProfileStrength("not-an-effect", {})).toBe(0.5);
});
});
/**
* A profile's knob name and its two ends are read on every track that carries
* the effect, so they fall under the same rule as the rest of the copy: say what
* changes in the sound, not what it does to a voice. See the matching audit in
* `audioFxCopy.test.ts`.
*/
describe("no profile assumes the track is a voice", () => {
const SPEECH =
/\b(voice|vocal|speech|spoken|word|words|sentence|syllable|narration|talking|chest)\b/i;
it("labels the knob and both ends without assuming speech", () => {
const bad: string[] = [];
for (const [type, p] of Object.entries(HF_AUDIO_FX_PROFILES)) {
for (const [where, text] of [
["label", p.label],
["ends.low", p.ends.low],
["ends.high", p.ends.high],
] as const) {
if (SPEECH.test(text)) bad.push(`${type}.${where}: "${text}"`);
}
}
expect(bad).toEqual([]);
});
});
+25 -5
View File
@@ -81,7 +81,7 @@ export const HF_AUDIO_FX_PROFILES: Record<string, HfAudioFxProfile> = {
gate: {
label: "Tightness",
ends: { low: "Only true silence", high: "Cuts quiet words too" },
ends: { low: "Only true silence", high: "Cuts quiet parts too" },
derives: ["threshold", "range", "release"],
at(strength) {
const s = clamp01(strength);
@@ -202,8 +202,28 @@ export function audioFxProfileStrength(type: string, params: HfAudioFxParamValue
if (key === undefined) return 0.5;
const value = params[key];
if (typeof value !== "number") return 0.5;
const low = profile.at(0)[key];
const high = profile.at(1)[key];
if (typeof low !== "number" || typeof high !== "number" || low === high) return 0.5;
return to2(Math.min(1, Math.max(0, (value - low) / (high - low))));
// Searched, not inverted algebraically: a curve is free to be piecewise — the
// reverb's `size` is, because the design's three anchors are not evenly
// spaced — and a straight line between the endpoints reads such a curve back
// at the wrong place. Setting Space to 0.5 wrote size 0.55 and reopening the
// project drew the knob at 0.46, so every reopen nudged the sound.
//
// The grid IS the knob's own resolution (0.01), not something finer: a finer
// grid lands between two settable positions and rounds to a neighbour, which
// is how a search can be off by a step even where the curve is exact.
// Searching only reachable values makes the round trip exact.
const STEPS = 100;
let best = 0.5;
let bestErr = Infinity;
for (let i = 0; i <= STEPS; i += 1) {
const s = to2(i / STEPS);
const at = profile.at(s)[key];
if (typeof at !== "number") continue;
const err = Math.abs(at - value);
if (err < bestErr) {
bestErr = err;
best = s;
}
}
return bestErr === Infinity ? 0.5 : to2(best);
}
+12
View File
@@ -457,6 +457,18 @@ describe("audio_volume_double_automation", () => {
}
});
it("still warns when another value in the same call is a function result", async () => {
// Bounding the scan at the first `)` to fix the chained-timeline case
// silenced the rule for the ordinary shape of a tween whose object holds a
// call — the paren closing `fadeTime(2)` ended the match before `volume`.
// The lane and the tween still both drive volume, and the author still gets
// no warning about it.
const res = await lintHyperframeHtml(
withScript(LANE, `tl.to("#bgm", { duration: fadeTime(2), volume: 0.2 });`),
);
expect(res.findings.some((f) => f.code === "audio_volume_double_automation")).toBe(true);
});
it("does not blame the wrong element in a chained timeline", async () => {
// A chain has no semicolon until its very end, so a run that could cross `)`
// reached the `volume` in a LATER call and reported the element from an
+38 -9
View File
@@ -2,6 +2,35 @@ import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils";
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
/**
* Does the GSAP call that names `#id` also set `volume` in the same call?
*
* Depth-counted rather than regex-bounded: the selector opens somewhere inside a
* call, and the interesting region ends when THAT call closes a nested
* `fadeTime(2)` opens and closes on the way and must not end the scan. A regex
* cannot count parens, and both fixed bounds were wrong in opposite directions:
* unbounded blamed a later element, first-paren missed a whole ordinary shape.
*/
function tweensVolumeInSameCall(script: string, id: string): boolean {
const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
for (let hit = selector.exec(script); hit; hit = selector.exec(script)) {
let depth = 0;
// Cap the scan so a malformed script cannot walk the whole file.
const limit = Math.min(script.length, hit.index + 2000);
for (let i = hit.index; i < limit; i += 1) {
const ch = script[i];
if (ch === "(") depth += 1;
else if (ch === ")") {
// Past the end of the call the selector sits in.
if (depth === 0) break;
depth -= 1;
} else if (ch === ";" && depth === 0) break;
else if (ch === "v" && /^volume\s*:/.test(script.slice(i))) return true;
}
}
return false;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -624,15 +653,15 @@ function findVolumeDoubleAutomationFindings(ctx: LintContext): HyperframeLintFin
// the element's own selector, rather than by parsing the timeline. It reads
// the same call the runtime's own probe would pick up, and the rule only
// warns, so a miss costs nothing.
const escaped = escapeRegExp(id);
// `[^;)]`, not `[^;]`: a chained timeline has no semicolon until the end of
// the whole chain, so a run that could cross `)` matched `volume` in a LATER
// `.to()` call and named the wrong element — and this rule's fixHint tells
// the author to delete their lane. Refusing to cross the closing paren keeps
// the match inside the call the selector belongs to. It costs a false
// negative when some other value in the same object is a call result, which
// is the safe direction for a warning that already only guesses.
const tweened = new RegExp(`#${escaped}(?![\\w-])[^;)]{0,200}?\\bvolume\\s*:`).test(script);
// Scan to the end of the call the selector opened, rather than to the first
// `)`. A chained timeline has no semicolon until the end of the whole chain,
// so an unbounded run matched `volume` in a LATER `.to()` and named the
// wrong element — but stopping at the first `)` instead silenced the rule
// for any object holding a call, e.g.
// `gsap.to("#bgm", { duration: fadeTime(2), volume: 0.2 })`, which is the
// ordinary case rather than an exotic one. Counting depth keeps the match
// inside the selector's own call AND lets it cross a nested one.
const tweened = tweensVolumeInSameCall(script, id);
if (!tweened) continue;
findings.push({
code: "audio_volume_double_automation",
@@ -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={() => {
+2 -2
View File
@@ -26,8 +26,8 @@
"files": 121
},
"hyperframes-audio": {
"hash": "533dcc01de09db53",
"files": 4
"hash": "8eccbc04ced1dab1",
"files": 5
},
"hyperframes-cli": {
"hash": "e042fcaaa3f9767f",
+29
View File
@@ -33,6 +33,9 @@ Three attributes carry everything, all on the audio/video element itself:
Exact JSON for each, and the rules a lane must satisfy: `references/attributes.md`.
Every effect with its parameters, ranges and units: `references/fx-registry.md`.
**Presets, named jobs and one-knob profiles, plus a symptom-to-fix table:
`references/presets.md`** — read that before hand-building a chain, because one
of the presets or named jobs usually already names the problem.
## How it fits together
@@ -109,6 +112,32 @@ flowchart LR
A static carve is the same graph with fixed values and no lanes at all.
## Start from the symptom
Before choosing an effect, name what is wrong with the audio. Most bad audio is
one or two of these, and each has a shipped answer:
| It sounds like | Reach for |
| ---------------------------------- | -------------------------------------------------- |
| Hum or thump underneath | `rumble-cut`, or a `highpass` at 80 Hz |
| Boomy, chesty | **Tame Boominess** job (200 Hz) |
| Muffled, behind cardboard | **Reduce Mud** job (250 Hz) |
| Words hard to make out | **Add Clarity** job (3 kHz), or carve the bed |
| Harsh and tiring | **Soften Harshness** job (3.2 kHz) |
| Some words much louder than others | **Evenness** on a compressor, or Even Out Levels |
| Room tone between sentences | `room-gate` |
| Voice and music fighting | **Voiceover carve** — not an EQ on either |
| Dry, recorded nowhere | `room-tight` or `room-natural` |
| Just "amateur" | `voice-clean`, which is four of the above in order |
Full catalogue, what each preset contains, the band vocabulary, and what is
deliberately NOT covered (de-essing, noise removal, tone match):
`references/presets.md`.
Subtract before you add, level after you filter, relationships after level,
character and ceiling last. Each step changes what the next one hears — a
compressor set before a high-pass spends its time chasing rumble.
## Reach for a family by the problem, not the name
**Filters** (`highpass`, `lowpass`, `peaking`, `lowshelf`, `highshelf`) decide
@@ -12,7 +12,12 @@ that sounds plausible and is wrong.
{
"version": 1,
"nodes": [
{ "type": "highpass", "id": "n1", "params": { "frequency": 120, "q": 0.707, "poles": "2" } },
{
"type": "highpass",
"id": "n1",
"label": "Remove Rumble",
"params": { "frequency": 120, "q": 0.707, "poles": "2" }
},
{
"type": "peaking",
"id": "n2",
@@ -29,6 +34,13 @@ that sounds plausible and is wrong.
}
```
**Write these attributes double-quoted, with the JSON's own quotes as `&quot;`.**
The browser reads them through `getAttribute` and does not care, but
`scripts/carve.mjs` finds them with a `name="..."` regex, so a single-quoted
attribute is invisible to it — the carve reports no existing chain and quietly
overwrites work it could not see. `&` becomes `&amp;`; nothing else needs
escaping.
- **Order is signal order.** Each node processes what the one before produced.
- `type` is an effect id from the registry. `params` are in the units a person
thinks in — dB, ms, Hz — and out-of-range values are clamped on read, so a
@@ -38,6 +50,11 @@ that sounds plausible and is wrong.
with no id loads fine but cannot be automated. Writing a chain by hand, any
unique string works; Studio hands out the first free `n1`, `n2`, … so matching
that convention keeps a hand-written chain and an edited one looking alike.
- `label` is what the rack calls this node, replacing the effect's own name.
Write one whenever the node is doing a named job — a chain with two `peaking`
nodes otherwise shows the same row twice and the author cannot tell which is
the mud cut and which is the clarity lift. Presets and jobs always set it; a
hand-written node should too. See `presets.md` for the names they use.
- `enabled: false` is bypass — the node stays in the chain, out of the signal
path. Absent means enabled.
- `fromCarve: true` marks a node the carve analysis generated. Re-running the
@@ -0,0 +1,216 @@
# Presets, jobs and one-knob profiles
Everything here is a shortcut to a chain you could have built by hand. A preset
writes ordinary nodes tagged with `fromPreset`, a job writes one ordinary node
with a name, and a profile is one control over several parameters of one effect.
Nothing is opaque: open any of them and you find effects from
[`fx-registry.md`](./fx-registry.md) with their parameters showing.
Reach for one when it names the problem you actually have. Build by hand when
none of them does — a preset applied because it was nearby is worse than three
deliberate nodes.
---
## Diagnose first: what to listen for, and what fixes it
Work from the symptom, not from the effect list. Most bad audio is one or two of
these, and the fix is usually a job rather than a whole preset.
| It sounds like | Where it lives | Reach for |
| --------------------------------------------- | -------------- | ------------------------------------------------------------ |
| Hum, rumble, traffic, footsteps, handling | 2080 Hz | `rumble-cut` preset, or a `highpass` at 80 Hz |
| Boomy, chesty, too close to the mic | 80250 Hz | **Tame Boominess** job (200 Hz, 4 dB) |
| Muffled, like it is behind cardboard | 250600 Hz | **Reduce Mud** job (250 Hz, 3 dB) |
| Boxy, like a small room | ~400 Hz | **Reduce Boxiness** job (400 Hz, 3 dB) |
| Words hard to make out, sits behind the music | 25 kHz | **Add Clarity** job (3 kHz, +2.5 dB), or carve the bed |
| Harsh, brittle, tiring over a whole listen | 35 kHz | **Soften Harshness** job (3.2 kHz, 3 dB) |
| Sibilant — `s` sounds spitting | 510 kHz | Nothing shipped does this properly; see "Not covered" below |
| Dull, closed-in, lifeless | 1020 kHz | `highshelf` lift, or `voice-broadcast` which includes one |
| Some words much louder than others | not a band | **Evenness** profile on a `compressor`, or `levellingResult` |
| Room tone audible between sentences | not a band | `room-gate` preset (**Tightness** profile) |
| Peaks clipping or spiking | not a band | `limiter` last in the chain — every voice preset ends in one |
| Voice and music fighting each other | 13 kHz mostly | **Voiceover carve**, not an EQ on either track |
| Dry, stuck to the speaker, recorded nowhere | not a band | `room-tight` or `room-natural` |
**The band vocabulary** these map onto — the same names the rack shows:
| Range | Name | What lives there |
| -------------- | -------- | ---------------------------- |
| 2080 Hz | Rumble | traffic, footsteps, handling |
| 80250 Hz | Weight | chest, body, warmth |
| 250600 Hz | Mud | boxy, muffled, cardboard |
| 6002000 Hz | Middle | the body of a voice |
| 20005000 Hz | Presence | consonants, intelligibility |
| 500010000 Hz | Edge | sibilance, harshness |
| 1000020000 Hz | Air | sparkle, openness |
### Order of operations
Diagnose in this order, because each step changes what the next one hears:
1. **Subtract before you add.** Cut rumble and mud first. A voice that sounds
dull often has too much low-mid, not too little top — lifting the top of a
muddy voice makes it muddy _and_ harsh.
2. **Level after you filter.** A compressor reacts to whatever is loudest, and
a rumble it can no longer see is a rumble it stops chasing.
3. **Relationships after level.** Carve a bed against a voice once the voice
itself is settled, or the analysis measures a problem you are about to fix.
4. **Character, then ceiling.** Saturation and space go late; a `limiter` goes
last, where it can actually act as a ceiling. Anything after it is not
bounded by it.
---
## Presets
Four families, listed in full below. Apply one and it **appends** — stacking a character preset
onto an already-cleaned voice is a real thing to want. Re-applying one that is
already present replaces its own nodes in place, because position in the chain
is signal order.
### Voice — make a real voice sound like its better self
| Preset | Answers | Chain |
| ----------------- | ------------------------------- | --------------------------------------------------------------------------------------------------- |
| `voice-clean` | "My voice sounds amateur" | Remove Rumble → Reduce Mud → Even Out Loudness → Add Clarity → Peak Ceiling |
| `voice-broadcast` | "I want it to sound like radio" | Remove Rumble → Reduce Boxiness → Even Out Loudness → Add Clarity → Add Air → Warmth → Peak Ceiling |
| `voice-warm` | "I want it intimate and close" | Remove Rumble → Add Weight → Even Out Loudness → Add Clarity → Peak Ceiling |
`voice-clean` is the default answer to "fix this voiceover". The other two are
the same idea pushed in one direction: broadcast is denser and more forward,
warm has body added rather than cut.
### Repair — one problem, one node
| Preset | Answers | Does |
| ------------ | --------------------------------------- | --------------------------------------------------------------------------- |
| `rumble-cut` | "There's a hum or thump underneath" | High-pass under the voice |
| `room-gate` | "I can hear the room between sentences" | Closes the pauses. **Does not remove noise** — room tone under speech stays |
| `boom-tame` | "My voice sounds boomy" | Cuts the chestiness of a too-close mic |
| `harsh-tame` | "It's harsh and tiring to listen to" | Rounds a brittle upper-mid, broad and always-on |
### Character — deliberate, not corrective
`telephone`, `radio-am`, `megaphone`, `lofi-tape`, `pa-system` (Tannoy),
`intercom`, `doofus-worble`.
These are costumes. Each is a band restriction plus a resonance plus its own kind
of dirt, and they are tuned to be distinguishable from one another — measured on
a log sweep, no two sit closer than the signal itself. Do not stack two.
### Space — put it somewhere
`room-tight` (presence without wash), `room-natural` (recorded somewhere rather
than nowhere), `hall` (far back and big), `slap-echo` (one quick repeat),
`dub-throw` (repeats trailing well behind).
Use these on whatever should sit _behind_ something else, and keep the wet amount
lower than sounds right in isolation — a tail occupies the room a voice needs.
### The whole preset as one control
A preset's nodes are wrapped in a wet/dry blend, so `presetAmount` (0..1) fades
the entire thing in or out, and `fx.preset.<id>` is an automation target that
ramps it over time. This is the only way to automate a preset as a unit: its
nodes share no common parameter, and worklet effects (compressor, limiter, gate,
bitcrush) expose no automatable parameters at all.
---
## Jobs — the range IS the module
Five named peaking filters with the frequency already chosen. Picking the job is
picking the range, which is what makes a single "how much" knob honest.
| Job | Symptom | Sets |
| ---------------- | ------------------------------------ | --------------------- |
| Tame Boominess | Too much chest — it booms | 200 Hz, 4 dB, Q 1.4 |
| Reduce Mud | Muffled, like it is behind cardboard | 250 Hz, 3 dB, Q 1.2 |
| Reduce Boxiness | Sounds like a small room, or a box | 400 Hz, 3 dB, Q 1.4 |
| Add Clarity | Words are hard to make out | 3 kHz, +2.5 dB, Q 1 |
| Soften Harshness | Harsh and tiring to listen to | 3.2 kHz, 3 dB, Q 1.6 |
Each is an ordinary `peaking` node underneath — the frequency is a starting
point, not a cage. Prefer a job to a bare `peaking` when one matches: it arrives
already aimed, and the rack names it for the work rather than the mechanism.
Writing one by hand, **carry the name in `label`** — `{"type":"peaking","id":"n2",
"label":"Reduce Mud","params":{"frequency":250,"gain":-3,"q":1.2}}`. The
parameters alone are not the job. A chain with three unlabelled `peaking` nodes
shows the author three identical rows, which is the exact problem jobs exist to
dissolve.
**Every job also ships inside a preset, at identical settings** — that is where
the five came from. `boom-tame` _is_ Tame Boominess; `harsh-tame` _is_ Soften
Harshness; `voice-clean` contains Reduce Mud and Add Clarity; `voice-broadcast`
contains Reduce Boxiness. So check what a preset already contains before adding
a job on top of it, or the cut lands twice — `voice-clean` plus a Reduce Mud job
is 6 dB at 250 Hz where 3 was meant. The rack shows the contained nodes by
name once the preset is expanded, which is the fastest way to see it.
---
## One-knob profiles
Five effects have no single parameter that can honestly be their face — a
compressor's threshold means nothing without its ratio. They get a derived
control instead, 0..1, which sets several parameters together.
| Effect | Knob | 0 → 1 | Sets |
| ------------ | --------- | ------------------------------------------ | ----------------------------------------- |
| `compressor` | Evenness | Barely touched → Very even, quite squashed | threshold, ratio, attack, release, makeup |
| `gate` | Tightness | Only true silence → Cuts quiet words too | threshold, range, release |
| `saturate` | Warmth | Just a sheen → Openly distorted | threshold, output |
| `reverb` | Space | A small tight room → A big open hall | size, wet, dry |
| `bitcrush` | Crush | Slightly gritty → Destroyed | bits, samples, mix |
**Evenness, Warmth and Space are level-matched** — the make-up gain, the output
trim and the dry leg move with the drive, so turning the knob up does not also
turn the track up or down. Those figures were solved by measurement, not chosen:
the compressor originally left a track 2.5 dB _quieter_ at full evenness, and
saturation's trim ran the wrong way entirely.
Tightness and Crush are not level-matched, because neither has a trim to move —
a gate only removes, and Crush's `mix` is the effect itself rather than a
make-up.
The chain stores the mechanism values, not the knob position; the knob is read
back by inverting the curve. So hand-editing a parameter under a profile is
allowed and will simply move the knob.
---
## Measuring scripts, not presets
Two things measure the audio before they act, so they cannot be a fixed chain:
- **Voiceover carve** — analyses the voice and cuts the bed in the bands the
voice occupies. The answer to "the music is fighting the voice". See the
carve section in `SKILL.md`.
- **Even Out Levels** (`levellingResult`) — measures the track's own speaking
windows and writes a gain envelope. Its target is the 80th percentile of that
track, not an absolute level, so an already-even track is left alone. Use it
over a compressor when the problem is passages drifting over a whole take
rather than word-to-word dynamics.
---
## Not covered by anything shipped
Name the gap rather than reaching for the nearest preset and calling it the
thing — but then **ship the honest fallback anyway**, with its cost stated. An
author who asked for a fix and got only an explanation has been told something
true and handed nothing. Say what it is, say what it costs, apply it.
- **De-essing.** `harsh-tame` is a broad always-on cut centred a band too low,
not a de-esser. A real one needs a detector faster than the analysis hop
available here. _Fallback:_ a narrow `peaking` cut in the Edge band — sweep
59 kHz to find where this voice actually spits, Q 34, 3 to 5 dB. It is
always on, so it costs a little air on every word; that trade is usually worth
it and is the author's to reject.
- **Tone matching** one track to another. _Fallback:_ the Tone EQ by hand, which
is predictable in a way a match curve derived from two takes would not be.
- **Noise removal.** `room-gate` closes the gaps; the noise under speech is
untouched. There is no fallback for hiss beneath the words — a source with
audible hiss needs a better source, and saying so is the whole answer.