feat(studio,core): mute groups, and hear-only-this that cannot reach the export (#3291)

B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).

Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.

Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.

Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.

Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).

Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-21 11:19:53 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent a01a5d7b3c
commit 0d26072e6c
24 changed files with 1056 additions and 264 deletions
@@ -0,0 +1,61 @@
import { useEffect, useMemo } from "react";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { usePlayerStore } from "../player/store/playerStore";
import { getTimelineElementDisplayLabel } from "../player/lib/timelineElementHelpers";
interface IframeWindow extends Window {
__hf?: { setAudioSolo?: (ids: readonly string[]) => void };
}
/**
* Pushes the studio's "Hear only this" set into the preview runtime whenever
* it changes. A dedicated push, not a DOM write: solo is session-only and
* must never touch an attribute (design doc §2.2 / the export-safety
* guarantee), so it can't ride `syncTimedElementVisibility`'s attribute-diff
* the way group mute does — see `window.__hf.setAudioSolo`.
*/
export function useAudioSoloBridge(previewIframeRef: { current: HTMLIFrameElement | null }): void {
const soloed = usePlayerStore((s) => s.soloed);
useEffect(() => {
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
win?.__hf?.setAudioSolo?.([...soloed]);
}, [soloed, previewIframeRef]);
}
/** One soloed id's display label — reads the live preview DOM directly (same
* approach as `patchLiveGroupAttribute`), since solo ids are never anywhere
* but the document's own element ids. A group carries its label on
* `data-label`; anything else falls back to the same label rule the
* timeline itself uses. */
function resolveSoloLabel(doc: Document | null | undefined, id: string): string {
const el = doc?.getElementById(id);
if (!el) return id;
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
return getTimelineElementDisplayLabel({ id, label: el.getAttribute("data-label") });
}
return getTimelineElementDisplayLabel({
id,
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
tag: el.tagName,
});
}
/**
* The transport bar's "Hear only this" banner text — `null` while nothing is
* soloed. One name when exactly one thing is soloed, `"N tracks"` otherwise
* (design doc §2.2's banner rule); "your export is not affected" is fixed
* copy the caller owns, this only resolves the variable half.
*/
export function useSoloBannerText(previewIframeRef: {
current: HTMLIFrameElement | null;
}): string | null {
const soloed = usePlayerStore((s) => s.soloed);
return useMemo(() => {
if (soloed.size === 0) return null;
if (soloed.size === 1) {
const doc = previewIframeRef.current?.contentDocument;
return resolveSoloLabel(doc, [...soloed][0]);
}
return `${soloed.size} tracks`;
}, [soloed, previewIframeRef]);
}
@@ -0,0 +1,36 @@
import { useEffect } from "react";
import type { MutableRefObject } from "react";
import { normalizeStudioCompositionPath, type StudioUrlState } from "../utils/studioUrlState";
/**
* One-time hydration of `activeCompPath` from the initial URL state, once the
* file tree has loaded (a path that isn't in the tree yet can't be
* validated). Runs exactly once — `hydrated` flips true whether or not the
* URL named a valid path, so a later file-tree change never re-fires it.
*/
export function useHydrateActiveCompPathFromUrl({
hydrated,
fileTreeLoaded,
fileTree,
initialUrlStateRef,
setActiveCompPath,
setHydrated,
}: {
hydrated: boolean;
fileTreeLoaded: boolean;
fileTree: string[];
initialUrlStateRef: MutableRefObject<StudioUrlState>;
setActiveCompPath: (updater: (current: string | null) => string | null) => void;
setHydrated: (value: boolean) => void;
}): void {
useEffect(() => {
if (hydrated) return;
if (!fileTreeLoaded) return;
const nextCompPath = normalizeStudioCompositionPath(
initialUrlStateRef.current.activeCompPath,
fileTree,
);
setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
setHydrated(true);
}, [hydrated, fileTree, fileTreeLoaded, initialUrlStateRef, setActiveCompPath, setHydrated]);
}