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
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 @@ -480,6 +486,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
3 changes: 3 additions & 0 deletions packages/core/src/audioFxCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ export const PRESET_PROBLEM: Record<string, string> = {
"pa-system": "Make it sound like a station announcement",
intercom: "Make it sound like a door intercom",
"doofus-worble": "Make it wobble like it is seasick",
chipmunk: "Make it small and squeaky",
giant: "Make it huge and deep",
monster: "Make it a monster",
"room-tight": "It sounds dry and stuck to the speaker",
"room-natural": "It should sound like a real place",
hall: "It should sound far away and big",
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/audioFxPresets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,28 @@ export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
},
],
),
preset("chipmunk", "character", "Chipmunk", "Small, fast and squeaky.", [
{ type: "pitchshift", label: "Up High", params: { semitones: 7, mix: 1 } },
{ type: "highshelf", label: "Extra Sparkle", params: { frequency: 4000, gain: 3 } },
]),
preset("giant", "character", "Giant", "Huge, slow and deep.", [
{ type: "pitchshift", label: "Down Low", params: { semitones: -5, mix: 1 } },
{ type: "lowshelf", label: "Add Weight", params: { frequency: 150, gain: 4 } },
{
type: "compressor",
label: "Hold It Together",
params: { threshold: -18, ratio: 3, attack: 10, release: 120, knee: 6, makeup: 2, mix: 1 },
},
]),
preset("monster", "character", "Monster", "Deep, rough and too close.", [
{ type: "pitchshift", label: "Down Low", params: { semitones: -8, mix: 1 } },
{ type: "saturate", label: "Growl", params: { type: "tanh", threshold: -12, output: -1 } },
{
type: "reverb",
label: "Right Behind You",
params: { size: 0.3, damping: 0.5, wet: 0.22, dry: 0.85 },
},
]),

// ---------------------------------------------------------------- space --
preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [
Expand Down
136 changes: 136 additions & 0 deletions packages/core/src/audioGroups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
audioGroupOf,
ensureAudioGroupInertStyle,
HF_AUDIO_GROUP_ATTR,
resolveAudioGroups,
} from "./audioGroups.js";

beforeEach(() => {
document.body.innerHTML = "";
// The inert stylesheet is injected once per document, so a leftover from an
// earlier test would carry the assertion for the one after it.
document.getElementById("__hf-audio-group-inert")?.remove();
});

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

// The mirror of resolveAudioGroups' own video case. These two readers used to
// disagree here: the resolver saw no group, this one answered "voiceover", so
// preview routed a track through a bus the export would never build (the
// render enforces audio-only in audioMixer).
it("returns null for a video, matching resolveAudioGroups", () => {
document.body.innerHTML = `<video id="v-1" data-audio-group="voiceover"></video>`;
const el = document.getElementById("v-1") as Element;
expect(audioGroupOf(el)).toBeNull();
expect(resolveAudioGroups(document)).toEqual([]);
});

it("returns null for an empty attribute, not an empty string", () => {
document.body.innerHTML = `<audio id="vo-1" data-audio-group=""></audio>`;
expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBeNull();
expect(resolveAudioGroups(document)).toEqual([]);
});

// Groups do not nest, and the group element is not a member of itself.
it("returns null for the group element even when it carries the attribute", () => {
document.body.innerHTML = `<hf-audio-group id="bus" data-audio-group="other"></hf-audio-group>`;
expect(audioGroupOf(document.getElementById("bus") as Element)).toBeNull();
});
});

describe("ensureAudioGroupInertStyle", () => {
it("takes the group element out of layout", () => {
document.body.innerHTML = `<hf-audio-group id="voiceover"></hf-audio-group>`;
const el = document.getElementById("voiceover") as HTMLElement;
ensureAudioGroupInertStyle(document);
expect(getComputedStyle(el).display).toBe("none");
});

// An unknown custom element is an ordinary inline box, so in a flex or grid
// root it takes a slot: a gap, a justify-content share, and every
// :nth-child after it shifts. An author rule must not be able to put it
// back — and an id selector outranks this rule's type selector no matter
// which stylesheet came last, so `!important` is the only thing holding the
// contract. Dropping it makes this case fail.
it("beats an author rule that outranks it on specificity", () => {
document.head.insertAdjacentHTML(
"beforeend",
`<style id="author">#voiceover{display:flex}</style>`,
);
document.body.innerHTML = `<hf-audio-group id="voiceover"></hf-audio-group>`;
ensureAudioGroupInertStyle(document);
expect(getComputedStyle(document.getElementById("voiceover") as HTMLElement).display).toBe(
"none",
);
document.getElementById("author")?.remove();
});

it("injects once, however many times it is called", () => {
ensureAudioGroupInertStyle(document);
ensureAudioGroupInertStyle(document);
expect(document.querySelectorAll("#__hf-audio-group-inert")).toHaveLength(1);
});
});

