Skip to content
Open
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
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/audioGroups.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<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"] }]);
});

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"] }]);
});

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"] }]);
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([]);
});
});

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(HF_AUDIO_GROUP_ATTR, () => {
it("is the attribute name membership is keyed on", () => {
expect(HF_AUDIO_GROUP_ATTR).toBe("data-audio-group");
});
});
59 changes: 59 additions & 0 deletions packages/core/src/audioGroups.ts
Original file line number Diff line number Diff line change
@@ -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 `<hf-audio-group>` element still resolves
* (label = id) so a hand-authored composition degrades gracefully. Audio
* only in v1 — a `data-audio-group` on a `<video>` is ignored.
*/
export function resolveAudioGroups(root: ParentNode): HfAudioGroup[] {
const membersByGroup = new Map<string, string[]>();
for (const member of root.querySelectorAll(`audio[${HF_AUDIO_GROUP_ATTR}]`)) {
const groupId = member.getAttribute(HF_AUDIO_GROUP_ATTR);
if (!groupId || !member.id) continue;
const members = membersByGroup.get(groupId);
if (members) members.push(member.id);
else membersByGroup.set(groupId, [member.id]);
}

const groupElements = new Map<string, Element>();
for (const el of root.querySelectorAll(HF_AUDIO_GROUP_TAG)) {
if (el.id) groupElements.set(el.id, el);
}

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 });
}
return groups;
}

/** The group a member belongs to, or null. Groups do not nest — this ignores
* `data-audio-group` on an `<hf-audio-group>` element itself. */
export function audioGroupOf(el: Element): string | null {
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
return el.getAttribute(HF_AUDIO_GROUP_ATTR);
}
11 changes: 11 additions & 0 deletions packages/core/src/canaryRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ export const CANARIES: readonly CanaryDefinition[] = [
owner: "vance",
sunsetAfter: "2026-12-15",
},
{
name: "audio-groups",
percentage: 0,
description:
"Group audio tracks under a shared label, FX chain, and automation " +
"clock. Gates the Studio UI for creating and managing groups; the " +
"underlying <hf-audio-group> element and data-audio-group membership " +
"parse and play regardless of enrollment.",
owner: "vance",
sunsetAfter: "2027-01-15",
},
] as const;

export function findCanary(name: string): CanaryDefinition | undefined {
Expand Down
24 changes: 24 additions & 0 deletions packages/studio/src/hooks/useDomEditCommits.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,30 @@ describe("useDomEditCommits attribute persist handling", () => {
}
});

it("sets and removes data-audio-group like any other data attribute", async () => {
stubPatchFetch({ ok: true, changed: true, matched: true });
const { iframe, element } = createPreviewElement();
const rendered = renderDomEditCommits(createSelection(element), iframe);

try {
await act(async () => {
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "voiceover", undefined, {
previewOnly: true,
});
});
expect(element.getAttribute("data-audio-group")).toBe("voiceover");

await act(async () => {
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "", undefined, {
previewOnly: true,
});
});
expect(element.getAttribute("data-audio-group")).toBeNull();
} finally {
rendered.cleanup();
}
});

it("keeps a data-attribute commit on success", async () => {
stubPatchFetch({
ok: true,
Expand Down
Loading