From f575bdadcbc9461df038a9fa84092ba22da9b18a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 23 Aug 2026 18:51:07 -0700 Subject: [PATCH] fix(studio): harden carve and FX rack behavior (#3452) * fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards * fix(studio): unify audio IDs and group state * fix(studio): make audio-group edits transactional * fix(studio): keep preview state synchronized * fix(studio): align audio rows, automation lanes and headers * fix(studio): stabilize timeline audio derivations * refactor(studio): simplify group metadata memoization * style(studio): keep timeline layout within size gate * fix(studio): keep timeline preset apply off auditions * fix(studio): harden carve and FX rack behavior * fix(studio): repeat audio FX reveal requests --- .../editor/audioFxRevealTarget.test.ts | 59 ++++ .../components/editor/audioFxRevealTarget.ts | 113 ++++++ .../editor/audioFxSignalPath.test.ts | 50 +++ .../components/editor/audioFxSignalPath.ts | 71 ++++ .../components/editor/audioFxSummary.test.ts | 8 + .../src/components/editor/audioFxSummary.ts | 44 ++- .../editor/propertyPanelFxCarveModule.tsx | 328 +++++++++++------- .../editor/propertyPanelFxControls.test.tsx | 50 +++ .../editor/propertyPanelFxControls.tsx | 12 +- .../editor/propertyPanelFxEqModule.tsx | 3 + .../editor/propertyPanelFxNodeRow.tsx | 12 +- .../editor/propertyPanelFxPresetMenu.tsx | 14 +- .../editor/propertyPanelFxRackChain.tsx | 17 +- .../editor/propertyPanelFxSection.test.tsx | 42 +++ .../editor/propertyPanelFxSection.tsx | 67 +++- .../editor/propertyPanelFxSectionTypes.ts | 19 + .../src/components/editor/useFxCarve.ts | Bin 28274 -> 24107 bytes .../editor/useFxCarveGrouping.test.ts | 118 +++++++ .../components/editor/useFxCarveGrouping.ts | 143 +++++++- .../src/components/editor/useFxCarveNodes.ts | 153 ++++++++ .../src/components/editor/useFxLevelling.ts | 39 +-- 21 files changed, 1169 insertions(+), 193 deletions(-) create mode 100644 packages/studio/src/components/editor/audioFxRevealTarget.test.ts create mode 100644 packages/studio/src/components/editor/audioFxRevealTarget.ts create mode 100644 packages/studio/src/components/editor/audioFxSignalPath.test.ts create mode 100644 packages/studio/src/components/editor/audioFxSignalPath.ts create mode 100644 packages/studio/src/components/editor/useFxCarveGrouping.test.ts create mode 100644 packages/studio/src/components/editor/useFxCarveNodes.ts diff --git a/packages/studio/src/components/editor/audioFxRevealTarget.test.ts b/packages/studio/src/components/editor/audioFxRevealTarget.test.ts new file mode 100644 index 0000000000..943ce69fa1 --- /dev/null +++ b/packages/studio/src/components/editor/audioFxRevealTarget.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { audioFxRevealTarget } from "./audioFxRevealTarget"; +import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; + +const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes }); + +describe("audioFxRevealTarget", () => { + it("points a hand-built effect's lane at its own row, by chain index", () => { + const c = chain([ + { type: "highpass", id: "n1", params: {} }, + { type: "peaking", id: "n2", params: {} }, + ]); + expect(audioFxRevealTarget("fx.n2.gain", c)).toEqual({ kind: "node", index: 1, nodeId: "n2" }); + }); + + // The case that made this a resolver rather than an index lookup: a carve's + // bands are filtered out of the rack's node list, so opening `openNode` on + // one opens nothing. The carve module is what renders them. + it("points a carve band's lane at the carve module", () => { + const c = chain([{ type: "peaking", id: "n1", fromCarve: true, params: {} }]); + expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "carve" }); + }); + + it("points an EQ band's lane at its EQ module", () => { + const c = chain([{ type: "peaking", id: "n1", fromEq: "eq1", params: {} }]); + expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "eq", eqId: "eq1" }); + }); + + it("points a preset node's lane at its run, keyed like collapsedRuns", () => { + const c = chain([ + { type: "highpass", id: "n1", params: {} }, + { type: "peaking", id: "n2", fromPreset: "clean-voice", params: {} }, + { type: "gain", id: "n3", fromPreset: "clean-voice", params: {} }, + ]); + // Keyed by the run's FIRST node, not the automated one. + expect(audioFxRevealTarget("fx.n3.gain", c)).toEqual({ + kind: "preset", + runKey: "clean-voice-1", + }); + }); + + it("resolves a preset-level lane through the preset it names", () => { + const c = chain([{ type: "peaking", id: "n1", fromPreset: "clean-voice", params: {} }]); + expect(audioFxRevealTarget("fx.preset.clean-voice", c)).toEqual({ + kind: "preset", + runKey: "clean-voice-0", + }); + }); + + it("treats the track's own volume as the rack itself", () => { + expect(audioFxRevealTarget("volume", chain([]))).toEqual({ kind: "volume" }); + }); + + it("resolves nothing for a lane whose effect is gone, or an unparseable target", () => { + expect(audioFxRevealTarget("fx.gone.gain", chain([]))).toBeNull(); + expect(audioFxRevealTarget("nonsense", chain([]))).toBeNull(); + expect(audioFxRevealTarget("fx.n1.gain", null)).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/audioFxRevealTarget.ts b/packages/studio/src/components/editor/audioFxRevealTarget.ts new file mode 100644 index 0000000000..764d7bf56f --- /dev/null +++ b/packages/studio/src/components/editor/audioFxRevealTarget.ts @@ -0,0 +1,113 @@ +/** + * Where in the rack an automation lane's parameter actually lives. + * + * A lane names `fx..`, but the rack does not show one flat list + * of nodes: the carve is one module standing for the filters it compiled, EQ + * bands are folded into their own module, and preset runs are collapsible + * groups. A node id therefore resolves to one of several surfaces, and the + * caller has to open the RIGHT one — opening `openNode` on a carve band, whose + * row is filtered out of `handBuilt`, would open nothing at all and read as the + * click doing nothing. + */ + +import { parseAutomationTarget } from "@hyperframes/core/audio-automation"; +import { escapeCssString } from "./domEditingDom"; +import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; + +export type AudioFxRevealTarget = + /** A hand-built effect, addressed by its index in the chain. */ + | { kind: "node"; index: number; nodeId: string } + /** An EQ module, addressed by the id its bands share. */ + | { kind: "eq"; eqId: string } + /** A preset run, addressed the way `collapsedRuns` keys it. */ + | { kind: "preset"; runKey: string } + /** The carve module, which owns every `fromCarve` node collectively. */ + | { kind: "carve" } + /** The track's own volume — no rack row; the reveal is the rack itself. */ + | { kind: "volume" }; + +/** + * Resolve a lane target to the surface that shows it, or null when the chain + * does not contain it (a stale lane, or one whose effect was removed). + */ +export function audioFxRevealTarget( + target: string, + chain: HfAudioFxChain | null, +): AudioFxRevealTarget | null { + const parsed = parseAutomationTarget(target); + if (!parsed) return null; + if (parsed.kind === "volume") return { kind: "volume" }; + if (parsed.kind === "preset") { + // A preset-level lane names the preset, not a node inside it: find its run + // by the first node that belongs to it, which is how `runKey` is built. + const index = (chain?.nodes ?? []).findIndex((node) => node.fromPreset === parsed.presetId); + return index >= 0 ? { kind: "preset", runKey: `${parsed.presetId}-${index}` } : null; + } + const index = (chain?.nodes ?? []).findIndex((node) => node.id === parsed.nodeId); + const node = index >= 0 ? chain?.nodes[index] : undefined; + if (!node) return null; + // Order matters: a carve band can also carry `fromEq`/`fromPreset` tags, and + // the carve module is the one that actually renders it. + if (node.fromCarve) return { kind: "carve" }; + if (node.fromEq) return { kind: "eq", eqId: node.fromEq }; + if (node.fromPreset) { + const first = (chain?.nodes ?? []).findIndex((n) => n.fromPreset === node.fromPreset); + return { kind: "preset", runKey: `${node.fromPreset}-${first}` }; + } + return { kind: "node", index, nodeId: parsed.nodeId }; +} + +/** + * The DOM selector for the row a reveal target lives in, or null when the target + * names no surface this panel renders. + * + * A preset run is keyed by the preset id the run element actually exposes, not by + * `runKey`, which is the collapse map's key and carries an index suffix. + */ +function revealRowSelector(where: AudioFxRevealTarget | null): string | null { + if (!where) return null; + switch (where.kind) { + case "node": + return where.nodeId ? `[data-fx-node-id="${escapeCssString(where.nodeId)}"]` : null; + case "eq": + return `[data-fx-eq="${escapeCssString(where.eqId)}"]`; + case "carve": + return ".hf-fx-carve-module"; + case "preset": + return `[data-fx-preset="${escapeCssString(where.runKey.replace(/-\d+$/, ""))}"]`; + default: + return null; + } +} + +/** + * Scroll the row that owns `target` into view, and report whether it was found. + * + * The target is resolved against the chain again rather than remembered: which + * surface owns a parameter is a fact about the chain, and the chain may have + * been edited between the reveal request and the pass that can act on it. A + * false return means the row has not mounted yet, so the caller keeps the + * request pending. + */ +export function scrollRevealedRowIntoView( + root: HTMLElement | null, + target: string, + chain: HfAudioFxChain, +): boolean { + const selector = revealRowSelector(audioFxRevealTarget(target, chain)); + // `querySelector` throws SyntaxError on a malformed selector, and this runs + // inside a render-phase effect — an unescapable id would take the whole + // property panel down instead of leaving the request pending, which is what + // returning false means. `parseAudioFxNode` accepts any non-empty string as + // an id and `parseAutomationTarget` only splits on `.`, so a hand- or + // LLM-authored chain can carry one. + let row: HTMLElement | null = null; + try { + row = selector ? (root?.querySelector(selector) ?? null) : null; + } catch { + return false; + } + if (!row) return false; + row.scrollIntoView({ block: "nearest", behavior: "smooth" }); + return true; +} diff --git a/packages/studio/src/components/editor/audioFxSignalPath.test.ts b/packages/studio/src/components/editor/audioFxSignalPath.test.ts new file mode 100644 index 0000000000..407a318b04 --- /dev/null +++ b/packages/studio/src/components/editor/audioFxSignalPath.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { HfAudioGroup } from "@hyperframes/core/audio-groups"; +import { audioFxSignalPath } from "./audioFxSignalPath"; + +const group = (over: Partial = {}): HfAudioGroup => ({ + id: "voiceover", + label: "Voiceover", + memberIds: ["vo-1", "vo-2"], + volume: 1, + hidden: false, + ...over, +}); + +describe("audioFxSignalPath", () => { + // The design doc's §5 mockup, both columns. + // Copy taken from the rendered designs, which are more specific than the + // ASCII stand-ins in the markdown: "vo-1 and vo-2, together" / "into + // Voiceover", not "vo-1, vo-2" / "to Voiceover". + it("names what a group sums, and sends it to the mix", () => { + expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({ + inLabel: "vo-1 and vo-2, together", + outLabel: "to mix", + subject: "group", + }); + }); + + it("names the group a member feeds, so routing reads from either end", () => { + expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({ + inLabel: "this track", + outLabel: "into Voiceover", + subject: "track", + }); + }); + + it("leaves an ungrouped clip on the shipped clip labels", () => { + expect(audioFxSignalPath("audio", "music-bed", [group()])).toEqual({ + inLabel: "this track", + outLabel: "to mix", + subject: "track", + }); + }); + + // The state an author is in the instant after making a group. It must not + // read as a failure to resolve. + it("says a memberless group holds nothing yet", () => { + expect( + audioFxSignalPath("hf-audio-group", "empty", [group({ id: "empty", memberIds: [] })]), + ).toMatchObject({ inLabel: "nothing yet", subject: "group" }); + }); +}); diff --git a/packages/studio/src/components/editor/audioFxSignalPath.ts b/packages/studio/src/components/editor/audioFxSignalPath.ts new file mode 100644 index 0000000000..43bc54a4a8 --- /dev/null +++ b/packages/studio/src/components/editor/audioFxSignalPath.ts @@ -0,0 +1,71 @@ +/** + * What the rack's `IN` and `OUT` lines say, per selected element. + * + * The rack brackets its chain with the signal path because the ORDER is the + * point (see `propertyPanelFxRackChain`). Those two lines were hardcoded to a + * clip's answer — "in this track", "out to mix" — which is wrong at both ends + * once groups exist, and the design doc's §5 mockup spells out both: + * + * GROUP: Voiceover CLIP: vo-1 + * IN vo-1, vo-2 IN this track + * OUT to mix OUT to Voiceover + * + * A group's IN names what it sums, which is the only thing on screen that says + * a bus is a sum rather than a copy of the chain on each member. A member's OUT + * names the group it feeds, "so the routing is readable from either end". + */ + +import type { HfAudioGroup } from "@hyperframes/core/audio-groups"; + +export interface AudioFxSignalPath { + /** After the word "In". */ + inLabel: string; + /** After the word "Out". */ + outLabel: string; + /** The thing the empty-state sentence is about: "No effects on this …". */ + subject: string; +} + +/** What a plain, ungrouped clip has always said, and the default everywhere. */ +export const CLIP_SIGNAL_PATH: AudioFxSignalPath = { + inLabel: "this track", + outLabel: "to mix", + subject: "track", +}; + +/** + * `groups` is the resolved set from the composition; `elementId` and `tag` come + * from the selection. Pure so the labels can be asserted without a DOM. + */ +/** "a", "a and b", "a, b and c" — how the designs read a member list aloud. */ +function joinNatural(items: readonly string[]): string { + if (items.length <= 1) return items[0] ?? ""; + return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; +} + +export function audioFxSignalPath( + tag: string | undefined, + elementId: string | undefined, + groups: readonly HfAudioGroup[], +): AudioFxSignalPath { + if (tag === "hf-audio-group") { + const group = groups.find((g) => g.id === elementId); + // A group with no members yet still reads as a group — "nothing yet" is the + // honest answer, and it is also the state the author is in right after + // making one, so it must not look like a bug. + const members = group?.memberIds ?? []; + return { + // "vo-1 and vo-2, together" — the rendered design's exact phrasing, not a + // comma list. The trailing "together" is the point: it says the group is + // ONE signal hearing both, which is the thing two separate copies of a + // chain cannot do, and it says it without "sum" or "bus". + inLabel: members.length > 0 ? `${joinNatural(members)}, together` : "nothing yet", + outLabel: "to mix", + subject: "group", + }; + } + const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined; + // "into Voiceover", not "to" — a member feeds the group, and the design uses + // the preposition that says so. + return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `into ${owner.label}` } : CLIP_SIGNAL_PATH; +} diff --git a/packages/studio/src/components/editor/audioFxSummary.test.ts b/packages/studio/src/components/editor/audioFxSummary.test.ts index 5ba7a87a5a..9b30a10806 100644 --- a/packages/studio/src/components/editor/audioFxSummary.test.ts +++ b/packages/studio/src/components/editor/audioFxSummary.test.ts @@ -8,6 +8,14 @@ const el = (dataAttributes: Record): DomEditSelection => const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes }); describe("audioFxSummary", () => { + // The designs give a member's rack the summary "in Voiceover" — it answers + // "where does this go?" before anything is opened, the same job the rack's + // OUT does from the other end, and it outranks the effect count because a + // member with no effects of its own is still in the group. + it("names the group a clip belongs to, ahead of any effect count", () => { + expect(audioFxSummary(el({}), "Voiceover")).toBe("in Voiceover"); + }); + it("counts a carve as one module, not as the filters behind it", () => { // Six bands and a level stage reading "7 effects" is the misreading the // grouping exists to prevent. diff --git a/packages/studio/src/components/editor/audioFxSummary.ts b/packages/studio/src/components/editor/audioFxSummary.ts index a56ee5323c..7bf1a3cf8c 100644 --- a/packages/studio/src/components/editor/audioFxSummary.ts +++ b/packages/studio/src/components/editor/audioFxSummary.ts @@ -10,27 +10,39 @@ import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx"; import type { DomEditSelection } from "./domEditingTypes"; -export function audioFxSummary(element: DomEditSelection): string { - const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]; - const carveAttr = element.dataAttributes?.["fx-carve"]; - let handBuilt = 0; - let carveNodes = 0; - if (raw) { - try { - for (const node of parseAudioFxChain(raw).nodes) { - if (node.enabled === false) continue; - if (node.fromCarve) carveNodes += 1; - else handBuilt += 1; - } - } catch { - return "unreadable"; +/** Enabled nodes split by who authored them, or null when the chain won't parse. */ +function countEnabledNodes(raw: string | undefined): { handBuilt: number; carve: number } | null { + if (!raw) return { handBuilt: 0, carve: 0 }; + try { + let handBuilt = 0; + let carve = 0; + for (const node of parseAudioFxChain(raw).nodes) { + if (node.enabled === false) continue; + if (node.fromCarve) carve += 1; + else handBuilt += 1; } + return { handBuilt, carve }; + } catch { + return null; } +} + +export function audioFxSummary(element: DomEditSelection, groupLabel?: string): string { + // A clip inside a group reads "in Voiceover" — the designs use this line to + // answer "where does this go?" before the author opens anything, which is + // the same job the rack's OUT does from the other end. It outranks the effect + // count: a member with no effects of its own is still IN the group, and that + // is the more useful thing to say about it. + if (groupLabel) return `in ${groupLabel}`; + const counts = countEnabledNodes(element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]); + if (!counts) return "unreadable"; const parts: string[] = []; - if (handBuilt > 0) parts.push(`${handBuilt} effect${handBuilt === 1 ? "" : "s"}`); + if (counts.handBuilt > 0) { + parts.push(`${counts.handBuilt} effect${counts.handBuilt === 1 ? "" : "s"}`); + } // One name for the module however many filters are behind it. Named when the // carve is switched on at all, because the control is in this section whether or // not it has compiled to anything yet. - if (carveNodes > 0 || carveAttr) parts.push("carve"); + if (counts.carve > 0 || element.dataAttributes?.["fx-carve"]) parts.push("carve"); return parts.length > 0 ? parts.join(" + ") : "none"; } diff --git a/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx b/packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx index 706595b822..9cf2b3b81d 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 @@ -230,6 +196,8 @@ export function FxCarveModule({ // 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" }} + // Truncates in a narrow panel like every other name in the rack. + title="Voiceover carve" aria-expanded={open} onClick={onToggleOpen} > @@ -255,50 +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 @@ -330,53 +261,200 @@ 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."} -

- )} + 0} + automatedTargets={automatedTargets} + liveAutomationValues={liveAutomationValues} + />
) : null}
); } + +/** + * 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 ( +
+ {/* Wraps like every other name in this column (see FxParamRow) — one + truncating row beside wrapping ones reads as a rendering bug. */} + + 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."} +

