feat(engine): render grouped audio through a summed, FX-processed bus

Renders what B3 already routes in preview: a group's members sub-mix into
one PCM WAV at full composition length (adelay already places each member
at its composition position, so the group WAV's t=0 IS composition time),
run through the group's own FX chain and automation via the same
applyAudioFxChain/envelope-bake path a member uses, then fold into the flat
track list as one processed AudioTrack — the final mixAudioTracks call
never has to know groups exist.

Gain law verified against plans/spikes/amix-nesting-spike.sh (brought over
from the plans branch, along with audioMixer.grouping.test.ts, since both
were committed there and never merged to origin/main — every step branch in
this stack descends from origin/main): the sub-mix's own amix prefers
normalize=0 (nulls exactly against a flat mix), falling back to per-node
compensation by the group's OWN member count only when this ffmpeg build's
amix rejects the option. Carrying any other count into a nested amix node
is the exact +2.499 dB silent failure the spike measured — confirmed by a
manual mutation check (wrong-count compensation landed 3.5 dB hot, exactly
20*log10(3/2) for a 2-member group compensated as 3; reverted after
confirming the level test catches it).

A group element carrying data-hidden drops every member before the sub-mix
ever runs (RULES: mute-by-drop, never mute-by-volume-0) — parseAudioElements
now resolves groups once per parse and skips hidden-group members the same
way it already skips data-hidden ancestors.

HfAudioGroup (packages/core/src/audioGroups.ts, from B1) gains fxChain,
automation, volume and hidden, read off the group element the same way
resolveAudioGroups already reads data-label — audioGroups.test.ts updated
for the wider shape plus new coverage for the added reads.

it.todo("mixes a grouped composition at the same level as the ungrouped
one") is now a real, passing test; two more added per the step doc (FX
routing isolation, member-level envelope survives grouping).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:30 -07:00
co-authored by Claude Sonnet 5
parent 351b219d2b
commit 8052f3e68f
7 changed files with 714 additions and 10 deletions
+46 -3
View File
@@ -19,7 +19,15 @@ describe("resolveAudioGroups", () => {
<audio id="sfx-1"></audio>
`;
const groups = resolveAudioGroups(document);
expect(groups).toEqual([{ id: "voiceover", label: "Voiceover", memberIds: ["vo-1", "vo-2"] }]);
expect(groups).toEqual([
{
id: "voiceover",
label: "Voiceover",
memberIds: ["vo-1", "vo-2"],
volume: 1,
hidden: false,
},
]);
});
it("resolves from member tags alone when the group element is absent, label = id", () => {
@@ -27,7 +35,9 @@ describe("resolveAudioGroups", () => {
<audio id="vo-1" data-audio-group="narration"></audio>
`;
const groups = resolveAudioGroups(document);
expect(groups).toEqual([{ id: "narration", label: "narration", memberIds: ["vo-1"] }]);
expect(groups).toEqual([
{ id: "narration", label: "narration", memberIds: ["vo-1"], volume: 1, hidden: false },
]);
});
it("ignores data-audio-group on the group element itself (groups do not nest)", () => {
@@ -36,7 +46,9 @@ describe("resolveAudioGroups", () => {
<audio id="vo-1" data-audio-group="outer"></audio>
`;
const groups = resolveAudioGroups(document);
expect(groups).toEqual([{ id: "outer", label: "outer", memberIds: ["vo-1"] }]);
expect(groups).toEqual([
{ id: "outer", label: "outer", memberIds: ["vo-1"], volume: 1, hidden: false },
]);
expect(audioGroupOf(document.getElementById("outer") as Element)).toBeNull();
});
@@ -55,6 +67,37 @@ describe("resolveAudioGroups", () => {
document.body.innerHTML = `<video id="v-1" data-audio-group="voiceover"></video>`;
expect(resolveAudioGroups(document)).toEqual([]);
});
it("reads the group element's fx chain, automation, volume and hidden", () => {
document.body.innerHTML = `
<hf-audio-group id="voiceover" data-fx-chain='{"version":1,"nodes":[]}' data-automation='{"lanes":[]}' data-volume="0.5" data-hidden></hf-audio-group>
<audio id="vo-1" data-audio-group="voiceover"></audio>
`;
const groups = resolveAudioGroups(document);
expect(groups).toEqual([
{
id: "voiceover",
label: "voiceover",
memberIds: ["vo-1"],
fxChain: '{"version":1,"nodes":[]}',
automation: '{"lanes":[]}',
volume: 0.5,
hidden: true,
},
]);
});
it("defaults volume to 1 and hidden to false when a group element exists but carries neither", () => {
document.body.innerHTML = `
<hf-audio-group id="voiceover"></hf-audio-group>
<audio id="vo-1" data-audio-group="voiceover"></audio>
`;
const [group] = resolveAudioGroups(document);
expect(group?.volume).toBe(1);
expect(group?.hidden).toBe(false);
expect(group?.fxChain).toBeUndefined();
expect(group?.automation).toBeUndefined();
});
});
describe("audioGroupOf", () => {
+36 -3
View File
@@ -9,6 +9,9 @@
* nothing here routes or sums audio yet.
*/
import { HF_AUDIO_FX_ATTR } from "./audioFx.js";
import { HF_AUDIO_AUTOMATION_ATTR } from "./audioAutomation.js";
export const HF_AUDIO_GROUP_TAG = "hf-audio-group";
export const HF_AUDIO_GROUP_ATTR = "data-audio-group";
@@ -18,6 +21,38 @@ export interface HfAudioGroup {
label: string;
/** Member element ids, in document order. */
memberIds: string[];
/** Serialised FX chain JSON from the group element's `data-fx-chain`, when set. */
fxChain?: string;
/** Serialised automation JSON from the group element's `data-automation`, when set. */
automation?: string;
/** The group element's `data-volume`, defaulting to 1 when absent or there is no group element. */
volume: number;
/**
* The group element's `data-hidden`. Render drops every member rather than
* zeroing them (RULES: mute-by-drop, never mute-by-volume-0) — B5 defines
* the UI for this; this field just makes the read available now.
*/
hidden: boolean;
}
function parseGroupVolume(el: Element | undefined): number {
const raw = el?.getAttribute("data-volume");
const parsed = raw ? parseFloat(raw) : 1;
return Number.isFinite(parsed) ? parsed : 1;
}
function buildGroup(id: string, memberIds: string[], el: Element | undefined): HfAudioGroup {
const fxChain = el?.getAttribute(HF_AUDIO_FX_ATTR);
const automation = el?.getAttribute(HF_AUDIO_AUTOMATION_ATTR);
return {
id,
label: el?.getAttribute("data-label") || id,
memberIds,
...(fxChain ? { fxChain } : {}),
...(automation ? { automation } : {}),
volume: parseGroupVolume(el),
hidden: el?.hasAttribute("data-hidden") ?? false,
};
}
/**
@@ -44,9 +79,7 @@ export function resolveAudioGroups(root: ParentNode): HfAudioGroup[] {
const groups: HfAudioGroup[] = [];
for (const [id, memberIds] of membersByGroup) {
const el = groupElements.get(id);
const label = el?.getAttribute("data-label") || id;
groups.push({ id, label, memberIds });
groups.push(buildGroup(id, memberIds, groupElements.get(id)));
}
return groups;
}