feat(lint): flag an audio group no clip joins, and timing attrs on a bus

Two silent failures groups can carry that nothing reported.

**audio_group_no_members (error).** `resolveAudioGroups` builds groups from the
MEMBERS (`audio[data-audio-group]`) and only then looks for a matching
`<hf-audio-group>`, so a bus whose id no clip names is dropped whole — its
fader, effect chain and automation never reach preview or render. One typo does
it: with `id="voiceover"` and `data-audio-group="voiceovr"`, resolveAudioGroups
returns `[{id:"voiceovr", members:["vo-1"], hasChain:false}]` — the authored bus
is gone AND a phantom group is invented at unity gain, which is what the
timeline then draws. The message names the ids clips DID use, because the fix is
almost always a typo on a member while the author is looking at the bus.

Found a real one on its first run: the audio-playground fixture declares a
`#narration` bus that no clip joins (only `sfx` is referenced), so its whole
chain has been dead. That fixture is gitignored, so nothing to fix in-tree.

**audio_group_timing_attrs (warning).** `data-start` / `data-duration` /
`data-track-index` on a bus mean nothing: the render reads a group's `fxChain`,
`automation` and `volume` only, members carry the timing, and a group's
automation clock is composition time. It is also the file-level footprint of the
phantom clip row just fixed in core (0e86e64d2) — if a drag ever persists onto
such a row, this is the shape it leaves behind.

Deliberately NOT rules, both checked:
- `data-audio-group` naming a group with no element — blessed by design ("still
  resolves, label = id, so a hand-authored composition degrades gracefully").
- `data-fx-carve` on a group — works end to end. The studio compiles it into the
  group's `data-fx-chain` and the render applies group fxChain; audio-real's
  `sfx` group carries both and behaves correctly.

audio-real, fx-test-bench and automation-test stay clean, as does the example in
skills/hyperframes-audio. lint: 14 files, 535 tests.

Note for anyone verifying by hand: `hyperframes lint` on PATH is the GLOBAL
install (~/.bun/install/global), not the worktree — it reported nothing until I
ran `bun packages/cli/src/cli.ts lint` instead.
This commit is contained in:
Vance Ingalls
2026-08-20 16:41:12 -07:00
parent 373884ddc0
commit 4f89082caa
2 changed files with 165 additions and 0 deletions
+77
View File
@@ -556,6 +556,83 @@ describe("audio_volume_double_automation", () => {
});
});
describe("audio_group_no_members", () => {
const doc = (body: string) => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
${body}
</div>
</body></html>`;
const BUS = `<hf-audio-group id="voiceover" data-label="Voiceover" data-volume="0.4"
data-fx-chain='{"version":1,"nodes":[{"type":"peaking","id":"n1","params":{"frequency":250,"gain":-3,"q":1.2}}]}'></hf-audio-group>`;
it("errors on a bus no clip 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}<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceovr"></audio>`,
),
);
const finding = res.findings.find((f) => f.code === "audio_group_no_members");
expect(finding?.elementId).toBe("voiceover");
expect(finding?.message).toContain("voiceovr");
});
it("stays quiet when a clip belongs to it", async () => {
const res = await lintHyperframeHtml(
doc(
`${BUS}<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>`,
),
);
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.
it("stays quiet for a bus with no id", async () => {
const res = await lintHyperframeHtml(
doc(`<hf-audio-group data-label="Nameless"></hf-audio-group>`),
);
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
});
});
describe("audio_group_timing_attrs", () => {
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="voiceover" data-label="Voiceover" ${busAttrs}></hf-audio-group>
<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
</div>
</body></html>`;
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_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">
+88
View File
@@ -634,6 +634,12 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
findVolumeTweenOverridesGainFindings,
// audio_carve_ungrouped_sources
findCarveUngroupedSourcesFindings,
// audio_group_no_members
findAudioGroupNoMembersFindings,
// audio_group_timing_attrs
findAudioGroupTimingAttrFindings,
];
/**
@@ -769,3 +775,85 @@ 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 `<hf-audio-group>` 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)),
);
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;
// 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.
const nearby = [...memberGroupIds].filter((id) => id !== elementId);
const suffix =
nearby.length > 0
? ` Clips name ${nearby.map((id) => `"${id}"`).join(", ")} instead.`
: " No clip carries `data-audio-group` at all.";
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;
}