fix(studio,lint,skills): a bus is never a carve bed

A music bed came out carved twice. The `music` bus carried three
`fromCarve` peaking filters against `voiceover`, and its one member clip
`bgm` carried six of its own — the bed ran through both. Same on the SFX
side: the `sfx` bus plus `sfx-pan-2`.

**The bus classified as a bed.** `useFxCarve` is shared — a group's rack
reaches it too (`propertyPanelAudioFxGroup.tsx`, and the comment there
says so) — and `carveBedRoles` reads `data-label` so that a name living
outside a filename still counts. A bus labelled "Music bed" therefore
read as music: `couldBeBed` offered it the control and `autoBed` wrote
one without being asked, entirely independently of the member clip that
had already done the same. Nothing caught the collision, because the only
guard, `carverAgainst`, asks "is somebody naming ME as a source" — never
"is my own bus, or my own member, already carved".

**And a bus cannot carve properly anyway.** The level half of the
analysis measures the bed's own audio against the voice, read from
`element.getAttribute("src")`. A bus has no `src`, so `bedBuffer` was
null, `duck` was empty, and every bus carve was the spectral half alone:
filters and no level match. That is exactly the shape found in the file —
three peaking nodes and no gain node on the bus, five and a gain on the
clip.

So `carveBedRoles` now refuses a bus outright, both halves: `couldBeBed`
false alone would still leave `autoBed` free to fire off the label, which
is the half that wrote these. An already-configured bus carve still shows
its module (`carve !== null`), so it can be switched off rather than
becoming unreachable — the same escape hatch a38785003 kept.

`audio_group_carve_attr` names what is already written down, since the
Studio fix cannot unwrite it. Warning, beside `audio_group_timing_attrs`,
which is the same shape of mistake: an attribute a bus has no use for.

The skill has said "a carve stays on the clip" since the bus was
documented; it now says why, and says not to wrap a single clip in a bus
at all — with the one real exception, which is wanting a
composition-time automation clock.

Both guards mutation-checked: dropping the tag check fails the Studio
test, and `if (false)` in place of the attribute check fails both quiet
cases.
This commit is contained in:
Vance Ingalls
2026-08-23 01:57:45 -07:00
parent 952401d12a
commit 5dc39a84e9
6 changed files with 150 additions and 9 deletions
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { carverAgainst, collectCarveCandidates } from "./useFxCarveGrouping";
import { carveBedRoles, carverAgainst, collectCarveCandidates } from "./useFxCarveGrouping";
function previewDoc(html: string): Document {
const doc = document.implementation.createHTMLDocument("preview");
@@ -82,3 +82,37 @@ describe("carverAgainst", () => {
expect(carverAgainst(doc, "vo-1")).toBeNull();
});
});
describe("carveBedRoles", () => {
const roles = (html: string, id: string) => {
const doc = previewDoc(html);
return carveBedRoles(id, doc.getElementById(id));
};
// The observed bug: the bus labelled "Music bed" classified as music, so it
// auto-carved against the same voiceover its own member clip had already
// carved against — the bed ran through both chains. A bus is never a bed, and
// `autoBed` matters as much as `couldBeBed`: that is the half that wrote one
// without being asked.
it("never makes a bus a bed, however it is labelled", () => {
expect(
roles(`<hf-audio-group id="music" data-label="Music bed"></hf-audio-group>`, "music"),
).toEqual({ couldBeBed: false, autoBed: false });
expect(
roles(`<hf-audio-group id="sfx" data-label="Sound FX"></hf-audio-group>`, "sfx"),
).toEqual({ couldBeBed: false, autoBed: false });
});
it("still reads a clip's label, id and src", () => {
expect(roles(`<audio id="a1" data-label="Music bed"></audio>`, "a1")).toEqual({
couldBeBed: true,
autoBed: true,
});
// A name that says nothing may be offered the control but never carves itself.
expect(roles(`<audio id="a1"></audio>`, "a1")).toEqual({ couldBeBed: true, autoBed: false });
expect(roles(`<audio id="vo-2"></audio>`, "vo-2")).toEqual({
couldBeBed: false,
autoBed: false,
});
});
});
@@ -15,7 +15,11 @@ import {
isNamedCarveBed,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
import { resolveAudioGroups, resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
import {
HF_AUDIO_GROUP_TAG,
resolveAudioGroups,
resolveCarveSourceIds,
} from "@hyperframes/core/audio-groups";
/**
* An id for a new voiceover group, de-duped against every id already in the
@@ -189,13 +193,33 @@ export function collectCarveCandidates(
* 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.
* Reads the element's `data-label` as well as its id and `src`, because a clip's
* display name is a hint its filename may not carry. A BUS is excluded outright
* — see the first branch.
*/
export function carveBedRoles(
id: string | null | undefined,
node: Element | null | undefined,
): { couldBeBed: boolean; autoBed: boolean } {
// A BUS is never a bed, whatever it is called. Its rack reaches `useFxCarve`
// too, and reading `data-label` — which is what lets a group be classified at
// all — made a bus labelled "Music bed" read as music: it then auto-carved
// against the same voice its own member clip had already auto-carved against,
// and the bed ran through both sets of filters. Nothing caught that, because
// the only guard (`carverAgainst`) asks "is somebody naming ME as a source",
// never "is my own bus, or my own member, already carved".
//
// A bus could not do the whole job anyway: the level half of the carve reads
// the bed's own `src` to measure how far over the voice it sits, and a bus has
// no `src` — so a bus carve was always the spectral half alone, filters with
// no level match. `data-fx-carve` is a clip attribute; the skill has said so
// ("A carve stays on the clip") since the bus was documented.
//
// Not `couldBeBed: false` alone: that leaves `autoBed` free to fire from a
// name, which is the half that wrote these unasked.
if (node?.tagName?.toLowerCase() === HF_AUDIO_GROUP_TAG) {
return { couldBeBed: false, autoBed: false };
}
const parts = [id, node?.getAttribute("src"), node?.getAttribute("data-label")];
return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) };
}