describe(HF_AUDIO_GROUP_ATTR, () => {
it("is the attribute name membership is keyed on", () => {
expect(HF_AUDIO_GROUP_ATTR).toBe("data-audio-group");
});
});
105 changes: 105 additions & 0 deletions packages/core/src/audioGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* 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";

/**
* v1 membership, in one place: an `<audio>` carrying a NON-EMPTY
* `data-audio-group`.
*
* Both readers below derive from this string, because they disagreed when they
* did not. `resolveAudioGroups` scanned `audio[...]` while `audioGroupOf`
* returned the attribute off any element, so the same DOM answered "no group"
* for a `<video data-audio-group="voiceover">` in one and `"voiceover"` in the
* other. The render already enforces audio-only (see audioMixer's
* `type === "audio"` guard), so the disagreement was preview routing a track
* the export would never group.
*/
const MEMBER_SELECTOR = `audio[${HF_AUDIO_GROUP_ATTR}]`;

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(MEMBER_SELECTOR)) {
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 — the same predicate
* `resolveAudioGroups` scans with, so the two can never disagree about a given
* element.
*
* Non-`<audio>` returns null: video is out of scope in v1, and so is
* `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.
*/
export function audioGroupOf(el: Element): string | null {
if (el.tagName?.toLowerCase() !== "audio") return null;
return el.getAttribute(HF_AUDIO_GROUP_ATTR) || null;
}

/**
* Make `<hf-audio-group>` inert, once per document.
*
* The element is metadata — an id, a label, a chain, an automation lane — and
* carries no content, but "no content" is not "no box": it is still an unknown
* custom element, so in a flex or grid composition root it counts as an item
* (taking a `gap`, shifting `justify-content`, moving every `:nth-child` after
* it), and in inline formatting it can still open a line box. Authored layout
* 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
* runtime rather than the compiler so preview and render share one source.
*/
export function ensureAudioGroupInertStyle(doc: Document): void {
const STYLE_ID = "__hf-audio-group-inert";
if (!doc?.head || doc.getElementById(STYLE_ID)) return;
const style = doc.createElement("style");
style.id = STYLE_ID;
style.textContent = `${HF_AUDIO_GROUP_TAG}{display:none!important}`;
doc.head.appendChild(style);
}
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
5 changes: 5 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { loadExternalCompositions, loadInlineTemplateCompositions } from "./comp
import { applyCaptionOverrides } from "./captionOverrides";
import { applyPositionEdits, installPositionEditsSeekReapply } from "./positionEdits";
import { applyVariableBindings } from "./applyVariableBindings";
import { ensureAudioGroupInertStyle } from "../audioGroups.js";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
Expand Down Expand Up @@ -131,6 +132,10 @@ export function initSandboxRuntimeModular(): void {
// custom props) — values are fixed for the page's lifetime, so applying
// once at init keeps renders deterministic and seeks safe.
applyVariableBindings(document);
// `<hf-audio-group>` is metadata, so it must not occupy a box — see
// ensureAudioGroupInertStyle. Injected here, before timelines bind, so no
// captured frame ever sees the group as a layout item.
ensureAudioGroupInertStyle(document);
const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
setRuntimeProtocolFps(state.canonicalFps);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@ export const FX_PRESET_STYLE: Record<string, FxPresetStyle> = {
color: "hsl(288, 85%, 72%)",
family: FACE.theatrical,
},
// Small, fast and squeaky — tight and bright, nothing about it sits still.
chipmunk: {
type: "text-[12px] font-black uppercase tracking-tighter",
color: "hsl(54, 88%, 66%)",
family: FACE.geometric,
},
// Huge and slow — the biggest size here, set on a face bolted to a monument.
giant: {
type: "text-[18px] font-black tracking-[0.02em]",
color: "hsl(250, 72%, 68%)",
family: FACE.engraved,
},
// Rough and too close — heavy industrial caps, growled rather than shouted.
monster: {
type: "text-[15px] font-black uppercase tracking-[0.05em]",
color: "hsl(350, 78%, 64%)",
family: FACE.condensed,
},

// --- space: rooms. Light and wide, because that is what space looks like ---
"room-tight": {
Expand Down
Loading
Loading