fix(core,studio): a voice track is never a carve bed, and never carves its own group

A narration clip inside a Voiceover group had a carve pointed at that
group — a member ducking the bus it feeds. Three faults, each sufficient
on its own.

**No bed-eligibility rule.** `couldBeCarveSource` has said since it was
written that music and sfx cannot be sources, and it is called from
nowhere — exported, tested, dead. Nothing ever asked the near-end
question: can this track be the BED. `showCarve` only asked "is anything
already carving against me, and is there anything to listen to", so a
voice track was offered the control like any other. Added
`couldBeCarveBed` beside its sibling and wired it in.

**Offering is not applying.** A bed with exactly one candidate carves
itself unasked, which is right for a track named `music-bed` and wrong
for one named `a1` — a decision taken off a name that said nothing is how
a carve appears that nobody remembers configuring. `isNamedCarveBed`
gates self-application on a name that positively reads as a bed; the
picker stays looser, the same split the source side already makes between
`sourceOptions` and `autoSourceIds`.

**A bed was offered its own group.** The candidate scan excluded exactly
one element, the bed itself. Its siblings survived that filter and rolled
up into the very group the bed belongs to, which came back as a
candidate — and being the only one, was applied. The mirror case too: a
group bed's id matches no <audio> id, so nothing stopped a group carving
against itself. `collectCarveCandidates` now takes the bed's id and drops
both it and its group.

An existing carve still shows its module (`carve !== null`), so nothing
already configured becomes unreachable — only newly offered and
self-applied ones are refused.

The bed/relationship predicates moved to `useFxCarveGrouping.ts`, next to
the source-eligibility rules they belong with. That is also what puts
`useFxCarve.ts` back under the 600-line ceiling it crossed here.

