Skip to content

Commit eb526c0

Browse files
committed
feat(studio): re-analyse a carve when a voice it was listening to is deleted
The filters and envelopes a carve produces are a MEASUREMENT of specific tracks. Delete one and they go on describing a voice nobody can hear — the bed keeps ducking for it. `analyse` already skipped a source it could not find, but nothing ever asked it to run again. So the panel prunes sources that have left the composition, and pruning is the whole trigger: setCarve re-analyses whenever the source list changes, and the survivors are re-measured together. Losing the last one leaves an empty list, and what the carve generated goes with it — those filters are making room for nobody. Staying enabled is deliberate: a voice may come back, and "off" is a different thing the author chose. Existence is asked of the TIMELINE, not of element.ownerDocument. Measured in the studio, a bed selected right after its voice was deleted still found that voice through the preview DOM, which outlives the edit; the store is what the delete actually changed. Absence there only counts once the store is known to describe this composition — the bed being in it is the proof — or an empty store would read as "every voice was deleted" and strip a healthy carve. Also fixes what that exposed: auto-apply could pick off the picker's FALLBACK list. The fallback shows every track when nothing classifies as a voice, so the author can override a name that reads as music — but with the narration deleted it left a 200 ms explosion as the only candidate, and the bed carved itself against it. Offering is for the author; choosing is the panel deciding, and an explosion is not a voice to make room for.
1 parent 25ecdef commit eb526c0

2 files changed

Lines changed: 249 additions & 23 deletions

File tree

packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx

Lines changed: 161 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,15 +1103,21 @@ describe("AudioFxGroup carve source list", () => {
11031103
});
11041104

