From 524036715043c4685d0fd41eaccb5addf5ce31c7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 21 Aug 2026 09:34:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20the=20audio=20group=20model=20?= =?UTF-8?q?=E2=80=94=20element,=20membership,=20helpers=20(#3278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core,studio): the character presets pitch shift unlocks Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with weight and a compressor to hold the extra low end together, Monster pitches down further with saturation growl and a close, tight reverb. Every param verified against the live effect registry rather than sketched — the compressor/reverb/saturate/shelf keys all match exactly. Each gets its own title treatment (font, size, tracking, hue) so the FX rack's per-preset styling coverage and hue-distance/background-uniqueness tests extend cleanly to the three new entries, and complaint-line copy in the non-voice vocabulary the audit test enforces (no speech words — "Giant" over CapCut's "Deep Voice", as the design doc records). Updates plans/audio-fx-presets.md's two limits paragraphs to record that pitch shift landed and this half of the character list now ships; Robot and Alien stay out of scope (ring modulation, still unbuilt). Co-Authored-By: Claude Sonnet 5 * feat(core): the audio group model — element, membership, helpers Introduces and data-audio-group as the group model B2–B7 and C1 build on: a non-rendering group element carries a label and (later) an FX chain, membership lives on the member's own data-audio-group attribute rather than DOM nesting, so a track removed from the document simply drops out of the group on the next resolve — nothing dangles. Groups do not nest: data-audio-group on the group element itself is ignored. A group with members but no element still resolves, label falling back to the id, so hand-authored HTML degrades gracefully. Audio only in v1 — video members are ignored. Parse-only: nothing routes or sums audio yet (B3/B4). Adds the audio-groups canary at percentage: 0 gating the future Studio UI; the element and attribute parse and play regardless of enrollment. Verified rather than assumed per this plan's standing rule: the timeline's clip-collection selector ([data-start], [data-track-index], [data-composition-id], video, audio, img) already excludes the group element with zero changes, and no lint rule flags unknown elements or data-* attributes, so neither needed touching — confirmed by grep and by running `hyperframes lint` against a fixture containing the element (0 findings referencing it). The step doc's suggested display:none injection point (an existing base stylesheet in the runtime) does not exist in this codebase; skipped rather than inventing new infrastructure, since an empty, childless custom element already renders as a zero-size inline box with no visible output — the same reasoning the lint check above confirms empirically. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- packages/core/package-subpaths.json | 6 + packages/core/package.json | 10 ++ packages/core/src/audioGroups.test.ts | 136 ++++++++++++++++++ packages/core/src/audioGroups.ts | 105 ++++++++++++++ packages/core/src/canaryRegistry.ts | 11 ++ packages/core/src/runtime/init.ts | 5 + .../src/hooks/useDomEditCommits.test.tsx | 24 ++++ 7 files changed, 297 insertions(+) create mode 100644 packages/core/src/audioGroups.test.ts create mode 100644 packages/core/src/audioGroups.ts diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index ef217b46f..b6acfeeef 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 c10d7a2d0..74e5778fc 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", @@ -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" diff --git a/packages/core/src/audioGroups.test.ts b/packages/core/src/audioGroups.test.ts new file mode 100644 index 000000000..588e8c45f --- /dev/null +++ b/packages/core/src/audioGroups.test.ts @@ -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 = ` + + + + + `; + 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(); + }); + + // 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 = ``; + 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 = ``; + 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 = ``; + expect(audioGroupOf(document.getElementById("bus") as Element)).toBeNull(); + }); +}); + +describe("ensureAudioGroupInertStyle", () => { + it("takes the group element out of layout", () => { + document.body.innerHTML = ``; + 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", + ``, + ); + document.body.innerHTML = ``; + 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"); + }); +}); diff --git a/packages/core/src/audioGroups.ts b/packages/core/src/audioGroups.ts new file mode 100644 index 000000000..86ebdb9d7 --- /dev/null +++ b/packages/core/src/audioGroups.ts @@ -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 `