Skip to content

Commit 1e30acc

Browse files
committed
refactor(studio): lift the carve out of the FX section file
`propertyPanelFxSection.tsx` was 992 lines against the studio's 600-line cap. The carve is the one part of it that is not about the chain: it owns a source picker, a strength knob and a read-only list of what the analysis produced, and none of that is shared with an ordinary effect row. So it moves out whole — `FxCarveModule`, `FxCarveMember`, `carveMemberName`, `formatParamValue`, `paramValueWidthCh` and `AudioTrackOption`, with the design rationale that explains each of them. Pure move: no behaviour change, no rendered-audio change. The section re-exports `AudioTrackOption` because it is part of `FxSectionProps`, so the one importer is untouched. Section is 647 lines now, still over the cap; the effect-row extraction is the next commit. Studio suite unchanged at 3674 passing, 18 todo, 1 file skipped.
1 parent 9bfbace commit 1e30acc

2 files changed

Lines changed: 369 additions & 351 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
/**
2+
* The voiceover carve, as one module in the FX rack.
3+
*
4+
* Carve is deliberately not an entry in the chain. It is a relationship between
5+
* two tracks — it analyses a voice and dips *this* bed where that voice sits —
6+
* so it gets its own card with a source picker, the way a sidechain control
7+
* lives on the track being processed. What it produces is an ordinary chain of
8+
* peaking filters, so it composes with whatever else is on the track.
9+
*/
10+
11+
import {
12+
defaultAudioFxParams,
13+
getAudioFxDef,
14+
type HfAudioFxNode,
15+
type HfAudioFxParam,
16+
} from "@hyperframes/core/audio-fx";
17+
import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
18+
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
19+
import { FxParamRow } from "./propertyPanelFxControls.js";
20+
// Shared with the timeline's lane labels: a band is named by its frequency in
21+
// both places, and two formatters would drift.
22+
import { formatHz } from "../../player/components/automationLaneData";
23+
24+
export interface AudioTrackOption {
25+
id: string;
26+
label: string;
27+
}
28+
29+
/** What one effect inside the module is called: its own name, plus the band. */
30+
function carveMemberName(node: HfAudioFxNode): string {
31+
const def = getAudioFxDef(node.type);
32+
const freq = node.params?.["frequency"];
33+
const label = def?.label ?? node.type;
34+
return typeof freq === "number" ? `${label} ${formatHz(freq)}` : label;
35+
}
36+
37+
/** A parameter's value as the rack shows it: rounded to the step, with its unit. */
38+
function formatParamValue(param: HfAudioFxParam, raw: number | string | undefined): string {
39+
if (param.kind !== "number" || typeof raw !== "number") return String(raw ?? "");
40+
const places = param.step >= 1 ? 0 : param.step >= 0.1 ? 1 : 2;
41+
return `${Number(raw.toFixed(places))}${param.unit ? ` ${param.unit}` : ""}`;
42+
}
43+
44+
/**
45+
* Width to reserve for a parameter's value, in characters.
46+
*
47+
* Derived from what the parameter CAN read rather than what it currently reads, so
48+
* the column never moves: an automated value updates 30 times a second, and
49+
* `-1 dB` is two characters narrower than `-3.2 dB`, which was enough to shunt
50+
* everything after it sideways on every frame. `ch` is exact here because the
51+
* readouts are monospace and already `tabular-nums`.
52+
*/
53+
function paramValueWidthCh(param: HfAudioFxParam): number {
54+
if (param.kind === "enum") {
55+
return Math.max(1, ...param.options.map((option) => option.value.length));
56+
}
57+
const places = param.step >= 1 ? 0 : param.step >= 0.1 ? 1 : 2;
58+
const digits = Math.max(
59+
String(Math.floor(Math.abs(param.min))).length,
60+
String(Math.floor(Math.abs(param.max))).length,
61+
);
62+
const sign = param.min < 0 ? 1 : 0;
63+
const decimals = places > 0 ? places + 1 : 0;
64+
const unit = param.unit ? param.unit.length + 1 : 0;
65+
return sign + digits + decimals + unit;
66+
}
67+
68+
/** One member of the module: what it is, and what every knob is set to. */
69+
function FxCarveMember({
70+
node,
71+
automatedTargets,
72+
liveAutomationValues,
73+
}: {
74+
node: HfAudioFxNode;
75+
automatedTargets?: ReadonlySet<string>;
76+
liveAutomationValues?: ReadonlyMap<string, number>;
77+
}) {
78+
const def = getAudioFxDef(node.type);
79+
if (!def) return null;
80+
const params = node.params ?? defaultAudioFxParams(node.type);
81+
return (
82+
<div className="hf-fx-carve-member flex flex-col gap-0.5 py-1 pl-3 pr-1.5">
83+
<span className="hf-fx-carve-member-name truncate font-mono text-[9px] text-panel-text-1">
84+
{carveMemberName(node)}
85+
</span>
86+
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
87+
{def.params.map((param) => {
88+
const target = node.id ? fxAutomationTarget(node.id, param.key) : null;
89+
const automated = Boolean(target && automatedTargets?.has(target));
90+
// The envelope's value at the playhead when there is one, which is what
91+
// the audio is using; the stored number is only the seed behind it.
92+
const live = target ? liveAutomationValues?.get(target) : undefined;
93+
const driven = automated && live !== undefined;
94+
const value = formatParamValue(param, driven ? live : params[param.key]);
95+
return (
96+
<span
97+
key={param.key}
98+
className="flex items-baseline gap-1 font-mono text-[9px] text-panel-text-4"
99+
{...(automated ? { "data-automated": "" } : {})}
100+
{...(driven ? { "data-automation-live": "" } : {})}
101+
>
102+
<span className="text-panel-text-4">{param.label}</span>
103+
<span
104+
className="tabular-nums text-panel-text-1"
105+
style={{ minWidth: `${paramValueWidthCh(param)}ch` }}
106+
>
107+
{value}
108+
</span>
109+
{/* The lane is where an automated value comes from, and where it is
110+
edited — saying so is the difference between a stale readout and
111+
a pointer to the thing that owns it. */}
112+
{automated ? <span className="text-[#3CE6AC]">A</span> : null}
113+
</span>
114+
);
115+
})}
116+
</div>
117+
</div>
118+
);
119+
}
120+
121+
/**
122+
* The carve, as one module in the rack.
123+
*
124+
* A carve is one thing the author switched on; the peaking filters and the level
125+
* stage are how it is built. Listed individually they read as hand-built effects —
126+
* removable one at a time, reorderable, each with knobs the next strength change
127+
* silently overwrites. So the rack shows the unit, and the unit owns everything
128+
* that means anything for it: which voice it listens to, how hard it works,
129+
* whether it follows that voice, and what the analysis made of it.
130+
*
131+
* The controls used to sit in their own block under the rack, which read as a
132+
* second, unrelated feature that happened to produce effects somewhere else. One
133+
* card, controls above the analysis they drive, is the same thing said once.
134+
*
135+
* Grouped is not hidden. Opening it lists every effect inside with all of its
136+
* settings, because an author has to be able to see where the analysis landed — as
137+
* readouts rather than controls, since strength is what sets them and a knob here
138+
* would be overwritten by the next adjustment.
139+
*/
140+
export function FxCarveModule({
141+
nodes,
142+
carve,
143+
sourceOptions,
144+
automatedTargets,
145+
liveAutomationValues,
146+
open,
147+
disabled,
148+
analysing,
149+
onToggleOpen,
150+
onCarveChange,
151+
onCarvePreview,
152+
}: {
153+
nodes: HfAudioFxNode[];
154+
carve: HfCarveSettings;
155+
sourceOptions: AudioTrackOption[];
156+
automatedTargets?: ReadonlySet<string>;
157+
liveAutomationValues?: ReadonlyMap<string, number>;
158+
open: boolean;
159+
disabled?: boolean;
160+
analysing?: boolean;
161+
onToggleOpen(): void;
162+
onCarveChange(carve: HfCarveSettings): void;
163+
onCarvePreview(carve: HfCarveSettings): void;
164+
}) {
165+
const bands = nodes.filter((n) => n.type === "peaking").length;
166+
const hasLevel = nodes.some((n) => n.type === "gain");
167+
const on = carve.enabled;
168+
/**
169+
* The only track this bed could be listening to, when there is exactly one.
170+
*
171+
* A picker with one entry is a question with one answer: it asks the author to
172+
* confirm something already decided. So the voice reads out instead.
173+
*
174+
* Not when the stored source is some OTHER track, though — a name that no longer
175+
* classifies as a voice, or a track since renamed. Reading out the one remaining
176+
* candidate there would quietly claim the carve listens to something it does not,
177+
* so the picker comes back and shows the mismatch.
178+
*/
179+
const soleVoice =
180+
sourceOptions.length === 1 &&
181+
(carve.sources.length === 0 ||
182+
(carve.sources.length === 1 && carve.sources[0] === sourceOptions[0]?.id))
183+
? sourceOptions[0]
184+
: null;
185+
// What the module is worth right now, in the head, so a collapsed card still
186+
// says whether it is doing anything: the analysis it produced, or why not.
187+
const summary = !on
188+
? "off"
189+
: analysing
190+
? "analysing…"
191+
: bands > 0
192+
? [
193+
`${bands} band${bands === 1 ? "" : "s"}`,
194+
...(hasLevel ? ["level"] : []),
195+
// Worth saying when it is more than one: the cuts follow whoever is
196+
// speaking, and that is not obvious from a band count.
197+
...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
198+
].join(" + ")
199+
: carve.sources.length > 0
200+
? "no analysis yet"
201+
: "pick a voice";
202+
return (
203+
<div
204+
className={`hf-fx-node hf-fx-carve-module hf-fx-carve rounded-[4px] border border-panel-border-input${
205+
on ? "" : " opacity-50"
206+
}`}
207+
data-fx-node="carve"
208+
data-carve-enabled={on ? "" : undefined}
209+
>
210+
<div className="hf-fx-node-head flex min-h-7 items-center gap-1 px-1.5">
211+
<button
212+
type="button"
213+
className="hf-fx-node-name min-w-0 flex-1 truncate text-left text-[11px] font-semibold text-panel-text-1 hover:text-panel-text-0"
214+
aria-expanded={open}
215+
onClick={onToggleOpen}
216+
>
217+
Voiceover carve
218+
</button>
219+
<span className="hf-fx-carve-summary shrink-0 font-mono text-[9px] text-panel-text-4">
220+
{summary}
221+
</span>
222+
{/* One switch, not a bypass and a delete. Off drops the effects and the
223+
envelopes it wrote, and is remembered — otherwise the default would
224+
re-apply the carve the next time this clip was selected. */}
225+
<button
226+
type="button"
227+
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"
228+
aria-pressed={on}
229+
title={on ? "Switch the carve off" : "Switch the carve on"}
230+
disabled={disabled}
231+
onClick={() => onCarveChange({ ...carve, enabled: !on })}
232+
>
233+
{on ? "On" : "Off"}
234+
</button>
235+
</div>
236+
{open && on ? (
237+
<div className="hf-fx-carve-body border-t border-panel-border-input">
238+
<div className="hf-fx-carve-controls space-y-0.5 px-1.5 py-1.5">
239+
<div className="hf-fx-row flex min-h-6 items-center gap-2">
240+
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-4">
241+
Listen to
242+
</span>
243+
{soleVoice ? (
244+
<span
245+
className="hf-fx-carve-source min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-1"
246+
data-carve-source={soleVoice.id}
247+
>
248+
{soleVoice.label}
249+
</span>
250+
) : (
251+
/* Every voice, not one of them. A bed usually runs under a whole
252+
sequence — a narrator, an answer, a second presenter — and they are
253+
analysed together, so the cuts follow whoever is speaking. Which
254+
makes this a set of things to include, not a choice between them. */
255+
<div className="hf-fx-carve-sources flex min-w-0 flex-1 flex-wrap gap-x-2.5 gap-y-0.5">
256+
{sourceOptions.map((o) => (
257+
<label
258+
key={o.id}
259+
className="flex min-w-0 items-center gap-1 font-mono text-[9px] text-panel-text-1"
260+
title={`Make room for ${o.label}`}
261+
>
262+
<input
263+
type="checkbox"
264+
className="hf-fx-carve-source h-2.5 w-2.5 accent-panel-accent"
265+
data-carve-source={o.id}
266+
checked={carve.sources.includes(o.id)}
267+
disabled={disabled}
268+
onChange={(e) =>
269+
onCarveChange({
270+
...carve,
271+
sources: e.target.checked
272+
? [...carve.sources, o.id]
273+
: carve.sources.filter((id) => id !== o.id),
274+
})
275+
}
276+
/>
277+
<span className="truncate">{o.label}</span>
278+
</label>
279+
))}
280+
</div>
281+
)}
282+
</div>
283+
{/* One knob for the whole effect. Depth, band count, width, the
284+
intelligibility weighting and both level-match numbers move together
285+
anyway — a gentle carve is shallow in few bands with little ducking, a
286+
hard one is deeper in more with more — so the panel sets the strength
287+
and `carveProfile` derives the six numbers the analysis works in. */}
288+
<FxParamRow
289+
param={{
290+
kind: "number",
291+
key: "strength",
292+
label: "Strength",
293+
unit: "",
294+
min: 0,
295+
max: 1,
296+
step: 0.05,
297+
default: DEFAULT_CARVE.strength,
298+
hint: "How hard to carve: deeper cuts, in more bands, and more room made by dropping the bed's level under the voice. At 0 it carves frequencies only. Moving this re-runs the analysis on what is already here.",
299+
}}
300+
value={carve.strength}
301+
disabled={disabled || carve.sources.length === 0}
302+
onChange={(_k, v) => onCarvePreview({ ...carve, strength: Number(v) })}
303+
onCommit={(_k, v) => onCarveChange({ ...carve, strength: Number(v) })}
304+
/>
305+
</div>
306+
{/* What the analysis made of all that. Divided rather than boxed: these
307+
are parts of one module, and a border around each would read as the
308+
separate effects this replaced. */}
309+
{/* While the analysis runs, the previous filters are gone rather than
310+
stale. Every number in that list is about to be replaced — a strength
311+
change re-derives all of them — so leaving them up reads as the
312+
settings that are in force when they are already history, and the one
313+
honest thing to say is that the work is happening. */}
314+
{analysing ? (
315+
<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">
316+
<svg
317+
className="hf-fx-carve-spinner h-3 w-3 animate-spin motion-reduce:animate-none"
318+
viewBox="0 0 24 24"
319+
fill="none"
320+
aria-hidden="true"
321+
>
322+
<circle
323+
className="opacity-25"
324+
cx="12"
325+
cy="12"
326+
r="10"
327+
stroke="currentColor"
328+
strokeWidth="4"
329+
/>
330+
<path
331+
className="opacity-75"
332+
fill="currentColor"
333+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
334+
/>
335+
</svg>
336+
Analysing…
337+
</p>
338+
) : nodes.length > 0 ? (
339+
<div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
340+
<div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
341+
analysed
342+
</div>
343+
{nodes.map((node, i) => (
344+
<FxCarveMember
345+
key={node.id ?? `${node.type}-${i}`}
346+
node={node}
347+
automatedTargets={automatedTargets}
348+
liveAutomationValues={liveAutomationValues}
349+
/>
350+
))}
351+
</div>
352+
) : (
353+
<p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-4">
354+
{carve.sources.length > 0
355+
? "Nothing analysed yet."
356+
: "Pick the voices this bed should make room for."}
357+
</p>
358+
)}
359+
</div>
360+
) : null}
361+
</div>
362+
);
363+
}

0 commit comments

Comments
 (0)