+ `;
+
+ // 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(`
+
diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts
index cb0576ce9..7053b965d 100644
--- a/packages/lint/src/rules/media.ts
+++ b/packages/lint/src/rules/media.ts
@@ -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;
+}
diff --git a/packages/studio/src/components/editor/useFxCarveGrouping.test.ts b/packages/studio/src/components/editor/useFxCarveGrouping.test.ts
index 381950bb2..5dfc2d2b0 100644
--- a/packages/studio/src/components/editor/useFxCarveGrouping.test.ts
+++ b/packages/studio/src/components/editor/useFxCarveGrouping.test.ts
@@ -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(``, "music"),
+ ).toEqual({ couldBeBed: false, autoBed: false });
+ expect(
+ roles(``, "sfx"),
+ ).toEqual({ couldBeBed: false, autoBed: false });
+ });
+
+ it("still reads a clip's label, id and src", () => {
+ expect(roles(``, "a1")).toEqual({
+ couldBeBed: true,
+ autoBed: true,
+ });
+ // A name that says nothing may be offered the control but never carves itself.
+ expect(roles(``, "a1")).toEqual({ couldBeBed: true, autoBed: false });
+ expect(roles(``, "vo-2")).toEqual({
+ couldBeBed: false,
+ autoBed: false,
+ });
+ });
+});
diff --git a/packages/studio/src/components/editor/useFxCarveGrouping.ts b/packages/studio/src/components/editor/useFxCarveGrouping.ts
index 3734a4e73..5f79cf130 100644
--- a/packages/studio/src/components/editor/useFxCarveGrouping.ts
+++ b/packages/studio/src/components/editor/useFxCarveGrouping.ts
@@ -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) };
}
diff --git a/skills-manifest.json b/skills-manifest.json
index 3ded9df16..535733533 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -26,7 +26,7 @@
"files": 121
},
"hyperframes-audio": {
- "hash": "73942820ff9cc774",
+ "hash": "037e080729140b19",
"files": 6
},
"hyperframes-cli": {
diff --git a/skills/hyperframes-audio/SKILL.md b/skills/hyperframes-audio/SKILL.md
index 2dd26e496..39ea049b9 100644
--- a/skills/hyperframes-audio/SKILL.md
+++ b/skills/hyperframes-audio/SKILL.md
@@ -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 `` parses, plays