Five tests, each mutation-checked against the pre-fix code. Verified live:
selecting `vo-2` renders no carve module; `music-bed` still gets one,
listening to `Voiceover (4)`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:15 -07:00
co-authored by Claude Opus 5
parent f26f48b3a6
commit a387850032
6 changed files with 273 additions and 37 deletions
+26
View File
@@ -10,6 +10,8 @@ import {
clipsOverlap,
mixCarveSources,
couldBeCarveSource,
couldBeCarveBed,
isNamedCarveBed,
DEFAULT_CARVE,
normalizeCarveSettings,
} from "./audioCarve.js";
@@ -538,6 +540,30 @@ describe("classifyAudioName", () => {
expect(couldBeCarveSource("sfx-explosion")).toBe(false);
});
// The near-end rule, which nothing used to ask. `couldBeCarveSource` shipped
// with its own doc comment ("music and sfx are out") and no caller; the bed
// side had no predicate at all, so a narration clip was offered the carve and
// — finding one candidate — had one applied for it, against the group it was
// a member of.
it("never offers a voice track as the bed, but keeps an unnamed one eligible", () => {
expect(couldBeCarveBed("music-bed")).toBe(true);
expect(couldBeCarveBed("sfx-riser")).toBe(true);
expect(couldBeCarveBed("a1")).toBe(true);
expect(couldBeCarveBed("vo-2")).toBe(false);
expect(couldBeCarveBed("voiceover")).toBe(false);
expect(couldBeCarveBed("narration-3")).toBe(false);
});
// Showing the control is a suggestion; writing the attribute is a decision.
// A decision taken off a name that said nothing is how a carve appears that
// nobody remembers configuring — so `a1` may be offered but never chosen.
it("only self-applies to a name that positively reads as a bed", () => {
expect(isNamedCarveBed("music-bed")).toBe(true);
expect(isNamedCarveBed("sfx-riser")).toBe(true);
expect(isNamedCarveBed("a1")).toBe(false);
expect(isNamedCarveBed("vo-2")).toBe(false);
});
it("treats underscores as separators, not word characters, for short hints", () => {
// `\b` treats `_` as a word character, so `\bbed\b` used to miss `bed_01` —
// an underscore-separated bed classified as "unknown" and could end up
+34
View File
@@ -188,6 +188,40 @@ export function couldBeCarveSource(...parts: readonly (string | null | undefined
return kind === "voice" || kind === "unknown";
}
/**
* Could this track be the BED a carve is written onto?
*
* The other half of `couldBeCarveSource`, and the half nothing used to ask. A
* carve makes room in a bed for a voice; a voice track has no room to make for
* itself, and offering it the control is offering a track to duck against its
* own kind. Observed: a narration clip in a Voiceover group carved against that
* group — a member ducking the bus it feeds.
*
* Loose in the same direction as its sibling: a name that says nothing stays
* eligible, because a name is a hint and an author may know better. Only a name
* that positively reads as speech is refused.
*/
export function couldBeCarveBed(...parts: readonly (string | null | undefined)[]): boolean {
return classifyAudioName(...parts) !== "voice";
}
/**
* Does this track's name positively say "bed"?
*
* Stricter than `couldBeCarveBed`, for the one act the author did not ask for:
* applying a carve on their behalf. Offering the control on a track named `a1`
* is a suggestion they can ignore; writing `data-fx-carve` onto it is a decision,
* and a decision taken off a name that said nothing is how a carve appears that
* nobody remembers configuring.
*
* The same split the source side already makes between what the picker may show
* and what `autoSourceIds` may choose unprompted.
*/
export function isNamedCarveBed(...parts: readonly (string | null | undefined)[]): boolean {
const kind = classifyAudioName(...parts);
return kind === "music" || kind === "sfx";
}
export const DEFAULT_CARVE: HfCarveSettings = {
enabled: true,
sources: [],
@@ -1341,6 +1341,62 @@ describe("AudioFxGroup carve by default", () => {
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
});
/**
* Mount a track of the test's own naming as the selection, with siblings.
*
* `mount` always calls the selected track `bed`, which is what let the near-end
* hole go unnoticed: nothing ever selected a track whose NAME said voice.
*/
const mountNamed = (id: string, siblings: string[]) => {
const onSetAttributeQuiet = vi.fn();
const selected = document.createElement("audio");
selected.id = id;
document.body.append(selected);
for (const other of siblings) {
const el = document.createElement("audio");
el.id = other;
document.body.append(el);
}
const host = document.createElement("div");
document.body.append(host);
act(() => {
createRoot(host).render(
<AudioFxGroup
element={
{
dataAttributes: { "fx-chain": "" },
id,
element: selected,
} as unknown as DomEditSelection
}
onSetAttributeQuiet={onSetAttributeQuiet}
onSetAttributeLive={vi.fn()}
/>,
);
});
return { host, onSetAttributeQuiet };
};
// The reported bug. A carve makes room in a bed for a voice, so a voice track
// is the one thing that can never be the bed — `couldBeCarveSource` has said
// as much since it was written, and nothing called it. Selecting a narration
// clip offered it the module, found one candidate, and applied a carve nobody
// asked for.
it("never offers the carve on a track whose name says voice", () => {
const { host, onSetAttributeQuiet } = mountNamed("vo-2", ["music-bed"]);
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
});
// Offering is a suggestion, applying is a decision. An unnamed track keeps the
// module — the author may know better than the name does — but nothing is
// written until they say so.
it("offers but does not apply on a track whose name says nothing", () => {
const { host, onSetAttributeQuiet } = mountNamed("a1", ["vo-1"]);
expect(host.querySelector(".hf-fx-carve-module")).not.toBeNull();
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
});
});
describe("AudioFxGroup carve source list", () => {
@@ -26,11 +26,12 @@ import {
DEFAULT_CARVE,
mixCarveSources,
HF_AUDIO_CARVE_ATTR,
normalizeCarveSettings,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
import { resolveAudioGroups, resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
import {
carveBedRoles,
carverAgainst,
collectCarveCandidates,
CARVE_ABORTED,
isPromiseLike,
@@ -54,17 +55,6 @@ import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
*/
const DECODE_SAMPLE_RATE = 48000;
/** Whether some element's own carve attribute names `targetId` as a source. */
function carvesAgainst(other: HTMLElement, targetId: string): boolean {
try {
const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
return Boolean(raw && normalizeCarveSettings(JSON.parse(raw)).sources.includes(targetId));
} catch {
// An unreadable carve on some other element says nothing about this one.
return false;
}
}
/**
* Which carve setting actually moved, by comparing the two snapshots.
*
@@ -304,24 +294,10 @@ export function useFxCarve(
*/
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>,
) {
/**
* Is some other track carving against this one?
*
* A carve is a relationship a bed is carved against a voice and the voice is
* the far end of it. Offering the same control there offers to carve a track
* against itself by proxy, and switching it on left a setting with no source it
* could legally name. Read off the other elements' own carve attributes, because
* that is where the relationship is recorded.
*/
const carvedAgainstBy = ((): string | null => {
const doc = element.element?.ownerDocument;
if (!doc || !element.id) return null;
const others = Array.from(doc.querySelectorAll<HTMLElement>(`[${HF_AUDIO_CARVE_ATTR}]`));
const carver = others.find(
(other) => other.id !== element.id && carvesAgainst(other, element.id ?? ""),
);
return carver ? carver.id || "another track" : null;
})();
const carvedAgainstBy = carverAgainst(element.element?.ownerDocument, element.id);
// Can this be a bed at all, and may one be applied unasked. See carveBedRoles.
const { couldBeBed, autoBed } = carveBedRoles(element.id, element.element);
/**
* The tracks worth offering as the voice.
@@ -336,13 +312,17 @@ export function useFxCarve(
* safe direction: a name that says nothing stays in, voice-shaped names sort
* 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.
*
* Empty on a track that cannot be a bed at all, which is what withholds the
* whole module: `showCarve` already asks "is there anything to carve against",
* so the near-end rule rides that rather than a second flag to thread.
*/
const { sourceOptions, autoSourceIds } = ((): {
sourceOptions: AudioTrackOption[];
autoSourceIds: string[];
} => {
const doc = element.element?.ownerDocument;
if (!doc) return { sourceOptions: [], autoSourceIds: [] };
if (!doc || !couldBeBed) return { sourceOptions: [], autoSourceIds: [] };
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
(a) => a.id !== element.id,
);
@@ -353,7 +333,7 @@ export function useFxCarve(
const overlapsBed = (a: Element): boolean =>
clipsOverlap(bedSpan, spanOf(a.getAttribute("data-start"), a.getAttribute("data-duration")));
const described = collectCarveCandidates(doc, others, overlapsBed);
const described = collectCarveCandidates(doc, others, overlapsBed, element.id ?? undefined);
const plausible = described.filter((t) => t.kind === "voice" || t.kind === "unknown");
const offered = plausible.length > 0 ? plausible : described;
const byVoiceFirst = (list: typeof described) =>
@@ -543,7 +523,7 @@ export function useFxCarve(
// both guards passing for a single candidate fired two setCarve calls with
// the same result — two decodes, two FFT runs, two concurrent attribute
// writes.
if (carvedAgainstBy || autoSourceIds.length <= 1) return;
if (carvedAgainstBy || !autoBed || autoSourceIds.length <= 1) return;
const all = autoSourceIds;
// Nothing configured: the default carve, pointed at everything it could hear.
if (carve === null) {
@@ -557,7 +537,7 @@ export function useFxCarve(
// 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, candidateIds]);
}, [carve, carvedAgainstBy, autoBed, candidateIds]);
/**
* A bed with one obvious voice above it carves itself.
@@ -578,7 +558,7 @@ export function useFxCarve(
* reason the flag exists rather than "off" being an absent attribute.
*/
useEffect(() => {
if (carvedAgainstBy || autoSourceIds.length !== 1) return;
if (carvedAgainstBy || !autoBed || autoSourceIds.length !== 1) return;
const only = autoSourceIds[0];
if (!only) return;
// Nothing configured: the default carve, pointed at the one candidate.
@@ -594,7 +574,7 @@ export function useFxCarve(
// 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, autoSourceIds.length, autoSourceIds[0]]);
}, [carve, carvedAgainstBy, autoBed, autoSourceIds.length, autoSourceIds[0]]);
return { carvedAgainstBy, sourceOptions, setCarve };
}
@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { collectCarveCandidates } from "./useFxCarveGrouping";
function previewDoc(html: string): Document {
const doc = document.implementation.createHTMLDocument("preview");
doc.body.innerHTML = html;
return doc;
}
/** What the panel would offer as sources for `bedId`, the way useFxCarve asks. */
function candidatesFor(doc: Document, bedId: string) {
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
(a) => a.id !== bedId,
);
return collectCarveCandidates(doc, others, () => true, bedId).map((c) => c.id);
}
const GROUPED_VOICES = `
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
<audio id="vo-1" data-audio-group="voiceover"></audio>
<audio id="vo-2" data-audio-group="voiceover"></audio>
<audio id="music-bed"></audio>
`;
describe("collectCarveCandidates", () => {
// The observed bug: selecting vo-2 offered "Voiceover (2)" — the group vo-2 is
// itself a member of. The caller filters out the bed element, but vo-1 survives
// that filter and rolls up into exactly that group. Being the only candidate, it
// was then applied without the author asking: a member ducking the bus it feeds.
it("never offers a member the group it belongs to", () => {
expect(candidatesFor(previewDoc(GROUPED_VOICES), "vo-2")).toEqual(["music-bed"]);
});
// And the mirror: a group bed's own id matches no <audio> id, so nothing
// excluded it. Its members rolled up and handed the group back to itself.
it("never offers a group itself", () => {
const doc = previewDoc(GROUPED_VOICES);
expect(candidatesFor(doc, "voiceover")).toEqual(["music-bed"]);
});
it("still offers a group the bed has nothing to do with", () => {
const doc = previewDoc(`
${GROUPED_VOICES}
<hf-audio-group id="sfx" data-label="SFX"></hf-audio-group>
<audio id="sfx-click" data-audio-group="sfx"></audio>
`);
expect(candidatesFor(doc, "music-bed")).toEqual(["voiceover", "sfx"]);
});
it("leaves an ungrouped bed's candidates alone", () => {
const doc = previewDoc(`<audio id="music-bed"></audio><audio id="vo-1"></audio>`);
expect(candidatesFor(doc, "music-bed")).toEqual(["vo-1"]);
});
});
@@ -7,7 +7,14 @@
* `propertyPanelAudioFxGroup.tsx`.
*/
import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
import {
classifyAudioName,
HF_AUDIO_CARVE_ATTR,
normalizeCarveSettings,
couldBeCarveBed,
isNamedCarveBed,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
/**
@@ -101,18 +108,38 @@ export interface CarveCandidate {
* the whole timeline is relevant even if each of its segments only overlaps
* part of the bed). Grouped members never appear individually.
*/
/**
* The ids a carve may never name, for a bed of this id.
*
* A carve names what the bed ducks UNDER, so the bed can never be on that
* list directly or through the bus it plays into. The caller filters the bed
* element out of `others`, but that only removes the bed itself: its siblings
* survive and roll up into the very group the bed is a member of, which came
* back as a candidate and, being the only one, was applied unprompted. And a
* group bed's own id never matches any <audio> id at all, so nothing stopped a
* group carving against itself.
*/
function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string): Set<string> {
const selfGroupId = bedId ? groupByMember.get(bedId)?.id : undefined;
return new Set([bedId, selfGroupId].filter((id): id is string => !!id));
}
export function collectCarveCandidates(
doc: Document,
others: readonly HTMLAudioElement[],
overlapsBed: (a: Element) => boolean,
/** The bed's own id, so neither it nor the group it belongs to is offered. */
bedId?: string,
): CarveCandidate[] {
const groupByMember = new Map(
resolveAudioGroups(doc).flatMap((group) => group.memberIds.map((id) => [id, group] as const)),
);
const excluded = excludedFor(groupByMember, bedId);
const offeredGroupIds = new Set<string>();
const described: CarveCandidate[] = [];
for (const a of others) {
const group = groupByMember.get(a.id);
if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
if (!group) {
if (overlapsBed(a)) {
described.push({
@@ -144,3 +171,61 @@ export function collectCarveCandidates(
}
return described;
}
/**
* The two near-end questions about a track, asked of its name.
*
* `couldBeBed` may a carve be written onto this at all? A carve makes room in
* a bed for a voice, so a voice track is the one thing that can never be the
* bed. The far-end rule (`couldBeCarveSource` music and sfx are out) has
* existed since it was written and had no caller; this is the half nothing
* asked, and without it a narration clip was offered the control and, finding
* exactly one candidate, had a carve applied against the group it belonged to.
*
* `autoBed` may one be applied WITHOUT the author asking? Stricter. Showing
* the module on a track named `a1` is a suggestion; writing `data-fx-carve`
* onto it is a decision, and a decision taken off a name that said nothing is
* how a carve turns up that nobody remembers configuring. The same split the
* source side already makes between what the picker may show (`sourceOptions`)
* and what it may choose unprompted (`autoSourceIds`).
*
* Reads the element's `data-label` as well as its id and `src`: a group carries
* its name there rather than in a filename, and a group can be a bed.
*/
export function carveBedRoles(
id: string | null | undefined,
node: Element | null | undefined,
): { couldBeBed: boolean; autoBed: boolean } {
const parts = [id, node?.getAttribute("src"), node?.getAttribute("data-label")];
return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) };
}
/** Whether some element's own carve attribute names `targetId` as a source. */
function carvesAgainst(other: HTMLElement, targetId: string): boolean {
try {
const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
return Boolean(raw && normalizeCarveSettings(JSON.parse(raw)).sources.includes(targetId));
} catch {
// An unreadable carve on some other element says nothing about this one.
return false;
}
}
/**
* Is some other track carving against this one, and which?
*
* A carve is a relationship a bed is carved against a voice and the voice is
* the far end of it. Offering the same control there offers to carve a track
* against itself by proxy, and switching it on left a setting with no source it
* could legally name. Read off the other elements' own carve attributes, because
* that is where the relationship is recorded.
*/
export function carverAgainst(
doc: Document | undefined,
id: string | null | undefined,
): string | null {
if (!doc || !id) return null;
const others = Array.from(doc.querySelectorAll<HTMLElement>(`[${HF_AUDIO_CARVE_ATTR}]`));
const carver = others.find((other) => other.id !== id && carvesAgainst(other, id));
return carver ? carver.id || "another track" : null;
}