mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
co-authored by
Claude Sonnet 5
parent
351b219d2b
commit
8052f3e68f
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user