Skip to content

Commit f575bda

Browse files
authored
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
1 parent 4ea018a commit f575bda

21 files changed

Lines changed: 1169 additions & 193 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from "vitest";
2+
import { audioFxRevealTarget } from "./audioFxRevealTarget";
3+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
4+
5+
const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
6+
7+
describe("audioFxRevealTarget", () => {
8+
it("points a hand-built effect's lane at its own row, by chain index", () => {
9+
const c = chain([
10+
{ type: "highpass", id: "n1", params: {} },
11+
{ type: "peaking", id: "n2", params: {} },
12+
]);
13+
expect(audioFxRevealTarget("fx.n2.gain", c)).toEqual({ kind: "node", index: 1, nodeId: "n2" });
14+
});
15+
16+
// The case that made this a resolver rather than an index lookup: a carve's
17+
// bands are filtered out of the rack's node list, so opening `openNode` on
18+
// one opens nothing. The carve module is what renders them.
19+
it("points a carve band's lane at the carve module", () => {
20+
const c = chain([{ type: "peaking", id: "n1", fromCarve: true, params: {} }]);
21+
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "carve" });
22+
});
23+
24+
it("points an EQ band's lane at its EQ module", () => {
25+
const c = chain([{ type: "peaking", id: "n1", fromEq: "eq1", params: {} }]);
26+
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "eq", eqId: "eq1" });
27+
});
28+
29+
it("points a preset node's lane at its run, keyed like collapsedRuns", () => {
30+
const c = chain([
31+
{ type: "highpass", id: "n1", params: {} },
32+
{ type: "peaking", id: "n2", fromPreset: "clean-voice", params: {} },
33+
{ type: "gain", id: "n3", fromPreset: "clean-voice", params: {} },
34+
]);
35+
// Keyed by the run's FIRST node, not the automated one.
36+
expect(audioFxRevealTarget("fx.n3.gain", c)).toEqual({
37+
kind: "preset",
38+
runKey: "clean-voice-1",
39+
});
40+
});
41+
42+
it("resolves a preset-level lane through the preset it names", () => {
43+
const c = chain([{ type: "peaking", id: "n1", fromPreset: "clean-voice", params: {} }]);
44+
expect(audioFxRevealTarget("fx.preset.clean-voice", c)).toEqual({
45+
kind: "preset",
46+
runKey: "clean-voice-0",
47+
});
48+
});
49+
50+
it("treats the track's own volume as the rack itself", () => {
51+
expect(audioFxRevealTarget("volume", chain([]))).toEqual({ kind: "volume" });
52+
});
53+
54+
it("resolves nothing for a lane whose effect is gone, or an unparseable target", () => {
55+
expect(audioFxRevealTarget("fx.gone.gain", chain([]))).toBeNull();
56+
expect(audioFxRevealTarget("nonsense", chain([]))).toBeNull();
57+
expect(audioFxRevealTarget("fx.n1.gain", null)).toBeNull();
58+
});
59+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Where in the rack an automation lane's parameter actually lives.
3+
*
4+
* A lane names `fx.<nodeId>.<param>`, but the rack does not show one flat list
5+
* of nodes: the carve is one module standing for the filters it compiled, EQ
6+
* bands are folded into their own module, and preset runs are collapsible
7+
* groups. A node id therefore resolves to one of several surfaces, and the
8+
* caller has to open the RIGHT one — opening `openNode` on a carve band, whose
9+
* row is filtered out of `handBuilt`, would open nothing at all and read as the
10+
* click doing nothing.
11+
*/
12+
13+
import { parseAutomationTarget } from "@hyperframes/core/audio-automation";
14+
import { escapeCssString } from "./domEditingDom";
15+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
16+
17+
export type AudioFxRevealTarget =
18+
/** A hand-built effect, addressed by its index in the chain. */
19+
| { kind: "node"; index: number; nodeId: string }
20+
/** An EQ module, addressed by the id its bands share. */
21+
| { kind: "eq"; eqId: string }
22+
/** A preset run, addressed the way `collapsedRuns` keys it. */
23+
| { kind: "preset"; runKey: string }
24+
/** The carve module, which owns every `fromCarve` node collectively. */
25+
| { kind: "carve" }
26+
/** The track's own volume — no rack row; the reveal is the rack itself. */
27+
| { kind: "volume" };
28+
29+
/**
30+
* Resolve a lane target to the surface that shows it, or null when the chain
31+
* does not contain it (a stale lane, or one whose effect was removed).
32+
*/
33+
export function audioFxRevealTarget(
34+
target: string,
35+
chain: HfAudioFxChain | null,
36+
): AudioFxRevealTarget | null {
37+
const parsed = parseAutomationTarget(target);
38+
if (!parsed) return null;
39+
if (parsed.kind === "volume") return { kind: "volume" };
40+
if (parsed.kind === "preset") {
41+
// A preset-level lane names the preset, not a node inside it: find its run
42+
// by the first node that belongs to it, which is how `runKey` is built.
43+
const index = (chain?.nodes ?? []).findIndex((node) => node.fromPreset === parsed.presetId);
44+
return index >= 0 ? { kind: "preset", runKey: `${parsed.presetId}-${index}` } : null;
45+
}
46+
const index = (chain?.nodes ?? []).findIndex((node) => node.id === parsed.nodeId);
47+
const node = index >= 0 ? chain?.nodes[index] : undefined;
48+
if (!node) return null;
49+
// Order matters: a carve band can also carry `fromEq`/`fromPreset` tags, and
50+
// the carve module is the one that actually renders it.
51+
if (node.fromCarve) return { kind: "carve" };
52+
if (node.fromEq) return { kind: "eq", eqId: node.fromEq };
53+
if (node.fromPreset) {
54+
const first = (chain?.nodes ?? []).findIndex((n) => n.fromPreset === node.fromPreset);
55+
return { kind: "preset", runKey: `${node.fromPreset}-${first}` };
56+
}
57+
return { kind: "node", index, nodeId: parsed.nodeId };
58+
}
59+
60+
/**
61+
* The DOM selector for the row a reveal target lives in, or null when the target
62+
* names no surface this panel renders.
63+
*
64+
* A preset run is keyed by the preset id the run element actually exposes, not by
65+
* `runKey`, which is the collapse map's key and carries an index suffix.
66+
*/
67+
function revealRowSelector(where: AudioFxRevealTarget | null): string | null {
68+
if (!where) return null;
69+
switch (where.kind) {
70+
case "node":
71+
return where.nodeId ? `[data-fx-node-id="${escapeCssString(where.nodeId)}"]` : null;
72+
case "eq":
73+
return `[data-fx-eq="${escapeCssString(where.eqId)}"]`;
74+
case "carve":
75+
return ".hf-fx-carve-module";
76+
case "preset":
77+
return `[data-fx-preset="${escapeCssString(where.runKey.replace(/-\d+$/, ""))}"]`;
78+
default:
79+
return null;
80+
}
81+
}
82+
83+
/**
84+
* Scroll the row that owns `target` into view, and report whether it was found.
85+
*
86+
* The target is resolved against the chain again rather than remembered: which
87+
* surface owns a parameter is a fact about the chain, and the chain may have
88+
* been edited between the reveal request and the pass that can act on it. A
89+
* false return means the row has not mounted yet, so the caller keeps the
90+
* request pending.
91+
*/
92+
export function scrollRevealedRowIntoView(
93+
root: HTMLElement | null,
94+
target: string,
95+
chain: HfAudioFxChain,
96+
): boolean {
97+
const selector = revealRowSelector(audioFxRevealTarget(target, chain));
98+
// `querySelector` throws SyntaxError on a malformed selector, and this runs
99+
// inside a render-phase effect — an unescapable id would take the whole
100+
// property panel down instead of leaving the request pending, which is what
101+
// returning false means. `parseAudioFxNode` accepts any non-empty string as
102+
// an id and `parseAutomationTarget` only splits on `.`, so a hand- or
103+
// LLM-authored chain can carry one.
104+
let row: HTMLElement | null = null;
105+
try {
106+
row = selector ? (root?.querySelector<HTMLElement>(selector) ?? null) : null;
107+
} catch {
108+
return false;
109+
}
110+
if (!row) return false;
111+
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
112+
return true;
113+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
3+
import { audioFxSignalPath } from "./audioFxSignalPath";
4+
5+
const group = (over: Partial<HfAudioGroup> = {}): HfAudioGroup => ({
6+
id: "voiceover",
7+
label: "Voiceover",
8+
memberIds: ["vo-1", "vo-2"],
9+
volume: 1,
10+
hidden: false,
11+
...over,
12+
});
13+
14+
describe("audioFxSignalPath", () => {
15+
// The design doc's §5 mockup, both columns.
16+
// Copy taken from the rendered designs, which are more specific than the
17+
// ASCII stand-ins in the markdown: "vo-1 and vo-2, together" / "into
18+
// Voiceover", not "vo-1, vo-2" / "to Voiceover".
19+
it("names what a group sums, and sends it to the mix", () => {
20+
expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({
21+
inLabel: "vo-1 and vo-2, together",
22+
outLabel: "to mix",
23+
subject: "group",
24+
});
25+
});
26+
27+
it("names the group a member feeds, so routing reads from either end", () => {
28+
expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({
29+
inLabel: "this track",
30+
outLabel: "into Voiceover",
31+
subject: "track",
32+
});
33+
});
34+
35+
it("leaves an ungrouped clip on the shipped clip labels", () => {
36+
expect(audioFxSignalPath("audio", "music-bed", [group()])).toEqual({
37+
inLabel: "this track",
38+
outLabel: "to mix",
39+
subject: "track",
40+
});
41+
});
42+
43+
// The state an author is in the instant after making a group. It must not
44+
// read as a failure to resolve.
45+
it("says a memberless group holds nothing yet", () => {
46+
expect(
47+
audioFxSignalPath("hf-audio-group", "empty", [group({ id: "empty", memberIds: [] })]),
48+
).toMatchObject({ inLabel: "nothing yet", subject: "group" });
49+
});
50+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* What the rack's `IN` and `OUT` lines say, per selected element.
3+
*
4+
* The rack brackets its chain with the signal path because the ORDER is the
5+
* point (see `propertyPanelFxRackChain`). Those two lines were hardcoded to a
6+
* clip's answer — "in this track", "out to mix" — which is wrong at both ends
7+
* once groups exist, and the design doc's §5 mockup spells out both:
8+
*
9+
* GROUP: Voiceover CLIP: vo-1
10+
* IN vo-1, vo-2 IN this track
11+
* OUT to mix OUT to Voiceover
12+
*
13+
* A group's IN names what it sums, which is the only thing on screen that says
14+
* a bus is a sum rather than a copy of the chain on each member. A member's OUT
15+
* names the group it feeds, "so the routing is readable from either end".
16+
*/
17+
18+
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
19+
20+
export interface AudioFxSignalPath {
21+
/** After the word "In". */
22+
inLabel: string;
23+
/** After the word "Out". */
24+
outLabel: string;
25+
/** The thing the empty-state sentence is about: "No effects on this …". */
26+
subject: string;
27+
}
28+
29+
/** What a plain, ungrouped clip has always said, and the default everywhere. */
30+
export const CLIP_SIGNAL_PATH: AudioFxSignalPath = {
31+
inLabel: "this track",
32+
outLabel: "to mix",
33+
subject: "track",
34+
};
35+
36+
/**
37+
* `groups` is the resolved set from the composition; `elementId` and `tag` come
38+
* from the selection. Pure so the labels can be asserted without a DOM.
39+
*/
40+
/** "a", "a and b", "a, b and c" — how the designs read a member list aloud. */
41+
function joinNatural(items: readonly string[]): string {
42+
if (items.length <= 1) return items[0] ?? "";
43+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
44+
}
45+
46+
export function audioFxSignalPath(
47+
tag: string | undefined,
48+
elementId: string | undefined,
49+
groups: readonly HfAudioGroup[],
50+
): AudioFxSignalPath {
51+
if (tag === "hf-audio-group") {
52+
const group = groups.find((g) => g.id === elementId);
53+
// A group with no members yet still reads as a group — "nothing yet" is the
54+
// honest answer, and it is also the state the author is in right after
55+
// making one, so it must not look like a bug.
56+
const members = group?.memberIds ?? [];
57+
return {
58+
// "vo-1 and vo-2, together" — the rendered design's exact phrasing, not a
59+
// comma list. The trailing "together" is the point: it says the group is
60+
// ONE signal hearing both, which is the thing two separate copies of a
61+
// chain cannot do, and it says it without "sum" or "bus".
62+
inLabel: members.length > 0 ? `${joinNatural(members)}, together` : "nothing yet",
63+
outLabel: "to mix",
64+
subject: "group",
65+
};
66+
}
67+
const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined;
68+
// "into Voiceover", not "to" — a member feeds the group, and the design uses
69+
// the preposition that says so.
70+
return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `into ${owner.label}` } : CLIP_SIGNAL_PATH;
71+
}

packages/studio/src/components/editor/audioFxSummary.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ const el = (dataAttributes: Record<string, string>): DomEditSelection =>
88
const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
99

1010
describe("audioFxSummary", () => {
11+
// The designs give a member's rack the summary "in Voiceover" — it answers
12+
// "where does this go?" before anything is opened, the same job the rack's
13+
// OUT does from the other end, and it outranks the effect count because a
14+
// member with no effects of its own is still in the group.
15+
it("names the group a clip belongs to, ahead of any effect count", () => {
16+
expect(audioFxSummary(el({}), "Voiceover")).toBe("in Voiceover");
17+
});
18+
1119
it("counts a carve as one module, not as the filters behind it", () => {
1220
// Six bands and a level stage reading "7 effects" is the misreading the
1321
// grouping exists to prevent.

packages/studio/src/components/editor/audioFxSummary.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,39 @@
1010
import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx";
1111
import type { DomEditSelection } from "./domEditingTypes";
1212

13-
export function audioFxSummary(element: DomEditSelection): string {
14-
const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
15-
const carveAttr = element.dataAttributes?.["fx-carve"];
16-
let handBuilt = 0;
17-
let carveNodes = 0;
18-
if (raw) {
19-
try {
20-
for (const node of parseAudioFxChain(raw).nodes) {
21-
if (node.enabled === false) continue;
22-
if (node.fromCarve) carveNodes += 1;
23-
else handBuilt += 1;
24-
}
25-
} catch {
26-
return "unreadable";
13+
/** Enabled nodes split by who authored them, or null when the chain won't parse. */
14+
function countEnabledNodes(raw: string | undefined): { handBuilt: number; carve: number } | null {
15+
if (!raw) return { handBuilt: 0, carve: 0 };
16+
try {
17+
let handBuilt = 0;
18+
let carve = 0;
19+
for (const node of parseAudioFxChain(raw).nodes) {
20+
if (node.enabled === false) continue;
21+
if (node.fromCarve) carve += 1;
22+
else handBuilt += 1;
2723
}
24+
return { handBuilt, carve };
25+
} catch {
26+
return null;
2827
}
28+
}
29+
30+
export function audioFxSummary(element: DomEditSelection, groupLabel?: string): string {
31+
// A clip inside a group reads "in Voiceover" — the designs use this line to
32+
// answer "where does this go?" before the author opens anything, which is
33+
// the same job the rack's OUT does from the other end. It outranks the effect
34+
// count: a member with no effects of its own is still IN the group, and that
35+
// is the more useful thing to say about it.
36+
if (groupLabel) return `in ${groupLabel}`;
37+
const counts = countEnabledNodes(element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]);
38+
if (!counts) return "unreadable";
2939
const parts: string[] = [];
30-
if (handBuilt > 0) parts.push(`${handBuilt} effect${handBuilt === 1 ? "" : "s"}`);
40+
if (counts.handBuilt > 0) {
41+
parts.push(`${counts.handBuilt} effect${counts.handBuilt === 1 ? "" : "s"}`);
42+
}
3143
// One name for the module however many filters are behind it. Named when the
3244
// carve is switched on at all, because the control is in this section whether or
3345
// not it has compiled to anything yet.
34-
if (carveNodes > 0 || carveAttr) parts.push("carve");
46+
if (counts.carve > 0 || element.dataAttributes?.["fx-carve"]) parts.push("carve");
3547
return parts.length > 0 ? parts.join(" + ") : "none";
3648
}

0 commit comments

Comments
 (0)