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
32 changes: 32 additions & 0 deletions packages/core/src/audioGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,35 @@ export function ensureAudioGroupInertStyle(doc: Document): void {
style.textContent = `${HF_AUDIO_GROUP_TAG}{display:none!important}`;
doc.head.appendChild(style);
}

/**
* 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 @@ -43,6 +43,7 @@ import { ensureAudioGroupInertStyle } from "../audioGroups.js";
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 @@ -182,6 +183,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 @@ -1929,6 +1944,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 @@ -1993,6 +2023,7 @@ export function initSandboxRuntimeModular(): void {
scheduleWebAudioForActiveClips();
}
hiddenAudioDirty = false;
syncAudioGroupMute();
};

const syncMediaForCurrentState = () => {
Expand Down Expand Up @@ -2053,6 +2084,7 @@ export function initSandboxRuntimeModular(): void {
webAudio.setElementVolume(el, authorVolume),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
isWebAudioRouted: (el) => webAudio.routesElement(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 @@ -223,6 +223,11 @@ export function syncRuntimeMedia(params: {
/** Native media routed through WebAudio keeps its upstream element volume at
* unity; do not mistake that transport write for an authored volume edit. */
isWebAudioRouted?: (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 @@ -332,7 +337,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, authorVolume);
Expand Down
Loading
Loading