+ ); + } + return ( +
+
+ analysed +
+ {nodes.map((node, i) => ( + + ))} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFxControls.test.tsx b/packages/studio/src/components/editor/propertyPanelFxControls.test.tsx index e498875822..ef9eed4c81 100644 --- a/packages/studio/src/components/editor/propertyPanelFxControls.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxControls.test.tsx @@ -113,3 +113,53 @@ describe("FxParamRow commit", () => { expect(onCommit).not.toHaveBeenCalled(); }); }); + +describe("FxParamRow label tooltip", () => { + // The label column is a fixed 86px and truncates: "Gap between repeats" reads + // as "Gap between re…", "How many repeats" as "How many repe…". The row + // already carried a `title`, but that is the HINT — what the knob does — so a + // truncated name had no way to be read in full. + const LONG: HfAudioFxNumberParam = { + kind: "number", + key: "time", + label: "Gap between repeats", + min: 0, + max: 1000, + step: 1, + default: 250, + unit: "ms", + hint: "How long before the echo comes back.", + }; + + it("wraps the full name rather than truncating it, and leaves the hint on the row", () => { + const host = document.createElement("div"); + document.body.append(host); + act(() => { + createRoot(host).render(); + }); + const label = host.querySelector(".hf-fx-label"); + // The whole name is present and allowed to wrap. No `title`: a tooltip + // answers one row on hover, wrapping answers the column at rest. + expect(label?.textContent).toBe("Gap between repeats"); + expect(label?.className).toContain("break-words"); + expect(label?.className).not.toContain("truncate"); + expect(label?.getAttribute("title")).toBeNull(); + // The hint stays where it was: the two are different questions, and the + // name is not a substitute for the explanation either. + expect(host.querySelector(".hf-fx-row")?.getAttribute("title")).toBe( + "How long before the echo comes back.", + ); + }); + + it("wraps it the same way with no hint to fall back on", () => { + const host = document.createElement("div"); + document.body.append(host); + const { hint: _hint, ...noHint } = LONG; + act(() => { + createRoot(host).render(); + }); + const label = host.querySelector(".hf-fx-label"); + expect(label?.textContent).toBe("Gap between repeats"); + expect(label?.getAttribute("title")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFxControls.tsx b/packages/studio/src/components/editor/propertyPanelFxControls.tsx index 7b41e1f16d..ae0fffbb70 100644 --- a/packages/studio/src/components/editor/propertyPanelFxControls.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxControls.tsx @@ -188,7 +188,14 @@ export function FxParamRow({ if (param.kind === "enum") { return (