From 54091b5015aa0d45ca61275e75567701daf6e2b2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 23 Aug 2026 18:09:40 -0700 Subject: [PATCH] feat(lint): validate audio group membership and timing (#3447) * fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards --- packages/lint/src/rules/media.test.ts | 162 ++++++++++++++++++++++++++ packages/lint/src/rules/media.ts | 149 +++++++++++++++++++++++ 2 files changed, 311 insertions(+) diff --git a/packages/lint/src/rules/media.test.ts b/packages/lint/src/rules/media.test.ts index a7fc07055..78be02b9f 100644 --- a/packages/lint/src/rules/media.test.ts +++ b/packages/lint/src/rules/media.test.ts @@ -556,6 +556,168 @@ describe("audio_volume_double_automation", () => { }); }); +describe("audio_group_no_members", () => { + const doc = (body: string) => ` +
+ ${body} +
+ `; + + const BUS = ``; + + it("errors on a bus no clip in the file belongs to", async () => { + const res = await lintHyperframeHtml( + doc( + `${BUS}`, + ), + ); + const finding = res.findings.find((f) => f.code === "audio_group_no_members"); + expect(finding?.severity).toBe("error"); + expect(finding?.elementId).toBe("voiceover"); + }); + + // The whole point: one typo drops the authored bus (fader AND chain) and + // invents a phantom group at unity, with nothing said about either. + it("catches the misspelled member — the case that motivated the rule", async () => { + const res = await lintHyperframeHtml( + doc( + `${BUS}`, + ), + ); + const finding = res.findings.find((f) => f.code === "audio_group_no_members"); + expect(finding?.elementId).toBe("voiceover"); + expect(finding?.message).toContain("voiceovr"); + }); + + it("suggests only unmatched member ids, not a healthy sibling group", async () => { + const res = await lintHyperframeHtml( + doc(`${BUS} + + `), + ); + const finding = res.findings.find((item) => item.code === "audio_group_no_members"); + expect(finding?.message).toContain('"voiceovr"'); + expect(finding?.message).not.toContain('"music"'); + }); + + it("does not count video as group membership", async () => { + const res = await lintHyperframeHtml( + doc(`${BUS} + `), + ); + expect( + res.findings.some( + (finding) => finding.code === "audio_group_no_members" && finding.elementId === "voiceover", + ), + ).toBe(true); + }); + + it("stays quiet when a clip belongs to it", async () => { + const res = await lintHyperframeHtml( + doc( + `${BUS}`, + ), + ); + expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false); + }); + + // A bus with no id cannot be joined at all, and `resolveAudioGroups` skips it + // when building its element map — a different mistake, not this rule's. + // The rule can only speak about a file it can see all of. `lintHyperframeHtml` + // takes ONE file, and the studio's own group creation writes the bus into the + // active composition while patching `data-audio-group` into each member's own + // file (timelineAudioGroupCreate) — so a file holding a bus and no members at + // all is the normal cross-file shape, not a mistake. + it("stays quiet in a file that declares no members at all", async () => { + const res = await lintHyperframeHtml(doc(BUS)); + expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false); + }); + + it("stays quiet for an unmatched bus when another group has local members", async () => { + const res = await lintHyperframeHtml( + doc(` + + ${BUS} +
`), + ); + expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false); + }); + + it("stays quiet for a bus with no id", async () => { + const res = await lintHyperframeHtml( + doc(` + `), + ); + expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false); + }); +}); + +describe("audio_group_timing_attrs", () => { + const doc = (busAttrs: string) => ` +
+ + +
+ `; + + it("warns on data-start", async () => { + const res = await lintHyperframeHtml(doc(`data-start="0" data-duration="40"`)); + const finding = res.findings.find((f) => f.code === "audio_group_timing_attrs"); + expect(finding?.severity).toBe("warning"); + expect(finding?.elementId).toBe("voiceover"); + expect(finding?.message).toContain("data-start"); + expect(finding?.message).toContain("data-duration"); + }); + + it("warns on data-track-index", async () => { + const res = await lintHyperframeHtml(doc(`data-track-index="7"`)); + expect(res.findings.some((f) => f.code === "audio_group_timing_attrs")).toBe(true); + }); + + it("stays quiet on a bus carrying only its own attributes", async () => { + const res = await lintHyperframeHtml(doc(`data-volume="0.4" data-hidden`)); + expect(res.findings.some((f) => f.code === "audio_group_timing_attrs")).toBe(false); + }); +}); + +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 11c2bf4cf..a5aec9a0c 100644 --- a/packages/lint/src/rules/media.ts +++ b/packages/lint/src/rules/media.ts @@ -634,6 +634,15 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = findVolumeTweenOverridesGainFindings, // audio_carve_ungrouped_sources findCarveUngroupedSourcesFindings, + + // audio_group_no_members + findAudioGroupNoMembersFindings, + + // audio_group_timing_attrs + findAudioGroupTimingAttrFindings, + + // audio_group_carve_attr + findAudioGroupCarveAttrFindings, ]; /** @@ -769,3 +778,143 @@ function findCarveUngroupedSourcesFindings(ctx: LintContext): HyperframeLintFind } return findings; } + +/** Timing attributes a bus must never carry. It has no clip window of its own: + * a group's automation clock is COMPOSITION time, and its members carry the + * timing. */ +const AUDIO_GROUP_TIMING_ATTRS = ["data-start", "data-duration", "data-track-index"] as const; + +/** + * A bus nobody joined does nothing, silently. + * + * `resolveAudioGroups` builds groups from the MEMBERS (`audio[data-audio-group]`) + * and only then looks for a matching `` element, so a bus whose + * id no clip names is dropped entirely — its fader, FX chain and automation + * never reach preview or render, and nothing says so. One typo is enough: + * `data-audio-group="voiceovr"` against `id="voiceover"` loses the authored bus + * AND invents a phantom group at unity gain with no chain, which is what the + * timeline then draws. + */ +function findAudioGroupNoMembersFindings(ctx: LintContext): HyperframeLintFinding[] { + const memberGroupIds = new Set( + ctx.tags + .filter((tag) => tag.name === "audio") + .map((tag) => readAttr(tag.raw, "data-audio-group")) + .filter((id): id is string => Boolean(id)), + ); + + // Only a file that declares SOME membership can be judged. `lintHyperframeHtml` + // sees one file, and the studio's own group creation writes the bus into the + // active composition while patching `data-audio-group` into each member's own + // file (`timelineAudioGroupCreate`) — so a file carrying a bus and no members + // at all is the ordinary cross-file shape. Firing there reported the studio's + // own output as an error, and said "No clip carries `data-audio-group` at all" + // about clips it simply could not see. + if (memberGroupIds.size === 0) return []; + const mayHaveCrossFileMembers = ctx.tags.some((tag) => + Boolean(readAttr(tag.raw, "data-composition-src")), + ); + const declaredGroupIds = new Set( + ctx.tags + .filter((tag) => tag.name === "hf-audio-group") + .map((tag) => readAttr(tag.raw, "id")) + .filter((id): id is string => Boolean(id)), + ); + const unmatchedMemberGroupIds = [...memberGroupIds].filter((id) => !declaredGroupIds.has(id)); + + const findings: HyperframeLintFinding[] = []; + for (const tag of ctx.tags) { + if (tag.name !== "hf-audio-group") continue; + // A bus with no id cannot be joined at all — a different mistake, and + // `resolveAudioGroups` skips it when building its element map. + const elementId = readAttr(tag.raw, "id"); + if (!elementId) continue; + if (memberGroupIds.has(elementId)) continue; + // A mixed file is still not closed-world: one bus may have local members + // while another serves clips inside a referenced composition. The linter + // cannot inspect that file here, so an unmatched bus is only provably empty + // when this source has no cross-file composition hosts at all. + if (mayHaveCrossFileMembers) continue; + + // Naming the near-misses is the whole value: the fix is almost always a + // typo on one member, and the author is looking at the bus, not the clip. + // Do not offer a correctly matched sibling bus as the fix for this one. + // Only member ids with no declared bus are plausible typos. + const nearby = unmatchedMemberGroupIds.filter((id) => id !== elementId); + const suffix = + nearby.length > 0 + ? ` Clips in this file name ${nearby.map((id) => `"${id}"`).join(", ")} instead.` + : ""; + findings.push({ + code: "audio_group_no_members", + severity: "error", + message: `#${elementId} is an audio group no clip belongs to, so its fader, effect chain and automation are dropped.${suffix}`, + elementId, + fixHint: `Add \`data-audio-group="${elementId}"\` to the clips this bus is for, or delete the bus.`, + snippet: truncateSnippet(tag.raw), + }); + } + return findings; +} + +/** + * Timing on a bus is meaningless — and it is how a phantom clip row appears. + * + * The preview runtime stamps `data-start`/`data-duration` on id'd children of + * the composition root so they show up in the timeline; a bus caught by that + * became a full-duration clip row above its own group header, draggable and + * deletable (fixed in core). Timing PERSISTED into the file is the same shape + * with none of the excuse: the render reads a group's `fxChain`, `automation` + * and `volume` only, so these attributes change nothing and mislead the next + * reader into thinking the bus has a window. + */ +function findAudioGroupTimingAttrFindings(ctx: LintContext): HyperframeLintFinding[] { + const findings: HyperframeLintFinding[] = []; + for (const tag of ctx.tags) { + if (tag.name !== "hf-audio-group") continue; + const present = AUDIO_GROUP_TIMING_ATTRS.filter((attr) => hasAttrName(tag.raw, attr)); + if (present.length === 0) continue; + const elementId = readAttr(tag.raw, "id") || undefined; + findings.push({ + code: "audio_group_timing_attrs", + severity: "warning", + message: `${elementId ? `#${elementId}` : "This audio group"} carries ${present.map((attr) => `\`${attr}\``).join(", ")}, which a bus has no use for — its members carry the timing and its automation clock is composition time.`, + elementId, + fixHint: `Remove ${present.map((attr) => `\`${attr}\``).join(", ")} from the group element.`, + snippet: truncateSnippet(tag.raw), + }); + } + 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; +}