diff --git a/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx b/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx
index e86a976ac..9f6d896d6 100644
--- a/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx
@@ -164,43 +164,9 @@ export function FxCarveModule({
onCarveChange(carve: HfCarveSettings): void;
onCarvePreview(carve: HfCarveSettings): void;
}) {
- const bands = nodes.filter((n) => n.type === "peaking").length;
- const hasLevel = nodes.some((n) => n.type === "gain");
const on = carve.enabled;
- /**
- * The only track this bed could be listening to, when there is exactly one.
- *
- * A picker with one entry is a question with one answer: it asks the author to
- * confirm something already decided. So the voice reads out instead.
- *
- * Not when the stored source is some OTHER track, though — a name that no longer
- * classifies as a voice, or a track since renamed. Reading out the one remaining
- * candidate there would quietly claim the carve listens to something it does not,
- * so the picker comes back and shows the mismatch.
- */
- const soleVoice =
- sourceOptions.length === 1 &&
- (carve.sources.length === 0 ||
- (carve.sources.length === 1 && carve.sources[0] === sourceOptions[0]?.id))
- ? sourceOptions[0]
- : null;
- // What the module is worth right now, in the head, so a collapsed card still
- // says whether it is doing anything: the analysis it produced, or why not.
- const summary = !on
- ? "off"
- : analysing
- ? "analysing…"
- : bands > 0
- ? [
- `${bands} band${bands === 1 ? "" : "s"}`,
- ...(hasLevel ? ["level"] : []),
- // Worth saying when it is more than one: the cuts follow whoever is
- // speaking, and that is not obvious from a band count.
- ...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
- ].join(" + ")
- : carve.sources.length > 0
- ? "no analysis yet"
- : "pick a voice";
+ const soleVoice = soleCarveVoice(sourceOptions, carve.sources);
+ const summary = carveSummary({ nodes, carve, analysing });
// 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
@@ -257,53 +223,13 @@ export function FxCarveModule({
{open && on ? (
-
-
- Listen to
-
- {soleVoice ? (
-
- {soleVoice.label}
-
- ) : (
- /* Every voice, not one of them. A bed usually runs under a whole
- sequence — a narrator, an answer, a second presenter — and they are
- analysed together, so the cuts follow whoever is speaking. Which
- makes this a set of things to include, not a choice between them. */
-
- {sourceOptions.map((o) => (
-
- ))}
-
- )}
-
+
{/* One knob for the whole effect. Depth, band count, width, the
intelligibility weighting and both level-match numbers move together
anyway — a gentle carve is shallow in few bands with little ducking, a
@@ -335,53 +261,201 @@ export function FxCarveModule({
change re-derives all of them — so leaving them up reads as the
settings that are in force when they are already history, and the one
honest thing to say is that the work is happening. */}
- {analysing ? (
-
-
- Analysing…
-
- ) : nodes.length > 0 ? (
-
-
- analysed
-
- {nodes.map((node, i) => (
-
- ))}
-
- ) : (
-
- {carve.sources.length > 0
- ? "Nothing analysed yet."
- : "Pick the voices this bed should make room for."}
-
);
}
+
+/**
+ * The only track this bed could be listening to, when there is exactly one.
+ *
+ * A picker with one entry is a question with one answer: it asks the author to
+ * confirm something already decided. So the voice reads out instead.
+ *
+ * Not when the stored source is some OTHER track, though — a name that no longer
+ * classifies as a voice, or a track since renamed. Reading out the one remaining
+ * candidate there would quietly claim the carve listens to something it does not,
+ * so the picker comes back and shows the mismatch.
+ */
+function soleCarveVoice(
+ sourceOptions: AudioTrackOption[],
+ sources: readonly string[],
+): AudioTrackOption | null {
+ if (sourceOptions.length !== 1) return null;
+ const only = sourceOptions[0];
+ if (!only) return null;
+ if (sources.length === 0) return only;
+ return sources.length === 1 && sources[0] === only.id ? only : null;
+}
+
+/**
+ * What the module is worth right now, for its head, so a collapsed card still
+ * says whether it is doing anything: the analysis it produced, or why not.
+ */
+function carveSummary(input: {
+ nodes: HfAudioFxNode[];
+ carve: HfCarveSettings;
+ analysing?: boolean;
+}): string {
+ const { nodes, carve, analysing } = input;
+ if (!carve.enabled) return "off";
+ if (analysing) return "analysing…";
+ const bands = nodes.filter((n) => n.type === "peaking").length;
+ if (bands === 0) return carve.sources.length > 0 ? "no analysis yet" : "pick a voice";
+ return [
+ `${bands} band${bands === 1 ? "" : "s"}`,
+ ...(nodes.some((n) => n.type === "gain") ? ["level"] : []),
+ // Worth saying when it is more than one: the cuts follow whoever is
+ // speaking, and that is not obvious from a band count.
+ ...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
+ ].join(" + ");
+}
+
+/** Which voices the bed makes room for: a readout when there is only one to
+ * choose, otherwise a set of checkboxes. */
+function CarveSourceRow({
+ carve,
+ sourceOptions,
+ soleVoice,
+ disabled,
+ onCarveChange,
+}: {
+ carve: HfCarveSettings;
+ sourceOptions: AudioTrackOption[];
+ soleVoice: AudioTrackOption | null;
+ disabled?: boolean;
+ onCarveChange(carve: HfCarveSettings): void;
+}) {
+ return (
+
+
+ Listen to
+
+ {soleVoice ? (
+
+ {soleVoice.label}
+
+ ) : (
+ /* Every voice, not one of them. A bed usually runs under a whole
+ sequence — a narrator, an answer, a second presenter — and they are
+ analysed together, so the cuts follow whoever is speaking. Which
+ makes this a set of things to include, not a choice between them. */
+
+ {sourceOptions.map((o) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * What the analysis made of all that. Divided rather than boxed: these are parts
+ * of one module, and a border around each would read as the separate effects
+ * this replaced.
+ *
+ * While the analysis runs the previous filters are gone rather than stale. Every
+ * number in that list is about to be replaced — a strength change re-derives all
+ * of them — so leaving them up reads as the settings that are in force when they
+ * are already history, and the one honest thing to say is that the work is
+ * happening.
+ */
+function CarveAnalysis({
+ nodes,
+ analysing,
+ hasSources,
+ automatedTargets,
+ liveAutomationValues,
+}: {
+ nodes: HfAudioFxNode[];
+ analysing?: boolean;
+ hasSources: boolean;
+ automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
+}) {
+ if (analysing) {
+ return (
+
+
+ Analysing…
+
+ );
+ }
+ if (nodes.length === 0) {
+ return (
+
+ {hasSources ? "Nothing analysed yet." : "Pick the voices this bed should make room for."}
+