11051105
it("keeps the picker when the stored voice is not among the candidates", () => {
1106-
// The stored track was renamed, or classifies as music now. Reading the one
1107-
// remaining candidate out would quietly claim the carve listens to it.
1106+
// The stored track is still there but no longer classifies as a voice.
1107+
// Reading the one remaining candidate out would quietly claim the carve
1108+
// listens to it. (A stored track that is GONE is a different case — see the
1109+
// deleted-voice tests, which re-analyse rather than sit on a measurement of
1110+
// something that is not there.)
11081111
const bed = document.createElement("audio");
11091112
bed.id = "bed";
11101113
bed.setAttribute(
11111114
"data-fx-carve",
1112-
JSON.stringify({ enabled: true, sources: ["gone"], strength: 0.25 }),
1115+
JSON.stringify({ enabled: true, sources: ["backing-music"], strength: 0.25 }),
11131116
);
11141117
document.body.append(bed);
1118+
const stored = document.createElement("audio");
1119+
stored.id = "backing-music";
1120+
document.body.append(stored);
11151121
const voice = document.createElement("audio");
11161122
voice.id = "narration";
11171123
document.body.append(voice);
@@ -1125,7 +1131,7 @@ describe("AudioFxGroup carve source list", () => {
11251131
dataAttributes: {
11261132
"fx-carve": JSON.stringify({
11271133
enabled: true,
1128-
sources: ["gone"],
1134+
sources: ["backing-music"],
11291135
strength: 0.25,
11301136
}),
11311137
},
@@ -1299,3 +1305,154 @@ describe("AudioFxGroup carve across tracks", () => {
12991305
]);
13001306
});
13011307
});
1308+
1309+
/**
1310+
* The filters and envelopes a carve produces are a MEASUREMENT of specific
1311+
* tracks. Delete one and they describe something nobody can hear any more — the
1312+
* bed keeps ducking for a voice that is gone.
1313+
*/
1314+
describe("AudioFxGroup carve against a deleted voice", () => {
1315+
const CARVED_CHAIN = JSON.stringify({
1316+
version: 1,
1317+
nodes: [
1318+
{ type: "peaking", id: "c1", enabled: true, fromCarve: true, params: { frequency: 1000 } },
1319+
{ type: "lowpass", id: "k1", enabled: true, params: { frequency: 8000 } },
1320+
],
1321+
});
1322+
const CARVED_AUTOMATION = JSON.stringify({
1323+
version: 1,
1324+
lanes: [
1325+
{ target: "fx.c1.gain", points: [{ t: 0, v: -6 }] },
1326+
{ target: "fx.k1.frequency", points: [{ t: 0, v: 8000 }] },
1327+
],
1328+
});
1329+
1330+
/**
1331+
* A bed carving against `sources`, with only `present` still in the composition.
1332+
*
1333+
* The timeline is what says a track is gone — not the preview DOM, which keeps
1334+
* a deleted element around — so the store is seeded and the document is left
1335+
* holding every track, which is exactly the mismatch the studio produces.
1336+
*/
1337+
function mountCarved(sources: string[], present: string[]) {
1338+
const carve = JSON.stringify({ enabled: true, sources, strength: 0.25 });
1339+
const bed = document.createElement("audio");
1340+
bed.id = "bed";
1341+
document.body.append(bed);
1342+
for (const id of new Set([...sources, ...present])) {
1343+
const el = document.createElement("audio");
1344+
el.id = id;
1345+
document.body.append(el);
1346+
}
1347+
usePlayerStore.setState({
1348+
elements: [
1349+
{ id: "bed", tag: "audio", start: 0, duration: 10, track: 0 },
1350+
...present.map((id) => ({ id, tag: "audio", start: 0, duration: 10, track: 1 })),
1351+
] as never,
1352+
});
1353+
const onSetAttributeQuiet = vi.fn();
1354+
const host = document.createElement("div");
1355+
document.body.append(host);
1356+
act(() => {
1357+
createRoot(host).render(
1358+
<AudioFxGroup
1359+
element={
1360+
{
1361+
dataAttributes: {
1362+
"fx-carve": carve,
1363+
"fx-chain": CARVED_CHAIN,
1364+
automation: CARVED_AUTOMATION,
1365+
},
1366+
id: "bed",
1367+
element: bed,
1368+
} as unknown as DomEditSelection
1369+
}
1370+
onSetAttributeQuiet={onSetAttributeQuiet}
1371+
onSetAttributeLive={vi.fn()}
1372+
/>,
1373+
);
1374+
});
1375+
return { host, onSetAttributeQuiet };
1376+
}
1377+
1378+
it("re-analyses against the voices that are left", () => {
1379+
// Two voices were measured together into one set of bands. With one gone that
1380+
// set answers a question nobody asked; the survivor has to be measured again.
1381+
const { onSetAttributeQuiet } = mountCarved(["narration", "guest"], ["narration"]);
1382+
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
1383+
expect(JSON.parse(String(write![1]))).toMatchObject({
1384+
enabled: true,
1385+
sources: ["narration"],
1386+
});
1387+
});
1388+
1389+
it("leaves a carve alone while every voice it names is still there", () => {
1390+
const { onSetAttributeQuiet } = mountCarved(["narration", "guest"], ["narration", "guest"]);
1391+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
1392+
});
1393+
1394+
it("does not fall back to carving against an effect when no voice is left", async () => {
1395+
// Found in the studio, not here: deleting the narration emptied the source
1396+
// list, and the panel filled it again with the only audio in the composition
1397+
// — a 200 ms explosion. The picker's fallback (offer everything rather than
1398+
// hide the track somebody needs) is for the AUTHOR to choose from. The panel
1399+
// choosing off it is the panel deciding, and that is never the answer.
1400+
const carve = JSON.stringify({ enabled: true, sources: [], strength: 0.25 });
1401+
const bed = document.createElement("audio");
1402+
bed.id = "bed";
1403+
bed.setAttribute("data-fx-carve", carve);
1404+
document.body.append(bed);
1405+
const sfx = document.createElement("audio");
1406+
sfx.id = "sfx-explosion";
1407+
document.body.append(sfx);
1408+
const onSetAttributeQuiet = vi.fn();
1409+
const host = document.createElement("div");
1410+
document.body.append(host);
1411+
act(() => {
1412+
createRoot(host).render(
1413+
<AudioFxGroup
1414+
element={
1415+
{
1416+
dataAttributes: { "fx-carve": carve },
1417+
id: "bed",
1418+
element: bed,
1419+
} as unknown as DomEditSelection
1420+
}
1421+
onSetAttributeQuiet={onSetAttributeQuiet}
1422+
onSetAttributeLive={vi.fn()}
1423+
/>,
1424+
);
1425+
});
1426+
await act(async () => {});
1427+
// Nothing written: the carve waits rather than picking the explosion.
1428+
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
1429+
// Still offered, so the author can say "actually, listen to that one".
1430+
expect(
1431+
Array.from(host.querySelectorAll("[data-carve-source]")).map((e) =>
1432+
e.getAttribute("data-carve-source"),
1433+
),
1434+
).toEqual(["sfx-explosion"]);
1435+
});
1436+
1437+
it("drops what it generated when the last voice goes and none is left to pick", async () => {
1438+
// Staying on with nothing to listen to is honest — a voice may come back, and
1439+
// "off" is a different thing the author chose. What cannot stay is the output:
1440+
// those filters and that envelope are making room for nobody.
1441+
const { onSetAttributeQuiet } = mountCarved(["narration"], []);
1442+
// The three writes are sequenced, not fired together: each is a
1443+
// read-modify-write against the same file, so the carve write lands only
1444+
// after the two that strip its output.
1445+
await act(async () => {});
1446+
const carve = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
1447+
expect(JSON.parse(String(carve![1]))).toMatchObject({ enabled: true, sources: [] });
1448+
1449+
const chain = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-chain");
1450+
// The hand-added low-pass survives; only what the carve minted goes.
1451+
expect(JSON.parse(String(chain![1])).nodes.map((n: { id: string }) => n.id)).toEqual(["k1"]);
1452+
1453+
const automation = writeTo(onSetAttributeQuiet.mock.calls, "data-automation");
1454+
expect(
1455+
JSON.parse(String(automation![1])).lanes.map((l: { target: string }) => l.target),
1456+
).toEqual(["fx.k1.frequency"]);
1457+
});
1458+
});

packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
} from "./propertyPanelAutomation";
4949
import type { DomEditSelection } from "./domEditingTypes";
5050
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
51+
import { usePlayerStore } from "../../player";
5152

5253
/**
5354
* Rate the carve source is decoded at. Analysis is self-consistent because it
@@ -199,10 +200,12 @@ export function AudioFxGroup({
199200
* commit, which does not exist yet.
200201
*/
201202
const setCarve = async (next: HfCarveSettings | null): Promise<void> => {
202-
// Envelopes the carve wrote outlive it otherwise, and an automated gain
203-
// ignores the panel's own depth — so switching dynamic off would leave the
204-
// filters still following the voice with nothing saying they do.
205-
if (!next?.enabled) {
203+
// What the carve generated is only justified by the voices it was measured
204+
// from: switched off, or left naming none — every source deleted, say —
205+
// there is nothing those filters are making room for. Left behind they keep
206+
// dipping the bed with nothing in the panel to explain them.
207+
const generatedOutputStands = Boolean(next?.enabled) && (next?.sources.length ?? 0) > 0;
208+
if (!generatedOutputStands) {
206209
const carriedOver = withoutCarveLanes(automation, chain);
207210
if (carriedOver.lanes.length !== automation.lanes.length) {
208211
await onSetAttributeQuiet(
@@ -211,7 +214,7 @@ export function AudioFxGroup({
211214
);
212215
}
213216
}
214-
if (!next?.enabled) {
217+
if (!generatedOutputStands) {
215218
const kept = chain.nodes.filter((n) => !n.fromCarve);
216219
if (kept.length !== chain.nodes.length) {
217220
await onSetAttributeQuiet(
@@ -300,9 +303,12 @@ export function AudioFxGroup({
300303
* first, and if filtering would leave nothing at all every track comes back. A
301304
* picker that hides the track somebody needs is worse than a long one.
302305
*/
303-
const sourceOptions: AudioTrackOption[] = (() => {
306+
const { sourceOptions, autoSourceIds } = ((): {
307+
sourceOptions: AudioTrackOption[];
308+
autoSourceIds: string[];
309+
} => {
304310
const doc = element.element?.ownerDocument;
305-
if (!doc) return [];
311+
if (!doc) return { sourceOptions: [], autoSourceIds: [] };
306312
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
307313
(a) => a.id !== element.id,
308314
);
@@ -324,11 +330,75 @@ export function AudioFxGroup({
324330
}));
325331
const plausible = described.filter((t) => t.kind === "voice" || t.kind === "unknown");
326332
const offered = plausible.length > 0 ? plausible : described;
327-
return offered
328-
.sort((a, b) => (a.kind === "voice" ? 0 : 1) - (b.kind === "voice" ? 0 : 1))
329-
.map(({ id, label }) => ({ id, label }));
333+
const byVoiceFirst = (list: typeof described) =>
334+
[...list].sort((a, b) => (a.kind === "voice" ? 0 : 1) - (b.kind === "voice" ? 0 : 1));
335+
return {
336+
sourceOptions: byVoiceFirst(offered).map(({ id, label }) => ({ id, label })),
337+
// What the panel may pick WITHOUT being asked — never the fallback. The
338+
// fallback exists so the picker can still show a track whose name reads as
339+
// music or as an effect, because a name is a hint and the author may know
340+
// better. Choosing off that list is a different act: it is the panel
341+
// deciding, and "the only audio left is a 200 ms explosion" is not a voice
342+
// to make room for. A bed surrounded by nothing plausible waits instead.
343+
autoSourceIds: byVoiceFirst(plausible).map((t) => t.id),
344+
};
330345
})();
331346

347+
/**
348+
* The voices this carve names that are still in the composition.
349+
*
350+
* Existence, not the candidate list: a voice can stop being offered without
351+
* being gone (it stopped overlapping the bed), and dropping it then would
352+
* quietly rewrite a relationship the author set. Deleted is the case that has
353+
* to be noticed, because what the carve produced was measured from that track.
354+
*
355+
* Asked of the timeline rather than of `element.element.ownerDocument`, which
356+
* is the preview's DOM and outlives a delete: measured in the studio, a bed
357+
* selected right after its voice was deleted still found that voice through
358+
* the document, so the carve sat on a measurement of a track the timeline had
359+
* already dropped. The store is what the delete actually edited.
360+
*/
361+
const timelineElements = usePlayerStore((s) => s.elements);
362+
const survivingSources = ((): string[] => {
363+
if (!carve) return [];
364+
const present = new Set(timelineElements.map((el) => el.domId ?? el.id));
365+
// Absence only means deletion once the timeline is known to describe THIS
366+
// composition, and the bed being in it is the proof. Without that check a
367+
// store that is empty — not loaded yet, or a panel mounted outside the
368+
// player — reads as "every voice was deleted" and throws away a carve that
369+
// is perfectly fine. Unchanged sources are what the prune treats as nothing
370+
// to do.
371+
if (!element.id || !present.has(element.id)) return carve.sources;
372+
return carve.sources.filter((id) => present.has(id));
373+
})();
374+
375+
/**
376+
* A deleted voice re-analyses the bed.
377+
*
378+
* The filters and envelopes are a measurement of specific tracks, so losing one
379+
* makes them a measurement of something that is no longer there — the bed keeps
380+
* ducking for a voice nobody can hear. `analyse` already skips a source it
381+
* cannot find, but nothing asked it to run again.
382+
*
383+
* Pruning is the whole trigger: `setCarve` re-analyses when the source list
384+
* changes, so the surviving voices are re-measured together. Losing the LAST
385+
* one leaves an empty list, which the effects below repoint at whatever
386+
* candidates remain — and if there are none, `setCarve` drops what the carve
387+
* generated, since there is nothing left it could be making room for.
388+
*
389+
* Keyed on the survivors rather than on the candidates: a voice that had
390+
* stopped overlapping was never in the candidate list, so its deletion would
391+
* not change that identity and this would never fire.
392+
*/
393+
useEffect(() => {
394+
if (carvedAgainstBy || !carve?.enabled) return;
395+
if (survivingSources.length === carve.sources.length) return;
396+
void setCarve({ ...carve, sources: survivingSources });
397+
// Keyed on the identity of the decision, not on setCarve — which is rebuilt
398+
// every render and would re-fire this.
399+
// eslint-disable-next-line react-hooks/exhaustive-deps
400+
}, [carve, carvedAgainstBy, survivingSources.join(" ")]);
401+
332402
/**
333403
* A bed with voices above it carves itself.
334404
*
@@ -346,10 +416,10 @@ export function AudioFxGroup({
346416
* off stores `enabled: false`, which is also a configured carve. That is the whole
347417
* reason the flag exists rather than "off" being an absent attribute.
348418
*/
349-
const candidateIds = sourceOptions.map((o) => o.id).join("\u0000");
419+
const candidateIds = autoSourceIds.join("\u0000");
350420
useEffect(() => {
351-
if (carvedAgainstBy || sourceOptions.length === 0) return;
352-
const all = sourceOptions.map((o) => o.id);
421+
if (carvedAgainstBy || autoSourceIds.length === 0) return;
422+
const all = autoSourceIds;
353423
// Nothing configured: the default carve, pointed at everything it could hear.
354424
if (carve === null) {
355425
void setCarve({ ...DEFAULT_CARVE, sources: all });
@@ -383,24 +453,23 @@ export function AudioFxGroup({
383453
* reason the flag exists rather than "off" being an absent attribute.
384454
*/
385455
useEffect(() => {
386-
if (carvedAgainstBy || sourceOptions.length !== 1) return;
387-
const only = sourceOptions[0];
456+
if (carvedAgainstBy || autoSourceIds.length !== 1) return;
457+
const only = autoSourceIds[0];
388458
if (!only) return;
389459
// Nothing configured: the default carve, pointed at the one candidate.
390460
if (carve === null) {
391-
void setCarve({ ...DEFAULT_CARVE, sources: [only.id] });
461+
void setCarve({ ...DEFAULT_CARVE, sources: [only] });
392462
return;
393463
}
394464
// Configured but with no voice yet — a carve switched on before there was
395465
// anything to listen to, or one whose source was cleared. The panel reads the
396466
// sole candidate out as the source, so it has to be the stored one too;
397467
// otherwise the card claims a relationship the attribute does not record.
398-
if (carve.enabled && carve.sources.length === 0)
399-
void setCarve({ ...carve, sources: [only.id] });
468+
if (carve.enabled && carve.sources.length === 0) void setCarve({ ...carve, sources: [only] });
400469
// Deliberately keyed on the identity of the decision, not on setCarve — which
401470
// is rebuilt every render and would re-fire this.
402471
// eslint-disable-next-line react-hooks/exhaustive-deps
403-
}, [carve, carvedAgainstBy, sourceOptions.length, sourceOptions[0]?.id]);
472+
}, [carve, carvedAgainstBy, autoSourceIds.length, autoSourceIds[0]]);
404473

405474
const [analysing, setAnalysing] = useState(false);
406475

0 commit comments

Comments
 (0)