mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user