diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json
index 1485e4f022..965a985de4 100644
--- a/packages/core/package-subpaths.json
+++ b/packages/core/package-subpaths.json
@@ -152,6 +152,12 @@
"types": "./dist/audioCarve.d.ts",
"environments": ["browser", "bun", "node"]
},
+ "./audio-groups": {
+ "source": "./src/audioGroups.ts",
+ "runtime": "./dist/audioGroups.js",
+ "types": "./dist/audioGroups.d.ts",
+ "environments": ["browser", "bun", "node"]
+ },
"./audio-automation": {
"source": "./src/audioAutomation.ts",
"runtime": "./dist/audioAutomation.js",
diff --git a/packages/core/package.json b/packages/core/package.json
index 41077e31d1..f0d598bbe8 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -166,6 +166,12 @@
"import": "./src/audioCarve.ts",
"types": "./src/audioCarve.ts"
},
+ "./audio-groups": {
+ "bun": "./src/audioGroups.ts",
+ "node": "./dist/audioGroups.js",
+ "import": "./src/audioGroups.ts",
+ "types": "./src/audioGroups.ts"
+ },
"./audio-automation": {
"bun": "./src/audioAutomation.ts",
"node": "./dist/audioAutomation.js",
@@ -474,6 +480,10 @@
"import": "./dist/audioCarve.js",
"types": "./dist/audioCarve.d.ts"
},
+ "./audio-groups": {
+ "import": "./dist/audioGroups.js",
+ "types": "./dist/audioGroups.d.ts"
+ },
"./audio-automation": {
"import": "./dist/audioAutomation.js",
"types": "./dist/audioAutomation.d.ts"
diff --git a/packages/core/src/audioGroups.test.ts b/packages/core/src/audioGroups.test.ts
new file mode 100644
index 0000000000..232827456b
--- /dev/null
+++ b/packages/core/src/audioGroups.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { audioGroupOf, HF_AUDIO_GROUP_ATTR, resolveAudioGroups } from "./audioGroups.js";
+
+beforeEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("resolveAudioGroups", () => {
+ it("returns one group of two members plus ignores an ungrouped track", () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ const groups = resolveAudioGroups(document);
+ expect(groups).toEqual([{ id: "voiceover", label: "Voiceover", memberIds: ["vo-1", "vo-2"] }]);
+ });
+
+ it("resolves from member tags alone when the group element is absent, label = id", () => {
+ document.body.innerHTML = `
+
+ `;
+ const groups = resolveAudioGroups(document);
+ expect(groups).toEqual([{ id: "narration", label: "narration", memberIds: ["vo-1"] }]);
+ });
+
+ it("ignores data-audio-group on the group element itself (groups do not nest)", () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const groups = resolveAudioGroups(document);
+ expect(groups).toEqual([{ id: "outer", label: "outer", memberIds: ["vo-1"] }]);
+ expect(audioGroupOf(document.getElementById("outer") as Element)).toBeNull();
+ });
+
+ it("drops a member removed from the DOM on re-resolve — nothing dangles", () => {
+ document.body.innerHTML = `
+
+
+ `;
+ 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 = ``;
+ expect(resolveAudioGroups(document)).toEqual([]);
+ });
+});
+
+describe("audioGroupOf", () => {
+ it("reads the member's group id", () => {
+ document.body.innerHTML = ``;
+ expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBe("voiceover");
+ });
+
+ it("returns null when the attribute is absent", () => {
+ document.body.innerHTML = ``;
+ expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBeNull();
+ });
+});
+
+describe(HF_AUDIO_GROUP_ATTR, () => {
+ it("is the attribute name membership is keyed on", () => {
+ expect(HF_AUDIO_GROUP_ATTR).toBe("data-audio-group");
+ });
+});
diff --git a/packages/core/src/audioGroups.ts b/packages/core/src/audioGroups.ts
new file mode 100644
index 0000000000..ad96b91822
--- /dev/null
+++ b/packages/core/src/audioGroups.ts
@@ -0,0 +1,59 @@
+/**
+ * The audio group model: a named bucket of audio tracks that shares a label,
+ * an FX chain, and automation. Membership is held by the member (`data-audio-group`
+ * pointing at a group id), not by the group nesting its members, so a track
+ * dropped from the DOM simply disappears from the group on the next resolve —
+ * nothing dangles.
+ *
+ * Parse-only: this module answers "what groups exist and who is in them," and
+ * nothing here routes or sums audio yet.
+ */
+
+export const HF_AUDIO_GROUP_TAG = "hf-audio-group";
+export const HF_AUDIO_GROUP_ATTR = "data-audio-group";
+
+export interface HfAudioGroup {
+ id: string;
+ /** `data-label`, falling back to the id when absent. */
+ label: string;
+ /** Member element ids, in document order. */
+ memberIds: string[];
+}
+
+/**
+ * Every group with at least one member, resolved from the live document.
+ *
+ * A group with members but no `` element still resolves
+ * (label = id) so a hand-authored composition degrades gracefully. Audio
+ * only in v1 — a `data-audio-group` on a `