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
32 changes: 32 additions & 0 deletions packages/core/src/audioGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,35 @@ export function audioGroupOf(el: Element): string | null {
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
return typeof el.getAttribute === "function" ? el.getAttribute(HF_AUDIO_GROUP_ATTR) : null;
}

/**
* Solo ("Hear only this") predicate — shared by the studio store (which owns
* the `soloed` set and the UI's lit/half-lit state) and the preview transport
* (which turns it into gain). An element is audible while any solo is active
* only if IT is soloed, or its OWN group is soloed (group solo = members
* solo). There is no "ancestor" to reach up to in this data model — a group
* bus is never itself attenuated by solo, so a soloed member's path through
* its group stays open by construction; this predicate only ever gates the
* member's own gain. No solo active at all is the one path that returns true
* unconditionally.
*/
export function isAudibleUnderSolo(
soloed: ReadonlySet<string>,
id: string,
groupId?: string | null,
): boolean {
if (soloed.size === 0) return true;
if (soloed.has(id)) return true;
return Boolean(groupId && soloed.has(groupId));
}

/** Half-lit: this group itself isn't soloed, but at least one of its members
* is — the display-only signal that "some of what's under here still plays". */
export function isGroupHalfLitUnderSolo(
soloed: ReadonlySet<string>,
groupId: string,
memberIds: readonly string[],
): boolean {
if (soloed.size === 0 || soloed.has(groupId)) return false;
return memberIds.some((id) => soloed.has(id));
}
32 changes: 32 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { applyVariableBindings } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { HF_AUDIO_GROUP_TAG, audioGroupOf, isAudibleUnderSolo } from "../audioGroups";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
import type {
Expand Down Expand Up @@ -175,6 +176,20 @@ export function initSandboxRuntimeModular(): void {
void webAudio.init().then((ok) => {
webAudioReady = ok;
});
// Studio's "Hear only this" push channel — session-only, so it rides a
// dedicated `__hf` field (mirrors `colorGrading`'s lazy-init pattern) rather
// than a DOM attribute: solo must never be written to the document (design
// doc §2.2 / the export-safety guarantee), so there is nothing here for
// `syncTimedElementVisibility`'s attribute-diffing to key off. Kept in this
// closure too (not just inside `webAudio`) so `syncRuntimeMedia`'s
// HTMLMedia-fallback path (video/non-transport audio) can apply the same
// predicate per tick, the same split A2 used for `data-hidden`.
let soloedIds: ReadonlySet<string> = new Set();
window.__hf = window.__hf || {};
window.__hf.setAudioSolo = (ids) => {
soloedIds = new Set(ids);
webAudio.setSolo(soloedIds);
};
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
Expand Down Expand Up @@ -1925,6 +1940,21 @@ export function initSandboxRuntimeModular(): void {
const nodeAffectsAudio = (node: HTMLElement): boolean =>
node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null;

// An `<hf-audio-group>` carries no `data-start`, so it is never among
// `visibilityNodes` above — group mute needs its own small diff pass.
// Preview-side only (render reads the group's `data-hidden` directly at
// export time, per B4); this just keeps the live WebAudio group bus in
// sync with a `data-hidden` toggle made mid-playback.
const groupHiddenLast = new WeakMap<Element, boolean>();
const syncAudioGroupMute = () => {
for (const groupEl of document.querySelectorAll(HF_AUDIO_GROUP_TAG)) {
const hidden = groupEl.hasAttribute("data-hidden");
if (groupHiddenLast.get(groupEl) === hidden) continue;
groupHiddenLast.set(groupEl, hidden);
if (groupEl.id) webAudio.setGroupMuted(groupEl.id, hidden);
}
};

const syncTimedElementVisibility = (
currentTime: number,
visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")),
Expand Down Expand Up @@ -1989,6 +2019,7 @@ export function initSandboxRuntimeModular(): void {
scheduleWebAudioForActiveClips();
}
hiddenAudioDirty = false;
syncAudioGroupMute();
};

const syncMediaForCurrentState = () => {
Expand Down Expand Up @@ -2054,6 +2085,7 @@ export function initSandboxRuntimeModular(): void {
forceSync,
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)),
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ export function syncRuntimeMedia(params: {
* plays it); not owned → leave audible (HTMLMedia fallback). Per-element, not a
* global flag, so a not-yet-claimed track isn't muted by other tracks. */
isWebAudioOwned?: (el: HTMLMediaElement) => boolean;
/** "Hear only this" gate for the HTMLMedia fallback path (video / any audio
* not owned by the Web Audio transport, which applies its own dedicated
* solo gain instead — see `WebAudioTransport.setSolo`). Absent when solo
* isn't wired up at all, which reads as "always audible". */
isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean;
forceSync?: boolean;
}): void {
const forceMuteAll = !!(params.outputMuted || params.userMuted);
Expand Down Expand Up @@ -312,7 +317,11 @@ export function syncRuntimeMedia(params: {
// A data-hidden ancestor is silent in the export (audioMixer.ts drops
// it); preview must match. Folded into the per-tick volume, not
// el.muted (RULES trap: el.muted is the transport's ownership flag).
const effectiveVolume = el.closest("[data-hidden]") ? 0 : clampVolume(authorVolume * userVol);
// Solo rides the same fold for the same reason — never el.muted, and
// never touching any attribute (it is session-only, unlike hidden).
const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false;
const effectiveVolume =
el.closest("[data-hidden]") || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
el.volume = effectiveVolume;
lastRuntimeAppliedVolume.set(el, effectiveVolume);
params.onElementVolume?.(el, effectiveVolume);
Expand Down
172 changes: 142 additions & 30 deletions packages/core/src/runtime/webAudioTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,23 +527,26 @@ describe("WebAudioTransport", () => {
return el;
}

/** The group's own input gain is built lazily on the first member —
* index 1 in creation order (that member's gain is index 0). */
/** The group's own input gain is built lazily on the first member — index
* 2 in creation order (that member's own gain is 0, its solo gain 1). */
const firstGroupInput = (mock: ReturnType<typeof createGroupMockAudioContext>) =>
mock.gainNodes[1]!;
mock.gainNodes[2]!;

beforeEach(() => {
document.body.innerHTML = "";
});

it("routes an ungrouped member straight to master, unchanged", async () => {
it("routes an ungrouped member straight to master, through its own solo gain", async () => {
const { transport, mock, gen } = setupGroupTransport();

await scheduleGrouped(transport, gen, "solo");

// One gain node — the member's own — connected directly to master.
expect(mock.gainNodes).toHaveLength(1);
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain);
// Member gain, then its dedicated solo gain (B5) — never straight to master.
expect(mock.gainNodes).toHaveLength(2);
const [memberGain, soloGain] = mock.gainNodes;
expect(memberGain!.connect).toHaveBeenCalledWith(soloGain);
expect(memberGain!.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(soloGain!.connect).toHaveBeenCalledWith(mock.masterGain);
});

it("two members of the same group land on ONE shared group gain, not master directly", async () => {
Expand All @@ -552,47 +555,56 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");

// Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the
// group's own input/output gain pair (B7's meter taps `output`),
// built inside a's schedule call.
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4);
const groupInput = firstGroupInput(mock);
const groupOutput = mock.gainNodes[2]!;
// Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3),
// muteGain(4) — the group bus is built lazily inside a's schedule call.
// Then b: b-gain(5), b-solo(6).
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(7);
const aGain = mock.gainNodes[0]!;
const bGain = mock.gainNodes[3]!;

// Neither member connects straight to master — both feed the shared bus.
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
expect(bGain.connect).toHaveBeenCalledWith(groupInput);
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);

// The bus's input never reaches master directly — it lands on the
// output gain (the dry passthrough, since neither member's group has a
// chain-bearing `<hf-audio-group>`), and THAT reaches master.
const aSolo = mock.gainNodes[1]!;
const groupInput = firstGroupInput(mock);
const groupOutput = mock.gainNodes[3]!;
const muteGain = mock.gainNodes[4]!;
const bGain = mock.gainNodes[5]!;
const bSolo = mock.gainNodes[6]!;

// Each member feeds its own solo gain, and both solo gains feed the
// shared bus — neither connects straight to master.
expect(aGain.connect).toHaveBeenCalledWith(aSolo);
expect(bGain.connect).toHaveBeenCalledWith(bSolo);
expect(aSolo.connect).toHaveBeenCalledWith(groupInput);
expect(bSolo.connect).toHaveBeenCalledWith(groupInput);
expect(aSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(bSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);

// The bus's input never reaches master directly — it lands on the mute
// gain (B5) first (the dry passthrough, since neither member's group has
// a chain-bearing `<hf-audio-group>`), then the output gain, then master.
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(groupInput.connect).toHaveBeenCalledWith(groupOutput);
expect(groupInput.connect).toHaveBeenCalledWith(muteGain);
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});

it("a second member of an already-open group does not rebuild the group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();

await scheduleGrouped(transport, gen, "a", "vo");
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group-input
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + a-solo + group-input/output/mute
await scheduleGrouped(transport, gen, "b", "vo");

// Only b's own gain is new — no second group-input gain minted.
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1);
// Only b's own gain and its solo gain are new — no second group bus minted.
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2);
});

it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
const { transport, mock, gen } = setupGroupTransport();

await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element

const groupOutput = mock.gainNodes[2]!;
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(groupOutput);
const muteGain = mock.gainNodes[4]!;
const groupOutput = mock.gainNodes[3]!;
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(muteGain);
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});

Expand Down Expand Up @@ -629,6 +641,106 @@ describe("WebAudioTransport", () => {
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
});

describe('solo — "Hear only this" (B5)', () => {
it("silences a non-soloed member via its own solo gain, without touching the group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
const aSolo = mock.gainNodes[1]!;
const bSolo = mock.gainNodes[6]!;
const groupInput = firstGroupInput(mock);

transport.setSolo(new Set(["other-clip"]));

expect(aSolo.gain.value).toBe(0);
expect(bSolo.gain.value).toBe(0);
// The group's own bus is never attenuated by solo — only the member
// gain stage is (design doc §2.2: "never ancestors").
expect(groupInput.gain.value).toBe(1);
});

it("soloing a member of a group leaves the group's gain untouched, and only that member is audible", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
const aSolo = mock.gainNodes[1]!;
const bSolo = mock.gainNodes[6]!;
const groupInput = firstGroupInput(mock);

transport.setSolo(new Set(["a"]));

expect(aSolo.gain.value).toBe(1);
expect(bSolo.gain.value).toBe(0); // sibling stays silent
expect(groupInput.gain.value).toBe(1); // group bus itself untouched
});

it("soloing the GROUP id makes every member audible (group solo = members solo)", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
const aSolo = mock.gainNodes[1]!;
const bSolo = mock.gainNodes[6]!;

transport.setSolo(new Set(["vo"]));

expect(aSolo.gain.value).toBe(1);
expect(bSolo.gain.value).toBe(1);
});

it("clearing solo (empty set) restores every member to audible", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const aSolo = mock.gainNodes[1]!;

transport.setSolo(new Set(["other"]));
expect(aSolo.gain.value).toBe(0);

transport.setSolo(new Set());
expect(aSolo.gain.value).toBe(1);
});

