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.
This commit is contained in:
Vance Ingalls
2026-08-07 21:29:12 -07:00
parent 70c5456d23
commit c06d7a1a8a
2 changed files with 249 additions and 23 deletions
@@ -1103,15 +1103,21 @@ describe("AudioFxGroup carve source list", () => {
});
it("keeps the picker when the stored voice is not among the candidates", () => {
// The stored track was renamed, or classifies as music now. Reading the one
// remaining candidate out would quietly claim the carve listens to it.
// The stored track is still there but no longer classifies as a voice.
// Reading the one remaining candidate out would quietly claim the carve
// listens to it. (A stored track that is GONE is a different case — see the
// deleted-voice tests, which re-analyse rather than sit on a measurement of
// something that is not there.)
const bed = document.createElement("audio");
bed.id = "bed";
bed.setAttribute(
"data-fx-carve",
JSON.stringify({ enabled: true, sources: ["gone"], strength: 0.25 }),
JSON.stringify({ enabled: true, sources: ["backing-music"], strength: 0.25 }),
);
document.body.append(bed);
const stored = document.createElement("audio");
stored.id = "backing-music";
document.body.append(stored);
const voice = document.createElement("audio");
voice.id = "narration";
document.body.append(voice);
@@ -1125,7 +1131,7 @@ describe("AudioFxGroup carve source list", () => {
dataAttributes: {
"fx-carve": JSON.stringify({
enabled: true,
sources: ["gone"],
sources: ["backing-music"],
strength: 0.25,
}),
},
@@ -1299,3 +1305,154 @@ describe("AudioFxGroup carve across tracks", () => {
]);
});
});
/**
* The filters and envelopes a carve produces are a MEASUREMENT of specific
* tracks. Delete one and they describe something nobody can hear any more — the
* bed keeps ducking for a voice that is gone.
*/
describe("AudioFxGroup carve against a deleted voice", () => {
const CARVED_CHAIN = JSON.stringify({
version: 1,
nodes: [
{ type: "peaking", id: "c1", enabled: true, fromCarve: true, params: { frequency: 1000 } },
{ type: "lowpass", id: "k1", enabled: true, params: { frequency: 8000 } },
],
});
const CARVED_AUTOMATION = JSON.stringify({
version: 1,
lanes: [
{ target: "fx.c1.gain", points: [{ t: 0, v: -6 }] },
{ target: "fx.k1.frequency", points: [{ t: 0, v: 8000 }] },
],
});
/**
* A bed carving against `sources`, with only `present` still in the composition.
*
* The timeline is what says a track is gone — not the preview DOM, which keeps
* a deleted element around — so the store is seeded and the document is left
* holding every track, which is exactly the mismatch the studio produces.
*/
function mountCarved(sources: string[], present: string[]) {
const carve = JSON.stringify({ enabled: true, sources, strength: 0.25 });
const bed = document.createElement("audio");
bed.id = "bed";
document.body.append(bed);
for (const id of new Set([...sources, ...present])) {
const el = document.createElement("audio");
el.id = id;
document.body.append(el);
}
usePlayerStore.setState({
elements: [
{ id: "bed", tag: "audio", start: 0, duration: 10, track: 0 },
...present.map((id) => ({ id, tag: "audio", start: 0, duration: 10, track: 1 })),
] as never,
});
const onSetAttributeQuiet = vi.fn();
const host = document.createElement("div");
document.body.append(host);
act(() => {
createRoot(host).render(
<AudioFxGroup
element={
{
dataAttributes: {
"fx-carve": carve,
"fx-chain": CARVED_CHAIN,
automation: CARVED_AUTOMATION,
},
id: "bed",
element: bed,
} as unknown as DomEditSelection
}
onSetAttributeQuiet={onSetAttributeQuiet}
onSetAttributeLive={vi.fn()}
/>,
);
});
return { host, onSetAttributeQuiet };
}
it("re-analyses against the voices that are left", () => {
// Two voices were measured together into one set of bands. With one gone that
// set answers a question nobody asked; the survivor has to be measured again.
const { onSetAttributeQuiet } = mountCarved(["narration", "guest"], ["narration"]);
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
expect(JSON.parse(String(write![1]))).toMatchObject({
enabled: true,
sources: ["narration"],
});
});
it("leaves a carve alone while every voice it names is still there", () => {
const { onSetAttributeQuiet } = mountCarved(["narration", "guest"], ["narration", "guest"]);
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
});
it("does not fall back to carving against an effect when no voice is left", async () => {
// Found in the studio, not here: deleting the narration emptied the source
// list, and the panel filled it again with the only audio in the composition
// — a 200 ms explosion. The picker's fallback (offer everything rather than
// hide the track somebody needs) is for the AUTHOR to choose from. The panel
// choosing off it is the panel deciding, and that is never the answer.
const carve = JSON.stringify({ enabled: true, sources: [], strength: 0.25 });
const bed = document.createElement("audio");
bed.id = "bed";
bed.setAttribute("data-fx-carve", carve);
document.body.append(bed);
const sfx = document.createElement("audio");
sfx.id = "sfx-explosion";
document.body.append(sfx);
const onSetAttributeQuiet = vi.fn();
const host = document.createElement("div");
document.body.append(host);
act(() => {
createRoot(host).render(
<AudioFxGroup
element={
{
dataAttributes: { "fx-carve": carve },
id: "bed",
element: bed,
} as unknown as DomEditSelection
}
onSetAttributeQuiet={onSetAttributeQuiet}
onSetAttributeLive={vi.fn()}
/>,
);
});
await act(async () => {});
// Nothing written: the carve waits rather than picking the explosion.
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
// Still offered, so the author can say "actually, listen to that one".
expect(
Array.from(host.querySelectorAll("[data-carve-source]")).map((e) =>
e.getAttribute("data-carve-source"),
),
).toEqual(["sfx-explosion"]);
});
it("drops what it generated when the last voice goes and none is left to pick", async () => {
// Staying on with nothing to listen to is honest — a voice may come back, and
// "off" is a different thing the author chose. What cannot stay is the output:
// those filters and that envelope are making room for nobody.
const { onSetAttributeQuiet } = mountCarved(["narration"], []);
// The three writes are sequenced, not fired together: each is a
// read-modify-write against the same file, so the carve write lands only
// after the two that strip its output.
await act(async () => {});
const carve = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-carve");
expect(JSON.parse(String(carve![1]))).toMatchObject({ enabled: true, sources: [] });
const chain = writeTo(onSetAttributeQuiet.mock.calls, "data-fx-chain");
// The hand-added low-pass survives; only what the carve minted goes.
expect(JSON.parse(String(chain![1])).nodes.map((n: { id: string }) => n.id)).toEqual(["k1"]);
const automation = writeTo(onSetAttributeQuiet.mock.calls, "data-automation");
expect(
JSON.parse(String(automation![1])).lanes.map((l: { target: string }) => l.target),
).toEqual(["fx.k1.frequency"]);
});
});
@@ -48,6 +48,7 @@ import {
} from "./propertyPanelAutomation";
import type { DomEditSelection } from "./domEditingTypes";
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
import { usePlayerStore } from "../../player";
/**
* Rate the carve source is decoded at. Analysis is self-consistent because it
@@ -199,10 +200,12 @@ export function AudioFxGroup({
* commit, which does not exist yet.
*/
const setCarve = async (next: HfCarveSettings | null): Promise<void> => {
// Envelopes the carve wrote outlive it otherwise, and an automated gain
// ignores the panel's own depth — so switching dynamic off would leave the
// filters still following the voice with nothing saying they do.
if (!next?.enabled) {
// What the carve generated is only justified by the voices it was measured
// from: switched off, or left naming none — every source deleted, say —
// there is nothing those filters are making room for. Left behind they keep
// dipping the bed with nothing in the panel to explain them.
const generatedOutputStands = Boolean(next?.enabled) && (next?.sources.length ?? 0) > 0;
if (!generatedOutputStands) {
const carriedOver = withoutCarveLanes(automation, chain);
if (carriedOver.lanes.length !== automation.lanes.length) {
await onSetAttributeQuiet(
@@ -211,7 +214,7 @@ export function AudioFxGroup({
);
}
}
if (!next?.enabled) {
if (!generatedOutputStands) {
const kept = chain.nodes.filter((n) => !n.fromCarve);
if (kept.length !== chain.nodes.length) {
await onSetAttributeQuiet(
@@ -300,9 +303,12 @@ export function AudioFxGroup({
* first, and if filtering would leave nothing at all every track comes back. A
* picker that hides the track somebody needs is worse than a long one.
*/
const sourceOptions: AudioTrackOption[] = (() => {
const { sourceOptions, autoSourceIds } = ((): {
sourceOptions: AudioTrackOption[];
autoSourceIds: string[];
} => {
const doc = element.element?.ownerDocument;
if (!doc) return [];
if (!doc) return { sourceOptions: [], autoSourceIds: [] };
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
(a) => a.id !== element.id,
);
@@ -324,11 +330,75 @@ export function AudioFxGroup({
}));
const plausible = described.filter((t) => t.kind === "voice" || t.kind === "unknown");
const offered = plausible.length > 0 ? plausible : described;
return offered
.sort((a, b) => (a.kind === "voice" ? 0 : 1) - (b.kind === "voice" ? 0 : 1))
.map(({ id, label }) => ({ id, label }));
const byVoiceFirst = (list: typeof described) =>
[...list].sort((a, b) => (a.kind === "voice" ? 0 : 1) - (b.kind === "voice" ? 0 : 1));
return {
sourceOptions: byVoiceFirst(offered).map(({ id, label }) => ({ id, label })),
// What the panel may pick WITHOUT being asked — never the fallback. The
// fallback exists so the picker can still show a track whose name reads as
// music or as an effect, because a name is a hint and the author may know
// better. Choosing off that list is a different act: it is the panel
// deciding, and "the only audio left is a 200 ms explosion" is not a voice
// to make room for. A bed surrounded by nothing plausible waits instead.
autoSourceIds: byVoiceFirst(plausible).map((t) => t.id),
};
})();
/**
* The voices this carve names that are still in the composition.
*
* Existence, not the candidate list: a voice can stop being offered without
* being gone (it stopped overlapping the bed), and dropping it then would
* quietly rewrite a relationship the author set. Deleted is the case that has
* to be noticed, because what the carve produced was measured from that track.
*
* Asked of the timeline rather than of `element.element.ownerDocument`, which
* is the preview's DOM and outlives a delete: measured in the studio, a bed
* selected right after its voice was deleted still found that voice through
* the document, so the carve sat on a measurement of a track the timeline had
* already dropped. The store is what the delete actually edited.
*/
const timelineElements = usePlayerStore((s) => s.elements);
const survivingSources = ((): string[] => {
if (!carve) return [];
const present = new Set(timelineElements.map((el) => el.domId ?? el.id));
// Absence only means deletion once the timeline is known to describe THIS
// composition, and the bed being in it is the proof. Without that check a
// store that is empty — not loaded yet, or a panel mounted outside the
// player — reads as "every voice was deleted" and throws away a carve that
// is perfectly fine. Unchanged sources are what the prune treats as nothing
// to do.
if (!element.id || !present.has(element.id)) return carve.sources;
return carve.sources.filter((id) => present.has(id));
})();
/**
* A deleted voice re-analyses the bed.
*
* The filters and envelopes are a measurement of specific tracks, so losing one
* makes them a measurement of something that is no longer there the bed keeps
* ducking for a voice nobody can hear. `analyse` already skips a source it
* cannot find, but nothing asked it to run again.
*
* Pruning is the whole trigger: `setCarve` re-analyses when the source list
* changes, so the surviving voices are re-measured together. Losing the LAST
* one leaves an empty list, which the effects below repoint at whatever
* candidates remain and if there are none, `setCarve` drops what the carve
* generated, since there is nothing left it could be making room for.
*
* Keyed on the survivors rather than on the candidates: a voice that had
* stopped overlapping was never in the candidate list, so its deletion would
* not change that identity and this would never fire.
*/
useEffect(() => {
if (carvedAgainstBy || !carve?.enabled) return;
if (survivingSources.length === carve.sources.length) return;
void setCarve({ ...carve, sources: survivingSources });
// Keyed on the identity of the decision, not on setCarve — which is rebuilt
// every render and would re-fire this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [carve, carvedAgainstBy, survivingSources.join(" ")]);
/**
* A bed with voices above it carves itself.
*
@@ -346,10 +416,10 @@ export function AudioFxGroup({
* off stores `enabled: false`, which is also a configured carve. That is the whole
* reason the flag exists rather than "off" being an absent attribute.
*/
const candidateIds = sourceOptions.map((o) => o.id).join("\u0000");
const candidateIds = autoSourceIds.join("\u0000");
useEffect(() => {
if (carvedAgainstBy || sourceOptions.length === 0) return;
const all = sourceOptions.map((o) => o.id);
if (carvedAgainstBy || autoSourceIds.length === 0) return;
const all = autoSourceIds;
// Nothing configured: the default carve, pointed at everything it could hear.
if (carve === null) {
void setCarve({ ...DEFAULT_CARVE, sources: all });
@@ -383,24 +453,23 @@ export function AudioFxGroup({
* reason the flag exists rather than "off" being an absent attribute.
*/
useEffect(() => {
if (carvedAgainstBy || sourceOptions.length !== 1) return;
const only = sourceOptions[0];
if (carvedAgainstBy || autoSourceIds.length !== 1) return;
const only = autoSourceIds[0];
if (!only) return;
// Nothing configured: the default carve, pointed at the one candidate.
if (carve === null) {
void setCarve({ ...DEFAULT_CARVE, sources: [only.id] });
void setCarve({ ...DEFAULT_CARVE, sources: [only] });
return;
}
// Configured but with no voice yet — a carve switched on before there was
// anything to listen to, or one whose source was cleared. The panel reads the
// sole candidate out as the source, so it has to be the stored one too;
// otherwise the card claims a relationship the attribute does not record.
if (carve.enabled && carve.sources.length === 0)
void setCarve({ ...carve, sources: [only.id] });
if (carve.enabled && carve.sources.length === 0) void setCarve({ ...carve, sources: [only] });
// Deliberately keyed on the identity of the decision, not on setCarve — which
// is rebuilt every render and would re-fire this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [carve, carvedAgainstBy, sourceOptions.length, sourceOptions[0]?.id]);
}, [carve, carvedAgainstBy, autoSourceIds.length, autoSourceIds[0]]);
const [analysing, setAnalysing] = useState(false);