Files
hyperframes/packages/core/src/audioGroups.test.ts
T
Vance IngallsandClaude Sonnet 5 8052f3e68f 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>
2026-08-20 16:39:30 -07:00

161 lines
5.9 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import {
audioGroupOf,
HF_AUDIO_GROUP_ATTR,
resolveAudioGroups,
resolveCarveSourceIds,
} from "./audioGroups.js";
beforeEach(() => {
document.body.innerHTML = "";
});
describe("resolveAudioGroups", () => {
it("returns one group of two members plus ignores an ungrouped track", () => {
document.body.innerHTML = `
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
<audio id="vo-1" data-audio-group="voiceover"></audio>
<audio id="vo-2" data-audio-group="voiceover"></audio>
<audio id="sfx-1"></audio>
`;
const groups = resolveAudioGroups(document);
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", () => {
document.body.innerHTML = `
<audio id="vo-1" data-audio-group="narration"></audio>
`;
const groups = resolveAudioGroups(document);
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)", () => {
document.body.innerHTML = `
<hf-audio-group id="outer" data-audio-group="outer"></hf-audio-group>
<audio id="vo-1" data-audio-group="outer"></audio>
`;
const groups = resolveAudioGroups(document);
expect(groups).toEqual([
{ id: "outer", label: "outer", memberIds: ["vo-1"], volume: 1, hidden: false },
]);
expect(audioGroupOf(document.getElementById("outer") as Element)).toBeNull();
});
it("drops a member removed from the DOM on re-resolve — nothing dangles", () => {
document.body.innerHTML = `
<audio id="vo-1" data-audio-group="voiceover"></audio>
<audio id="vo-2" data-audio-group="voiceover"></audio>
`;
expect(resolveAudioGroups(document)[0].memberIds).toEqual(["vo-1", "vo-2"]);
document.getElementById("vo-2")?.remove();
expect(resolveAudioGroups(document)[0].memberIds).toEqual(["vo-1"]);
});
it("ignores a data-audio-group on a video element (audio only in v1)", () => {
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", () => {
it("reads the member's group id", () => {
document.body.innerHTML = `<audio id="vo-1" data-audio-group="voiceover"></audio>`;
expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBe("voiceover");
});
it("returns null when the attribute is absent", () => {
document.body.innerHTML = `<audio id="vo-1"></audio>`;
expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBeNull();
});
});
describe("resolveCarveSourceIds", () => {
it("expands a group id to its current members", () => {
document.body.innerHTML = `
<audio id="vo-1" data-audio-group="voiceover"></audio>
<audio id="vo-2" data-audio-group="voiceover"></audio>
`;
expect(resolveCarveSourceIds(document, ["voiceover"])).toEqual(["vo-1", "vo-2"]);
});
it("picks up a member added to the group after the carve was set (analysis-time, not frozen)", () => {
document.body.innerHTML = `
<audio id="vo-1" data-audio-group="voiceover"></audio>
<audio id="vo-2" data-audio-group="voiceover"></audio>
`;
expect(resolveCarveSourceIds(document, ["voiceover"])).toEqual(["vo-1", "vo-2"]);
document.body.insertAdjacentHTML(
"beforeend",
`<audio id="vo-3" data-audio-group="voiceover"></audio>`,
);
expect(resolveCarveSourceIds(document, ["voiceover"])).toEqual(["vo-1", "vo-2", "vo-3"]);
});
it("passes through a plain clip id that still exists", () => {
document.body.innerHTML = `<audio id="vo-1"></audio>`;
expect(resolveCarveSourceIds(document, ["vo-1"])).toEqual(["vo-1"]);
});
it("drops an id that resolves to nothing — a deleted clip, an empty or vanished group", () => {
document.body.innerHTML = `<audio id="vo-1"></audio>`;
expect(resolveCarveSourceIds(document, ["vo-1", "deleted", "no-such-group"])).toEqual(["vo-1"]);
});
it("dedupes and preserves first-seen order across a mix of group and plain ids", () => {
document.body.innerHTML = `
<audio id="vo-1" data-audio-group="voiceover"></audio>
<audio id="vo-2" data-audio-group="voiceover"></audio>
`;
expect(resolveCarveSourceIds(document, ["voiceover", "vo-1"])).toEqual(["vo-1", "vo-2"]);
});
});
describe(HF_AUDIO_GROUP_ATTR, () => {
it("is the attribute name membership is keyed on", () => {
expect(HF_AUDIO_GROUP_ATTR).toBe("data-audio-group");
});
});