Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions packages/core/src/audioGroups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ensureAudioGroupInertStyle,
HF_AUDIO_GROUP_ATTR,
resolveAudioGroups,
resolveCarveSourceIds,
} from "./audioGroups.js";

beforeEach(() => {
Expand All @@ -22,15 +23,25 @@ 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", () => {
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"] }]);
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)", () => {
Expand All @@ -39,7 +50,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();
});

Expand All @@ -58,6 +71,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", () => {
Expand Down Expand Up @@ -129,6 +173,47 @@ describe("ensureAudioGroupInertStyle", () => {
});
});

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");
Expand Down
81 changes: 75 additions & 6 deletions packages/core/src/audioGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -32,6 +35,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,
};
}

/**
Expand All @@ -58,13 +93,42 @@ 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;
}

/**
* Expand a list of source ids for a carve: a plain id passes through if it
* still exists, a group id expands to its CURRENT members. Resolved fresh
* every time — group membership is never frozen into the carve's own
* attribute, so adding a fourth voice to a group already named in a carve's
* `sources` picks it up on the next analysis without editing that carve.
*
* Dedupes and preserves first-seen order; an id that resolves to nothing
* (a deleted clip, an empty or vanished group) is dropped rather than kept
* as a dangling reference the analysis would only fail to find anyway.
*/
export function resolveCarveSourceIds(doc: Document, ids: readonly string[]): string[] {
const groupsById = new Map(resolveAudioGroups(doc).map((group) => [group.id, group] as const));
const seen = new Set<string>();
const out: string[] = [];
const add = (id: string): void => {
if (seen.has(id)) return;
seen.add(id);
out.push(id);
};
for (const id of ids) {
const group = groupsById.get(id);
if (group) {
group.memberIds.forEach(add);
} else if (doc.getElementById(id)) {
add(id);
}
}
return out;
}

/**
* The group a member belongs to, or null — the same predicate
* `resolveAudioGroups` scans with, so the two can never disagree about a given
Expand All @@ -74,9 +138,14 @@ export function resolveAudioGroups(root: ParentNode): HfAudioGroup[] {
* `data-audio-group` on an `<hf-audio-group>` itself (groups do not nest).
* `data-audio-group=""` returns null rather than `""` — the resolver skips a
* falsy id, and the "or null" in this contract has to mean it.
*
* Tolerant of objects that only partially implement `Element` (test doubles for
* `HTMLMediaElement` commonly do): anything missing `tagName` or `getAttribute`
* simply has no group, mirroring `readChain`'s style in `runtime/audioFx.ts`.
*/
export function audioGroupOf(el: Element): string | null {
if (el.tagName?.toLowerCase() !== "audio") return null;
if (typeof el.tagName !== "string" || el.tagName.toLowerCase() !== "audio") return null;
if (typeof el.getAttribute !== "function") return null;
return el.getAttribute(HF_AUDIO_GROUP_ATTR) || null;
}

Expand All @@ -91,8 +160,8 @@ export function audioGroupOf(el: Element): string | null {
* would shift by adding a group, which is not something a mixing decision is
* allowed to do.
*
* `!important` because the rule has to beat an author rule that sets `display`
* on the tag — inertness here is a contract, not a default. Emitted from the
* `!important` because an author rule can outrank a bare type selector on
* specificity — inertness here is a contract, not a default. Emitted from the
* runtime rather than the compiler so preview and render share one source.
*/
export function ensureAudioGroupInertStyle(doc: Document): void {
Expand Down
Loading
Loading