From 5dc39a84e9055abc23ce457d42b6d9dd09c96a60 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 23 Aug 2026 01:57:45 -0700 Subject: [PATCH] fix(studio,lint,skills): a bus is never a carve bed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/lint/src/rules/media.test.ts | 37 +++++++++++++++++++ packages/lint/src/rules/media.ts | 35 ++++++++++++++++++ .../editor/useFxCarveGrouping.test.ts | 36 +++++++++++++++++- .../components/editor/useFxCarveGrouping.ts | 30 +++++++++++++-- skills-manifest.json | 2 +- skills/hyperframes-audio/SKILL.md | 19 ++++++++-- 6 files changed, 150 insertions(+), 9 deletions(-) diff --git a/packages/lint/src/rules/media.test.ts b/packages/lint/src/rules/media.test.ts index 8237efa0d..7aaaa97c7 100644 --- a/packages/lint/src/rules/media.test.ts +++ b/packages/lint/src/rules/media.test.ts @@ -651,6 +651,43 @@ describe("audio_group_timing_attrs", () => { }); }); +describe("audio_group_carve_attr", () => { + const doc = (busAttrs: string) => ` +
+ + +
+ `; + + // 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(` +
+ + +
+ `); + expect(res.findings.some((f) => f.code === "audio_group_carve_attr")).toBe(false); + }); +}); + describe("audio_carve_ungrouped_sources", () => { const withCarve = (carveJson: string, extra = "") => `
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