it("a newly scheduled member picks up an already-active solo immediately", async () => {
const { transport, mock, gen } = setupGroupTransport();
transport.setSolo(new Set(["a"]));

await scheduleGrouped(transport, gen, "a");
await scheduleGrouped(transport, gen, "b");

expect(mock.gainNodes[1]!.gain.value).toBe(1); // a's own solo gain
expect(mock.gainNodes[3]!.gain.value).toBe(0); // b's own solo gain
});
});

describe("group mute (B5)", () => {
it("a group created with data-hidden already set starts muted (mute gain at 0)", async () => {
document.body.innerHTML = `<hf-audio-group id="vo" data-hidden></hf-audio-group>`;
const { transport, mock, gen } = setupGroupTransport();

await scheduleGrouped(transport, gen, "a", "vo");

const muteGain = mock.gainNodes[4]!;
expect(muteGain.gain.value).toBe(0);
});

it("setGroupMuted toggles the mute gain on an active group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const muteGain = mock.gainNodes[4]!;
expect(muteGain.gain.value).toBe(1);

transport.setGroupMuted("vo", true);
expect(muteGain.gain.value).toBe(0);

transport.setGroupMuted("vo", false);
expect(muteGain.gain.value).toBe(1);
});

it("setGroupMuted on a group with no active member is a no-op, not a throw", () => {
const { transport } = setupGroupTransport();
expect(() => transport.setGroupMuted("never-played", true)).not.toThrow();
});
});

describe("groupLevel meter (B7)", () => {
it("groupLevel returns null for an unknown/idle group id", () => {
const { transport } = setupGroupTransport();
Expand Down
Loading
Loading