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
+37
View File
@@ -651,6 +651,43 @@ describe("audio_group_timing_attrs", () => {
});
});
describe("audio_group_carve_attr", () => {
const doc = (busAttrs: string) => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
<hf-audio-group id="music" data-label="Music bed" ${busAttrs}></hf-audio-group>
<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="10" data-audio-group="music"></audio>
</div>
</body></html>`;
// The observed bug: the bus and its one member each carried a carve against
// the same voiceover, so the bed ran through both sets of filters.
it("warns on a carve written onto a bus", async () => {
const res = await lintHyperframeHtml(
doc(`data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'`),
);
const finding = res.findings.find((f) => f.code === "audio_group_carve_attr");
expect(finding?.severity).toBe("warning");
expect(finding?.elementId).toBe("music");
expect(finding?.message).toContain("data-fx-carve");
});
it("stays quiet on a bus carrying only its own attributes", async () => {
const res = await lintHyperframeHtml(doc(`data-volume="0.4"`));
expect(res.findings.some((f) => f.code === "audio_group_carve_attr")).toBe(false);
});
it("leaves a carve on the clip alone", async () => {
const res = await lintHyperframeHtml(`<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
<hf-audio-group id="music" data-label="Music bed"></hf-audio-group>
<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="10" data-audio-group="music"
data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'></audio>
</div>
</body></html>`);
expect(res.findings.some((f) => f.code === "audio_group_carve_attr")).toBe(false);
});
});
describe("audio_carve_ungrouped_sources", () => {
const withCarve = (carveJson: string, extra = "") => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
+35
View File
@@ -640,6 +640,9 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
// audio_group_timing_attrs
findAudioGroupTimingAttrFindings,
// audio_group_carve_attr
findAudioGroupCarveAttrFindings,
];
/**
@@ -866,3 +869,35 @@ function findAudioGroupTimingAttrFindings(ctx: LintContext): HyperframeLintFindi
}
return findings;
}
/**
* A carve on a bus is half an effect, applied twice.
*
* `data-fx-carve` is a CLIP attribute. The bed being carved is one track, and
* the level half of the analysis measures that track's own audio against the
* voice — a bus has no `src`, so a carve there can only ever produce the
* spectral half: filters with no level match.
*
* Worse, it stacks. A bus and a member clip are the same signal path, so a
* carve on each puts the bed through both sets of filters — which is exactly
* what happened when a bus labelled "Music bed" classified as one and carved
* itself (fixed in Studio; this catches what was already written down).
*/
function findAudioGroupCarveAttrFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
for (const tag of ctx.tags) {
if (tag.name !== "hf-audio-group") continue;
if (!hasAttrName(tag.raw, "data-fx-carve")) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "audio_group_carve_attr",
severity: "warning",
message: `${elementId ? `#${elementId}` : "This audio group"} carries \`data-fx-carve\`, which belongs on the clip being carved — a bus has no audio of its own to level-match against, and a carve here stacks with any its members already have.`,
elementId,
fixHint:
"Remove `data-fx-carve` and the `fromCarve` nodes it wrote into this bus's `data-fx-chain`, and carve the bed clip instead.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}
@@ -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) };
}
+1 -1
View File
@@ -26,7 +26,7 @@
"files": 121
},
"hyperframes-audio": {
"hash": "73942820ff9cc774",
"hash": "037e080729140b19",
"files": 6
},
"hyperframes-cli": {
+15 -4
View File
@@ -306,10 +306,21 @@ clip. A lane on a clip is clip-local; the same numbers mean different instants o
the two, which is the one thing to get right when moving an envelope from a clip
up onto its bus.
**A carve stays on the clip.** `data-fx-carve` is not a group attribute: the bus
has no carve, and putting one there does nothing. The bed being carved is a
single track, and it is that track which carries `data-fx-carve` — pointed AT a
group, per the rule above. Group and carve meet in `sources`, not on one element.
**A carve stays on the clip.** `data-fx-carve` is not a group attribute. The bed
being carved is a single track, and it is that track which carries
`data-fx-carve` — pointed AT a group, per the rule above. Group and carve meet in
`sources`, not on one element. A carve written onto a bus is half an effect
applied twice: the level half measures the bed's own audio, which a bus has none
of, so only the filters survive — and a bus and its members are one signal path,
so the bed then runs through the bus's filters AND its own. The
`audio_group_carve_attr` lint rule catches it.
**One clip is not a bus.** A group exists to give several tracks one chain, one
fader and one clock. Wrapping a single clip in a bus buys nothing the clip's own
`data-fx-chain` does not already do, and it doubles the places a later edit has
to land. The one reason to do it anyway: a bus's automation clock is composition
time, so a single-member bus is how a lane on that clip gets composition-time
timing.
Nothing here needs a feature flag: the Studio UI for building groups is behind
the `audio-groups` canary, but a hand-authored `<hf-audio-group>` parses, plays