feat(studio,core): mute groups, and hear-only-this that cannot reach the export - #3291
Conversation
miga-heygen
left a comment
There was a problem hiding this comment.
Review
Approve. Independent read at exact head.
The critical export-safety invariant is correctly maintained: solo is session-only state that never touches any DOM attribute, never invokes the save path, and never flows into the render. Two explicit tests pin this (setAttribute/removeAttribute spy + saveProjectFilesWithHistory spy — both assert zero calls across toggle/add/clear). The mute/solo separation uses dedicated gain nodes (soloGain per member, muteGain per group) so toggles never fight scheduleVolumeLane's ramps.
isAudibleUnderSolo predicate is clean: empty set → audible, direct membership → audible, group membership → audible. Group bus is never attenuated by solo — a soloed member's path through its group stays open by construction. isGroupHalfLitUnderSolo correctly distinguishes fully-lit (group soloed) from half-lit (member soloed but group not). The syncAudioGroupMute diff pass keeps the preview's group bus in sync with mid-playback data-hidden toggles.
Solo banner UX correctly resolves labels from the live preview DOM. The audioSoloSlice toggle semantics (exclusive by default, ⌘/Ctrl-click for additive) match the design doc.
No issues. Ship it.
— Miga
5d5d596 to
291e0db
Compare
45fa9c8 to
461ba8e
Compare
291e0db to
f8bd6d1
Compare
461ba8e to
c714f9e
Compare
f8bd6d1 to
ed29b75
Compare
c714f9e to
2349cb5
Compare
ed29b75 to
19635c0
Compare
2967aea to
145a1cb
Compare
The base branch was changed.
145a1cb to
1711718
Compare
…the export
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>
1711718 to
8244019
Compare
B5 — mute and solo, on groups and tracks
Stacks on and depends on:
Group mute
Persisted as
data-hiddenon the<hf-audio-group>element itself (never written onto members — design doc §2.1's state-restoration warning). Studio action reuses B7's genericsetAudioGroupAttribute(setQuiet/setLive) rather than duplicatingtoggleTimelineTrackHidden's shape. Render: B4 already drops every member of adata-hiddengroup — confirmed by a newaudioMixer.test.tscase, no production change needed. Preview: a dedicatedmuteGainnode (groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle never fightsscheduleVolumeLane's ramps on the same param. Mid-playback toggles sync via a newsyncAudioGroupMutepass ininit.ts(a group carries nodata-start, so it's invisible to the existing visibility-node query). Members of a muted group get the strikethrough label treatment, display only.Solo — "Hear only this"
New session-only store slice (
audioSoloSlice,soloed: ReadonlySet<string>of clip/group ids, never track numbers, never serialized). Shared predicateisAudibleUnderSolo(packages/core/src/audioGroups.ts): audible only if the element or its own group is soloed. "Siblings, never ancestors" lives in the graph — solo gain is a per-element stage only, group buses are never attenuated by solo. Preview: dedicated per-elementsoloGaininwebAudioTransport.ts, pushed viawindow.__hf.setAudioSolo(a direct call, not an attribute write).media.ts's HTMLMedia fallback folds the same predicate into its per-tick volume computation. Half-lit group indicator for "not soloed itself, but a member is." Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove. Transport-bar banner ("Hearing only<label>— your export is not affected", Clear button).Export-safety
The most important property: toggling/adding/clearing solo never calls
setAttribute/removeAttributeon any element and never invokes the project save path — both asserted directly via spies inaudioSoloSlice.test.ts. Solo cannot reach an export by construction.Incidental fixes
useHydrateActiveCompPathFromUrlout ofApp.tsx(pre-existing, unrelated effect) to stay under the 600-line filesize cap after wiring the solo bridge in.useAudioSoloBridge.tsnow importsusePlayerStorefrom its concrete module instead of theplayer/barrel (which re-exportsPlayerControls.tsx— that's what closed the cycle).Gates
bun run buildcleanpackages/corefull suite: 2379/2379packages/studiofull suite: 4276/4294 (18 pre-existing todo)packages/engineaudioMixer.grouping.test.ts: 5/5🤖 Generated with Claude Code