From 2199f55c3bd8b5f45c4d80260feafd4e31a7b5ef Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 00:58:07 -0700 Subject: [PATCH] feat(studio,core)!: remove mute and solo from tracks and groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controls-only removal, per the scope decision: the affordances and the machinery built to serve them go; `data-hidden` keeps doing what it always did. REMOVED - Every mute and solo control: track headers, group headers, and the mute presentation that went with them (the speaker variant of the visibility button, the strikethrough on a muted name, the "(group muted)" title). - Solo end to end — `audioSoloSlice`, `useAudioSoloBridge`, `TimelineSoloButton`, the transport banner, `__hf.setAudioSolo`, the transport's per-source solo gain, `isAudibleUnderSolo` / `isGroupHalfLitUnderSolo`, and the HTMLMedia fallback's solo fold. Four modules deleted outright. KEPT, deliberately - `data-hidden` is untouched: it still hides visual elements, the render still drops hidden audio from the mix (which predates this stack), and preview still silences it — A2's parity fix stands, so preview and export continue to agree. - Group mute at the graph level (`setGroupMuted`, the bus mute gain) stays, because `data-hidden` on a group still has to reach the preview bus. Only the button that wrote it is gone. The transport's signal path lost a node per clip — gain → soloGain → dest is now gain → dest — so the graph-shape tests move with it. Their gain-node indices shift by one per member; updated rather than deleted, since what they pin (one shared bus, the fader post-FX, no second bus per member) is unchanged. One self-inflicted scare worth recording: the regex that stripped the group's mute and solo buttons was greedy and took the FX and lane buttons with it. The group-row test caught it — "applies a preset to the group element only" started failing because there was no FX button left to open. Restored from HEAD. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, core 2387 green, studio 4326 green, full `bun run test` green. --- packages/core/src/audioGroups.ts | 32 ---- packages/core/src/runtime/init.ts | 16 +- packages/core/src/runtime/media.ts | 14 +- .../src/runtime/webAudioTransport.test.ts | 143 ++++-------------- .../core/src/runtime/webAudioTransport.ts | 45 +----- packages/core/src/runtime/window.d.ts | 6 - packages/studio/src/App.tsx | 2 - .../studio/src/components/nle/PreviewPane.tsx | 1 - .../studio/src/hooks/useAudioSoloBridge.ts | 61 -------- .../src/player/components/PlayerControls.tsx | 32 ---- .../player/components/TimelineGroupHeader.tsx | 87 +---------- .../player/components/TimelineGroupRow.tsx | 31 +--- .../player/components/TimelineSoloButton.tsx | 42 ----- .../components/TimelineTrackHeader.test.tsx | 16 +- .../player/components/TimelineTrackHeader.tsx | 18 +-- .../components/TimelineTrackPlainHeader.tsx | 41 ++--- .../player/components/timelineViewModel.ts | 2 +- .../components/useTimelineTrackDerivations.ts | 2 +- .../src/player/hooks/useTimelinePlayer.ts | 4 +- .../src/player/lib/runtimeAudioId.test.ts | 72 +-------- .../src/player/lib/timelineElementHelpers.ts | 7 +- .../player/lib/timelineIframeHelpers.test.ts | 19 +-- .../src/player/lib/timelineIframeHelpers.ts | 18 +-- .../src/player/store/audioSoloSlice.test.ts | 128 ---------------- .../studio/src/player/store/audioSoloSlice.ts | 43 ------ .../studio/src/player/store/playerStore.ts | 6 +- 26 files changed, 68 insertions(+), 820 deletions(-) delete mode 100644 packages/studio/src/hooks/useAudioSoloBridge.ts delete mode 100644 packages/studio/src/player/components/TimelineSoloButton.tsx delete mode 100644 packages/studio/src/player/store/audioSoloSlice.test.ts delete mode 100644 packages/studio/src/player/store/audioSoloSlice.ts diff --git a/packages/core/src/audioGroups.ts b/packages/core/src/audioGroups.ts index 31f3cdb90..b3c8e7a3f 100644 --- a/packages/core/src/audioGroups.ts +++ b/packages/core/src/audioGroups.ts @@ -133,35 +133,3 @@ 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, - 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, - groupId: string, - memberIds: readonly string[], -): boolean { - if (soloed.size === 0 || soloed.has(groupId)) return false; - return memberIds.some((id) => soloed.has(id)); -} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 85b0c97b4..b1c781888 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -42,7 +42,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 { HF_AUDIO_GROUP_TAG } from "../audioGroups"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers"; import type { @@ -178,20 +178,7 @@ 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 = new Set(); window.__hf = window.__hf || {}; - window.__hf.setAudioSolo = (ids) => { - soloedIds = new Set(ids); - webAudio.setSolo(soloedIds); - }; // Canary states the HOST resolved, keyed by registry name. Core cannot // resolve one itself — bucketing needs an install id it has no access to — // so every runtime-visible flag arrives through this one channel rather than @@ -2116,7 +2103,6 @@ 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)), silenceHiddenAudio: silenceHiddenAudioEnabled(), onAutoplayBlocked: () => { if (state.mediaAutoplayBlockedPosted) return; diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index a3bafc64e..26d760f57 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -217,11 +217,6 @@ 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; /** Silence media under a `data-hidden` ancestor, matching the render. Opt-in: * the host pushes it via `__hf.setCanaries` when the `audio-track-mute` * canary is on. Absent/false = the shipped behaviour (hidden audio still @@ -329,16 +324,11 @@ export function syncRuntimeMedia(params: { // it); preview matches once the host opts in (`silenceHiddenAudio`, the // `audio-track-mute` canary — see init.ts). Folded into the per-tick // volume, not el.muted (RULES trap: el.muted is the transport's ownership - // flag). Solo rides the same fold for the same reason — never el.muted, - // and never touching any attribute (it is session-only, unlike hidden) — - // but is NOT gated: it is a session control with no shipped behaviour to - // preserve. + // flag). const silencedByHidden = params.silenceHiddenAudio ? el.closest("[data-hidden]") !== null : false; - const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false; - const effectiveVolume = - silencedByHidden || silencedBySolo ? 0 : clampVolume(authorVolume * userVol); + const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol); el.volume = effectiveVolume; lastRuntimeAppliedVolume.set(el, effectiveVolume); params.onElementVolume?.(el, effectiveVolume, authorVolume); diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index f41c4d435..be21002b0 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -724,25 +724,23 @@ describe("WebAudioTransport", () => { } /** 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). */ + * 1 in creation order (that member's own gain is 0). */ const firstGroupInput = (mock: ReturnType) => - mock.gainNodes[2]!; + mock.gainNodes[1]!; beforeEach(() => { document.body.innerHTML = ""; }); - it("routes an ungrouped member straight to master, through its own solo gain", async () => { + it("routes an ungrouped clip straight to master through its own gain", async () => { const { transport, mock, gen } = setupGroupTransport(); - await scheduleGrouped(transport, gen, "solo"); + await scheduleGrouped(transport, gen, "lone"); - // 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); + // One gain per clip now that solo is gone — it goes straight to master. + expect(mock.gainNodes).toHaveLength(1); + const [clipGain] = mock.gainNodes; + expect(clipGain!.connect).toHaveBeenCalledWith(mock.masterGain); }); it("two members of the same group land on ONE shared group gain, not master directly", async () => { @@ -751,27 +749,22 @@ describe("WebAudioTransport", () => { await scheduleGrouped(transport, gen, "a", "vo"); await scheduleGrouped(transport, gen, "b", "vo"); - // Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3), - // muteGain(4), fader(5) — the group bus is built lazily inside a's - // schedule call. Then b: b-gain(6), b-solo(7). - expect(mock.gainNodes.length).toBeGreaterThanOrEqual(8); + // Creation order for a: a-gain(0), groupInput(1), groupOutput(2), + // muteGain(3), fader(4) — the group bus is built lazily inside a's + // schedule call. Then b: b-gain(5). + expect(mock.gainNodes.length).toBeGreaterThanOrEqual(6); const aGain = mock.gainNodes[0]!; - const aSolo = mock.gainNodes[1]!; const groupInput = firstGroupInput(mock); - const groupOutput = mock.gainNodes[3]!; - const muteGain = mock.gainNodes[4]!; - const fader = mock.gainNodes[5]!; - const bGain = mock.gainNodes[6]!; - const bSolo = mock.gainNodes[7]!; + const groupOutput = mock.gainNodes[2]!; + const muteGain = mock.gainNodes[3]!; + const fader = mock.gainNodes[4]!; + const bGain = mock.gainNodes[5]!; - // 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); + // Both members feed the shared bus — neither connects straight to master. + 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 runs through the // chain (dry here — neither member's group has a chain-bearing @@ -789,11 +782,11 @@ describe("WebAudioTransport", () => { const { transport, mock, gen } = setupGroupTransport(); await scheduleGrouped(transport, gen, "a", "vo"); - const gainCountAfterFirst = mock.gainNodes.length; // a-gain + a-solo + group-input/output/mute + const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group input/output/mute/fader await scheduleGrouped(transport, gen, "b", "vo"); - // Only b's own gain and its solo gain are new — no second group bus minted. - expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2); + // Only b's own gain is new — no second group bus minted. + expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1); }); it("a group id with no matching element still gets a flat bus", async () => { @@ -801,9 +794,9 @@ describe("WebAudioTransport", () => { await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element - const muteGain = mock.gainNodes[4]!; - const groupOutput = mock.gainNodes[3]!; - const fader = mock.gainNodes[5]!; + const muteGain = mock.gainNodes[3]!; + const groupOutput = mock.gainNodes[2]!; + const fader = mock.gainNodes[4]!; expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(fader); expect(fader.connect).toHaveBeenCalledWith(muteGain); expect(muteGain.connect).toHaveBeenCalledWith(groupOutput); @@ -822,7 +815,7 @@ describe("WebAudioTransport", () => { await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull(); - expect(mock.gainNodes[5]!.gain.value).toBeCloseTo(0.4, 6); + expect(mock.gainNodes[4]!.gain.value).toBeCloseTo(0.4, 6); }); it("leaves the fader at unity when the group carries no data-volume", async () => { @@ -832,7 +825,7 @@ describe("WebAudioTransport", () => { // No throw wiring the group's automation reader against a real // element that carries no fx/automation attrs. await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull(); - expect(mock.gainNodes[5]!.gain.value).toBe(1); + expect(mock.gainNodes[4]!.gain.value).toBe(1); }); it("destroy() disposes every group bus", async () => { @@ -867,7 +860,7 @@ describe("WebAudioTransport", () => { document.body.innerHTML = ``; const { transport, mock, gen } = setupGroupTransport(); await scheduleGrouped(transport, gen, "a", "vo"); - const fader = mock.gainNodes[5]!; + const fader = mock.gainNodes[4]!; // Something moved the fader mid-pass (a ramp reaching its last point). fader.gain.value = 0; @@ -891,7 +884,7 @@ describe("WebAudioTransport", () => { document.body.innerHTML = ``; const { transport, mock, gen } = setupGroupTransport(); await scheduleGrouped(transport, gen, "a", "vo"); - const fader = mock.gainNodes[5]!; + const fader = mock.gainNodes[4]!; fader.gain.cancelScheduledValues = vi.fn(() => { throw new Error("param is not schedulable"); }); @@ -906,76 +899,6 @@ describe("WebAudioTransport", () => { expect(fader.gain.value).toBeCloseTo(0.5, 6); }); - 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[7]!; - 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[7]!; - 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[7]!; - - 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 = ``; @@ -983,14 +906,14 @@ describe("WebAudioTransport", () => { await scheduleGrouped(transport, gen, "a", "vo"); - const muteGain = mock.gainNodes[4]!; + const muteGain = mock.gainNodes[3]!; 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]!; + const muteGain = mock.gainNodes[3]!; expect(muteGain.gain.value).toBe(1); transport.setGroupMuted("vo", true); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index f46a59b83..da387148b 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -6,7 +6,7 @@ import { type AutomationTiming, } from "../audio/audioFxAutomation.js"; import { VOLUME_RANGE } from "../audioAutomation.js"; -import { audioGroupOf, isAudibleUnderSolo, readAudioGroupVolume } from "../audioGroups.js"; +import { audioGroupOf, readAudioGroupVolume } from "../audioGroups.js"; import { swallow } from "./diagnostics"; import { clampAudioGain } from "../audioGain.js"; import { getDebugSurface } from "./globals.js"; @@ -101,11 +101,6 @@ function scheduleVolumeLane( type ScheduledSourceBase = { el: HTMLMediaElement; gainNode: GainNode; - /** Solo ("Hear only this") attenuation — dedicated node, parallel to the - * volume gain, so a solo toggle never fights `scheduleVolumeLane`'s ramps - * on the same param (same hazard B5's group-mute gain was split out to - * avoid). 0 while silenced by an active solo elsewhere, 1 otherwise. */ - soloGain: GainNode; /** FX chain spliced between source and gain, when the element carries one. */ fx?: ElementFxHandle | null; compositionStart: number; @@ -171,10 +166,6 @@ export class WebAudioTransport { private _rate = 1; private _paused = true; private _playGeneration = 0; - // Session-only "Hear only this" set (clip ids and group ids). Never read - // from or written to any attribute — studio pushes it in directly via - // `setSolo`; see `isAudibleUnderSolo` for the exact predicate. - private _soloed: ReadonlySet = new Set(); async init(): Promise { try { @@ -506,7 +497,6 @@ export class WebAudioTransport { sourceNode.disconnect(); scheduled.fx?.dispose(); scheduled.gainNode.disconnect(); - scheduled.soloGain.disconnect(); } catch { // Already torn down. } @@ -559,10 +549,7 @@ export class WebAudioTransport { // output — the same order the offline render uses. Preview and render run // the identical graph builders, so what is heard here is what is written. const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing); - const soloGain = this._ctx.createGain(); - soloGain.gain.value = isAudibleUnderSolo(this._soloed, el.id, audioGroupOf(el)) ? 1 : 0; - gainNode.connect(soloGain); - soloGain.connect( + gainNode.connect( this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain, ); @@ -586,7 +573,6 @@ export class WebAudioTransport { sourceNode.disconnect(); fx?.dispose(); gainNode.disconnect(); - soloGain.disconnect(); return null; } @@ -600,7 +586,6 @@ export class WebAudioTransport { sourceNode, sourceKind: "buffer", gainNode, - soloGain, compositionStart, mediaStart, scheduledAt, @@ -682,7 +667,6 @@ export class WebAudioTransport { source.sourceNode.disconnect(); source.fx?.dispose(); source.gainNode.disconnect(); - source.soloGain.disconnect(); } catch { // already stopped } @@ -731,31 +715,6 @@ export class WebAudioTransport { if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume; } - /** - * Push the current "Hear only this" set and re-evaluate every active - * source's solo gain against it — a gain-stage update, never a graph - * rebuild (rule 3 of B5's step doc). Group buses are never touched here: - * per `isAudibleUnderSolo`, a group is never attenuated by solo, so a - * soloed member's path through its (unattenuated) group stays open by - * construction. - */ - setSolo(soloed: ReadonlySet): void { - this._soloed = soloed; - for (const source of this._activeSources) { - try { - source.soloGain.gain.value = isAudibleUnderSolo( - this._soloed, - source.el.id, - audioGroupOf(source.el), - ) - ? 1 - : 0; - } catch (err) { - swallow("webAudioTransport.setSolo", err); - } - } - } - isActive(): boolean { return this._activeSources.length > 0 && !this._paused; } diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 135f50600..e849a36cc 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -37,12 +37,6 @@ declare global { onSwallowed?: (label: string, err: unknown) => void; seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void; duration?: number; - /** - * Studio's "Hear only this" push: the full set of soloed clip/group ids, - * replaced wholesale on every change. Session-only by design — never - * read from or written to any document attribute. - */ - setAudioSolo?: (ids: readonly string[]) => void; /** * Canary states resolved by the HOST and pushed in, because core cannot * resolve one itself: bucketing needs an install id, which lives in the diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b29c5213a..6b23e6752 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -38,7 +38,6 @@ import { useToast } from "./hooks/useToast"; import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader"; import { useStudioUrlState } from "./hooks/useStudioUrlState"; import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration"; -import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge"; import { buildStudioContextValue, useGlobalFileDrop, @@ -82,7 +81,6 @@ export function StudioApp() { const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion(); const [blockPreview, setBlockPreview] = useState(null); const previewIframeRef = useRef(null); - useAudioSoloBridge(previewIframeRef); const activeCompPathRef = useRef(activeCompPath); activeCompPathRef.current = activeCompPath; const leftSidebarRef = useRef(null); diff --git a/packages/studio/src/components/nle/PreviewPane.tsx b/packages/studio/src/components/nle/PreviewPane.tsx index 42c6e742f..530f4ba95 100644 --- a/packages/studio/src/components/nle/PreviewPane.tsx +++ b/packages/studio/src/components/nle/PreviewPane.tsx @@ -156,7 +156,6 @@ export function PreviewPane({ disabled={timelineDisabled} isFullscreen={isFullscreen} onToggleFullscreen={toggleFullscreen} - previewIframeRef={iframeRef} /> diff --git a/packages/studio/src/hooks/useAudioSoloBridge.ts b/packages/studio/src/hooks/useAudioSoloBridge.ts deleted file mode 100644 index da790c514..000000000 --- a/packages/studio/src/hooks/useAudioSoloBridge.ts +++ /dev/null @@ -1,61 +0,0 @@ -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]); -} diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 6cfba31ed..ed879fbbf 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -6,7 +6,6 @@ import { liveTime, usePlayerStore } from "../store/playerStore"; import { trackStudioEvent } from "../../utils/studioTelemetry"; import { Tooltip } from "../../components/ui"; import { useMountEffect } from "../../hooks/useMountEffect"; -import { useSoloBannerText } from "../../hooks/useAudioSoloBridge"; import { ShortcutsPanel } from "./ShortcutsPanel"; import { SpeedMenu } from "./SpeedMenu"; import { VolumeControl } from "./VolumeControl"; @@ -154,34 +153,6 @@ const FullscreenButton = memo(function FullscreenButton({ ); }); -const SoloBanner = memo(function SoloBanner({ - previewIframeRef, -}: { - previewIframeRef: { current: HTMLIFrameElement | null }; -}) { - const bannerText = useSoloBannerText(previewIframeRef); - const clearSolo = usePlayerStore.getState().clearSolo; - if (bannerText === null) return null; - return ( -
- - Hearing only {bannerText} — your - export is not affected - - -
- ); -}); - /* ── Main component ──────────────────────────────────────────────── */ interface PlayerControlsProps { @@ -190,7 +161,6 @@ interface PlayerControlsProps { disabled?: boolean; isFullscreen?: boolean; onToggleFullscreen?: () => void; - previewIframeRef?: { current: HTMLIFrameElement | null }; } export const PlayerControls = memo(function PlayerControls({ @@ -199,7 +169,6 @@ export const PlayerControls = memo(function PlayerControls({ disabled = false, isFullscreen = false, onToggleFullscreen, - previewIframeRef, }: PlayerControlsProps) { const isPlaying = usePlayerStore((s) => s.isPlaying); const duration = usePlayerStore((s) => s.duration); @@ -252,7 +221,6 @@ export const PlayerControls = memo(function PlayerControls({ return (
- {previewIframeRef && }
void; - /** The group element's own `data-hidden` — mutes every member at once. */ - hidden: boolean; - onToggleHidden: () => void; - /** This group id is itself in the soloed set (fully lit). */ - isSoloed: boolean; - /** Not soloed itself, but at least one member is (half-lit). */ - isHalfLitSolo: boolean; /** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */ - onToggleSolo: (options?: { add?: boolean }) => void; /** C1: the group's serialized `data-fx-chain`, when set. */ fxChain?: string; onFxChainChange: (next: HfAudioFxChain) => void; onFxChainPreview?: (next: HfAudioFxChain) => void; /** Member clips, so hovering a preset auditions where the group sounds. */ auditionSpans?: readonly AuditionSpan[]; - /** Set the group mute on the running graph only, so an audition can lift it. */ - onSetMutedLive?: (muted: boolean) => void; onOpenFxRack: () => void; columnWidth: number; theme: TimelineTheme; @@ -39,7 +28,7 @@ interface TimelineGroupHeaderProps { /** * A group's own row header: caret (member disclosure) + `▤` + label + count + - * mute + solo + FX + `∿ n` (lane disclosure). + * FX + `∿ n` (lane disclosure). */ /** @@ -51,12 +40,10 @@ interface TimelineGroupHeaderProps { function GroupNameButton({ label, memberCount, - hidden, onOpenFxRack, }: { label: string; memberCount: number; - hidden: boolean; onOpenFxRack: () => void; }) { return ( @@ -75,13 +62,7 @@ function GroupNameButton({ - {/* Struck through, not merely dimmed — the designs are explicit that "a - muted track that only looks dim is a track someone re-mutes by - accident", and a muted GROUP silences every member at once, so it is - the most expensive one to misread. */} - - {label} - + {label}
{/* Line two: what you can DO to it. Its own row so the name is not squeezed to a few characters by five controls sharing 232px. */}
- - - ); -} diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index d11f2abd5..63474aadf 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { defaultTimelineTheme } from "./timelineTheme"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { type TimelineElement } from "../store/playerStore"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; @@ -757,20 +757,6 @@ describe("TimelineTrackHeader", () => { // The set is pushed straight into the runtime, which compares it against // `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns // false for every element, and soloing silences the whole preview. - it("solos by bare DOM id, not by the store key", () => { - enabledCanaries.add("audio-track-mute"); - const view = renderHeader({ - keyframeClip: VOICE, - animations: [], - expanded: false, - isAudioTrack: true, - }); - click(view.host, "Hear only this"); - expect([...usePlayerStore.getState().soloed]).toEqual(["voice-1"]); - act(() => view.root.unmount()); - usePlayerStore.getState().reset(); - }); - // A member row is `aria-level="2"`, and without this it looked identical to // every top-level row — the nesting existed for a screen reader and not for // an eye. B2's design called for the accent rail; only the semantics shipped. diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index dba23258f..67153ab52 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -5,7 +5,7 @@ import { type HfAudioFxChain, } from "@hyperframes/core/audio-fx"; import { classifyAudioName } from "@hyperframes/core/audio-carve"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { type TimelineElement } from "../store/playerStore"; import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; @@ -434,16 +434,6 @@ export function TimelineTrackHeader({ // left an audio clip's envelopes unreachable, since the track could not expand. const disclosable = lanes.length > 0 || automationRows.length > 0; const isKeyframeLayer = !!keyframeClip && disclosable; - // Solo is per-clip/per-group, never per track (design doc §2.2) — this header - // acts on the track's first clip as a pragmatic stand-in for "this track", - // the same simplification the mute button doesn't need to make (it patches - // every clip on the track at once). - // A bare DOM id, not the store key: the set lands in the runtime, which - // compares it against `el.id` (see `runtimeAudioId`). A track whose first - // clip has no DOM id simply has no solo button. - const soloTargetId = trackElements[0] ? runtimeAudioId(trackElements[0]) : null; - const soloed = usePlayerStore((s) => s.soloed); - const toggleSolo = usePlayerStore((s) => s.toggleSolo); // C1: the FX entry point. A single audio clip has one chain to point at; a // track holding several ungrouped ones has no single chain — the design @@ -525,11 +515,8 @@ export function TimelineTrackHeader({ showTrackLabel={showTrackLabel} isTrackHidden={isTrackHidden} isAudioTrack={isAudioTrack} - isGroupMuted={trackElements.some((el) => el.audioGroupHidden)} - isSoloed={soloTargetId !== null && soloed.has(soloTargetId)} - onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined} onToggleTrackHidden={onToggleTrackHidden} - // On the control line, beside mute and solo — not a third row. + // On the control line rather than a third row of its own. trailing={ <> {singleAudioClip && isCanaryEnabled("audio-fx-rack") && ( @@ -606,7 +593,6 @@ export function TimelineTrackHeader({ trackNumber={trackNumber} trackDisplayNumber={trackDisplayNumber} visible={!isAudioTrack} - isAudioTrack={isAudioTrack} onToggle={onToggleTrackHidden} /> diff --git a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx index 19a404569..a3bb3fcbd 100644 --- a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx @@ -1,21 +1,18 @@ import type React from "react"; -import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react"; -import { isCanaryEnabled } from "../../telemetry/canary"; +import { Eye, EyeSlash } from "@phosphor-icons/react"; import { Music } from "../../icons/SystemIcons"; -import { TimelineSoloButton } from "./TimelineSoloButton"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { TrackClipCount } from "./TrackClipCount"; import { trackDisplaySuffix } from "./timelineTrackDisplay"; -// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows. -// Gated: the relabel ships behind the canary, unlike the preview fix. -function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string { - if (showAsMute) return hidden ? "Muted" : "Mute"; +// Hide, plainly. The speaker variant was the mute presentation; with mute gone +// this is the visibility eye it always was, and audio rows do not render it. +function visibilityButtonLabel(hidden: boolean, suffix: string): string { return hidden ? `Show track${suffix}` : `Hide track${suffix}`; } -function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) { - const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye; +function visibilityButtonIcon(hidden: boolean) { + const Icon = hidden ? EyeSlash : Eye; return
diff --git a/packages/studio/src/player/components/timelineViewModel.ts b/packages/studio/src/player/components/timelineViewModel.ts index 36cd0ae17..d95196ba1 100644 --- a/packages/studio/src/player/components/timelineViewModel.ts +++ b/packages/studio/src/player/components/timelineViewModel.ts @@ -19,7 +19,7 @@ function hasKeyframedTimelineClips( * for the same reason a keyframed clip does — a row whose name has nowhere else * to go. A track row survives a narrow gutter because its CLIPS carry the name * on the bar; a group row has no clips at all, so in the 80px gutter its label - * rendered at zero width and its solo, FX and lane buttons were clipped off the + * rendered at zero width and its FX and lane buttons were clipped off the * side. * * Widening the column for the whole timeline, rather than letting just the diff --git a/packages/studio/src/player/components/useTimelineTrackDerivations.ts b/packages/studio/src/player/components/useTimelineTrackDerivations.ts index 0c230dfd8..100bb8416 100644 --- a/packages/studio/src/player/components/useTimelineTrackDerivations.ts +++ b/packages/studio/src/player/components/useTimelineTrackDerivations.ts @@ -22,7 +22,7 @@ export interface TimelineTrackGroupInfo { * * Collapsing a group stops emitting its member rows into `tracks`, so anything * that recovered member elements by looking them up there got an empty list in - * the default (collapsed) state — silently disabling half-lit solo, the + * the default (collapsed) state — silently disabling the * automation-lane count, and the bus strip's member labels. Membership is not * a display concern, so it does not travel through the display list. */ diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 04f1b5f78..cd0a9121f 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -232,8 +232,8 @@ export function useTimelinePlayer() { } catch {} }, []); const applyPreviewAudioState = useCallback(() => { - const { audioMuted, audioVolume, soloed } = usePlayerStore.getState(); - applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume, soloed); + const { audioMuted, audioVolume } = usePlayerStore.getState(); + applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume); }, []); const play = useCallback(() => { stopRAFLoop(); diff --git a/packages/studio/src/player/lib/runtimeAudioId.test.ts b/packages/studio/src/player/lib/runtimeAudioId.test.ts index 9c9150b10..1c88e10f9 100644 --- a/packages/studio/src/player/lib/runtimeAudioId.test.ts +++ b/packages/studio/src/player/lib/runtimeAudioId.test.ts @@ -12,12 +12,7 @@ */ import { describe, expect, it } from "vitest"; -import { - audioGroupOf, - isAudibleUnderSolo, - isGroupHalfLitUnderSolo, - resolveAudioGroups, -} from "@hyperframes/core/audio-groups"; +import { resolveAudioGroups } from "@hyperframes/core/audio-groups"; import { parseTimelineFromDOM } from "./timelineDOM"; import { runtimeAudioId } from "./timelineElementHelpers"; @@ -35,51 +30,6 @@ const COMPOSITION = ` `; -describe("solo ids cross into the runtime", () => { - it("keeps the soloed clip audible and silences the rest", () => { - const doc = docWith(COMPOSITION); - const elements = parseTimelineFromDOM(doc, 30); - const voice1 = elements.find((el) => el.domId === "voice-1"); - expect(voice1).toBeDefined(); - // The store key is NOT the runtime's id space — that is the whole point. - expect(voice1?.key).not.toBe("voice-1"); - - const soloTargetId = runtimeAudioId(voice1 ?? {}); - expect(soloTargetId).toBe("voice-1"); - const soloed = new Set([soloTargetId as string]); - - const audible = (id: string) => { - const el = doc.getElementById(id); - expect(el).not.toBeNull(); - return isAudibleUnderSolo(soloed, (el as Element).id, audioGroupOf(el as Element)); - }; - expect(audible("voice-1")).toBe(true); - expect(audible("music-bed")).toBe(false); - // Soloing a member does not open its sibling — group solo is the other button. - expect(audible("voice-2")).toBe(false); - }); - - it("soloing the group opens every member", () => { - const doc = docWith(COMPOSITION); - const group = resolveAudioGroups(doc)[0]; - const soloed = new Set([group.id]); - for (const id of group.memberIds) { - const el = doc.getElementById(id) as Element; - expect(isAudibleUnderSolo(soloed, el.id, audioGroupOf(el))).toBe(true); - } - const bed = doc.getElementById("music-bed") as Element; - expect(isAudibleUnderSolo(soloed, bed.id, audioGroupOf(bed))).toBe(false); - }); - - it("a composite key matches nothing — the regression this file exists for", () => { - const doc = docWith(COMPOSITION); - const voice1 = parseTimelineFromDOM(doc, 30).find((el) => el.domId === "voice-1"); - const soloed = new Set([voice1?.key ?? ""]); - const el = doc.getElementById("voice-1") as Element; - expect(isAudibleUnderSolo(soloed, el.id, audioGroupOf(el))).toBe(false); - }); -}); - describe("group membership ids cross into the runtime", () => { it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => { const doc = docWith(COMPOSITION); @@ -96,25 +46,7 @@ describe("group membership ids cross into the runtime", () => { for (const id of memberIds) expect(clipIds).toContain(id); }); - // TimelineGroupRow's half-lit state ("some of what's under here still - // plays") compares its member list against the same soloed set. Built from - // store keys it never matched, so soloing a member lit nothing on its group. - it("half-lights the group when one member is soloed", () => { - const doc = docWith(COMPOSITION); - const members = parseTimelineFromDOM(doc, 30).filter((el) => el.audioGroup === "voiceover"); - const memberIds = members.map(runtimeAudioId).filter((id): id is string => id !== null); - expect(memberIds).toEqual(["voice-1", "voice-2"]); - - const soloed = new Set(["voice-1"]); - expect(isGroupHalfLitUnderSolo(soloed, "voiceover", memberIds)).toBe(true); - // Store keys are the shape that silently failed. - const storeKeys = members.map((el) => el.key ?? el.id); - expect(isGroupHalfLitUnderSolo(soloed, "voiceover", storeKeys)).toBe(false); - // Soloing the group itself is lit, not half-lit. - expect(isGroupHalfLitUnderSolo(new Set(["voiceover"]), "voiceover", memberIds)).toBe(false); - }); - - it("an element with no DOM id is not groupable or soloable", () => { + it("an element with no DOM id is not groupable", () => { const doc = docWith(`
diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index 4ee0f4c93..24c93f3dd 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -361,13 +361,12 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s * Studio addresses rows by `buildTimelineElementKey`'s composite * `#`, but everything audio in `@hyperframes/core` keys off * the live document: `resolveAudioGroups` collects `member.id`, - * `isAudibleUnderSolo` compares `el.id`, `resolveCarveSourceIds` and - * `resolveSoloLabel` both go through `getElementById`. Anything crossing into - * that space — a solo id, a group membership list, a carve source — has to be + * `resolveCarveSourceIds` goes through `getElementById`. Anything crossing into + * that space — a group membership list, a carve source — has to be * converted here first; a composite key silently matches nothing. * * `null` for a row with no DOM id at all (selector-addressed elements): such an - * element cannot be soloed or grouped, because `resolveAudioGroups` skips + * element cannot be grouped, because `resolveAudioGroups` skips * members without an `id` and would build a group that is half there. */ export function runtimeAudioId(element: { domId?: string | null }): string | null { diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts index 3a9b1abd1..ccb5d55e3 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts @@ -94,9 +94,6 @@ describe("applyPreviewAudioFlags", () => { const calls: Record = {}; const win = { __hf: { - setAudioSolo: (ids: readonly string[]) => { - calls.solo = [...ids]; - }, setCanaries: (states: Record) => { calls.canaries = [states]; }, @@ -111,23 +108,13 @@ describe("applyPreviewAudioFlags", () => { } // Everything pushed here is state the runtime loses on reload and nothing - // else re-sends: the solo bridge's effect deps do not change across a - // reload, so the button stayed lit while every track played. - it("re-pushes the whole audio state, solo included", () => { + // else re-sends, so the push has to carry all of it every time. + it("re-pushes the whole audio state", () => { const { iframe, calls } = fakeIframe(); - applyPreviewAudioFlags(iframe, false, 1, new Set(["voice-1"])); + applyPreviewAudioFlags(iframe, false, 1); - expect(calls.solo).toEqual(["voice-1"]); // Every runtime-visible flag in one push, each resolved by the host. expect(calls.canaries?.[0]).toMatchObject({ "audio-track-mute": expect.any(Boolean) }); }); - - it("pushes an empty solo set rather than skipping the call", () => { - const { iframe, calls } = fakeIframe(); - - applyPreviewAudioFlags(iframe, false, 1, new Set()); - - expect(calls.solo).toEqual([]); - }); }); diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index 4b56990ba..4fb9f3bad 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -167,23 +167,9 @@ function setPreviewCanaries(iframe: HTMLIFrameElement | null): void { } catch {} } -/** Replace the runtime's soloed set. Same channel `useAudioSoloBridge` uses for - * live changes; repeated here because the bridge's effect deps do not change - * across a preview reload, so it never re-fires and the reloaded runtime would - * keep an empty set while the button stays lit. */ -function setPreviewSolo(iframe: HTMLIFrameElement | null, ids: readonly string[]): void { - if (!iframe) return; - try { - const win = iframe.contentWindow as - | (Window & { __hf?: { setAudioSolo?: (ids: readonly string[]) => void } }) - | null; - win?.__hf?.setAudioSolo?.(ids); - } catch {} -} - /** * Everything the preview runtime has to be told about audio after it loads: - * the transport's mute, the session's solo set, and the canary flags core + * the transport's mute and the canary flags core * cannot resolve for itself. Called from `applyPreviewAudioState`, which is the * path that re-runs after a preview reload — the runtime comes back with every * one of these at its default and nothing else pushes them again. @@ -192,14 +178,12 @@ export function applyPreviewAudioFlags( iframe: HTMLIFrameElement | null, muted: boolean, volume: number, - soloed: ReadonlySet, ): void { setPreviewMediaMuted(iframe, muted); // Volume too: the transport comes back at unity after a reload, so a preview // the author had turned down came back loud. setPreviewMediaVolume(iframe, volume); setPreviewCanaries(iframe); - setPreviewSolo(iframe, [...soloed]); } export function setPreviewPlaybackRate( diff --git a/packages/studio/src/player/store/audioSoloSlice.test.ts b/packages/studio/src/player/store/audioSoloSlice.test.ts deleted file mode 100644 index ddd4798ec..000000000 --- a/packages/studio/src/player/store/audioSoloSlice.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -// @vitest-environment happy-dom - -import { describe, expect, it, vi } from "vitest"; -import { usePlayerStore } from "./playerStore"; -import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "./audioSoloSlice"; -import * as studioFileHistory from "../../utils/studioFileHistory"; - -describe("audioSoloSlice", () => { - it("is exclusive by default, and clicking the only soloed element again clears it", () => { - usePlayerStore.getState().toggleSolo("a"); - expect(usePlayerStore.getState().soloed).toEqual(new Set(["a"])); - usePlayerStore.getState().toggleSolo("b"); - expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"])); - usePlayerStore.getState().toggleSolo("b"); - expect(usePlayerStore.getState().soloed).toEqual(new Set()); - }); - - it("⌘/Ctrl-click adds and removes membership without disturbing the rest", () => { - usePlayerStore.getState().toggleSolo("a"); - usePlayerStore.getState().toggleSolo("b", { add: true }); - expect(usePlayerStore.getState().soloed).toEqual(new Set(["a", "b"])); - usePlayerStore.getState().toggleSolo("a", { add: true }); - expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"])); - }); - - it("clearSolo empties the set", () => { - usePlayerStore.getState().toggleSolo("a"); - usePlayerStore.getState().clearSolo(); - expect(usePlayerStore.getState().soloed).toEqual(new Set()); - }); -}); - -describe("export-safety: solo never touches an attribute or the save path", () => { - it("toggling, ⌘-adding, and clearing solo never calls setAttribute/removeAttribute on any element", () => { - usePlayerStore.getState().clearSolo(); - const setAttributeSpy = vi.spyOn(Element.prototype, "setAttribute"); - const removeAttributeSpy = vi.spyOn(Element.prototype, "removeAttribute"); - - usePlayerStore.getState().toggleSolo("a"); - usePlayerStore.getState().toggleSolo("b", { add: true }); - usePlayerStore.getState().toggleSolo("a", { add: true }); - usePlayerStore.getState().clearSolo(); - - expect(setAttributeSpy).not.toHaveBeenCalled(); - expect(removeAttributeSpy).not.toHaveBeenCalled(); - setAttributeSpy.mockRestore(); - removeAttributeSpy.mockRestore(); - }); - - it("toggling, ⌘-adding, and clearing solo never invokes the project save path (setElementsHidden's own write function)", () => { - usePlayerStore.getState().clearSolo(); - const saveSpy = vi.spyOn(studioFileHistory, "saveProjectFilesWithHistory"); - - usePlayerStore.getState().toggleSolo("a"); - usePlayerStore.getState().toggleSolo("b", { add: true }); - usePlayerStore.getState().clearSolo(); - - expect(saveSpy).not.toHaveBeenCalled(); - saveSpy.mockRestore(); - }); -}); - -describe("isAudibleUnderSolo — the three-bullet rule", () => { - it("everything is audible when no solo is active", () => { - expect(isAudibleUnderSolo(new Set(), "clip-a")).toBe(true); - expect(isAudibleUnderSolo(new Set(), "clip-a", "group-1")).toBe(true); - }); - - it("a soloed element is audible", () => { - expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-a")).toBe(true); - }); - - it("a sibling of a soloed element is silent", () => { - expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b")).toBe(false); - }); - - it("a member of a soloed group is audible (group solo = members solo)", () => { - expect(isAudibleUnderSolo(new Set(["group-1"]), "clip-a", "group-1")).toBe(true); - }); - - it("a member of an UNsoloed group, while a sibling group is soloed, is silent", () => { - expect(isAudibleUnderSolo(new Set(["group-2"]), "clip-a", "group-1")).toBe(false); - }); - - it("soloing one member of a group does not un-attenuate its siblings in the same group", () => { - // clip-a is soloed directly; clip-b shares its group but is not itself - // soloed and the group itself is not soloed — clip-b stays silent. - expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b", "group-1")).toBe(false); - }); - - it("the group itself, monitored as a bus, is audible only when it or a member is soloed", () => { - expect(isAudibleUnderSolo(new Set(["clip-a"]), "group-1", null)).toBe(false); - expect(isAudibleUnderSolo(new Set(["group-1"]), "group-1", null)).toBe(true); - }); -}); - -describe("isGroupHalfLitUnderSolo", () => { - it("is false when no solo is active", () => { - expect(isGroupHalfLitUnderSolo(new Set(), "group-1", ["a", "b"])).toBe(false); - }); - - it("is false when the group itself is soloed (fully lit, not half)", () => { - expect(isGroupHalfLitUnderSolo(new Set(["group-1"]), "group-1", ["a", "b"])).toBe(false); - }); - - it("is true when a member is soloed but the group is not", () => { - expect(isGroupHalfLitUnderSolo(new Set(["a"]), "group-1", ["a", "b"])).toBe(true); - }); - - it("is false when an unrelated element is soloed", () => { - expect(isGroupHalfLitUnderSolo(new Set(["other"]), "group-1", ["a", "b"])).toBe(false); - }); -}); - -describe("solo does not outlive its composition", () => { - // Solo ids only mean anything against the document they were taken from. - // Carried into another composition they match nothing, and "match nothing" - // is exactly the state that silences every track while the banner still - // claims to be hearing one of them. - it("is cleared by the timeline reset that a composition switch runs", () => { - usePlayerStore.getState().toggleSolo("voice-1"); - expect(usePlayerStore.getState().soloed.size).toBe(1); - - usePlayerStore.getState().reset(); - - expect(usePlayerStore.getState().soloed).toEqual(new Set()); - }); -}); diff --git a/packages/studio/src/player/store/audioSoloSlice.ts b/packages/studio/src/player/store/audioSoloSlice.ts deleted file mode 100644 index 9dcf354da..000000000 --- a/packages/studio/src/player/store/audioSoloSlice.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * "Hear only this" — session-only monitoring override, never persisted. - * - * Holds clip ids and group ids (never track numbers, per the design doc: solo - * is a per-element/per-group concept, not a row-position one). Exclusive by - * default (a plain toggle replaces the set); ⌘/Ctrl-click adds/removes one - * member without disturbing the rest. Never written to any attribute or - * document — the export-safety guarantee this slice exists to hold. - */ -import type { StoreApi } from "zustand"; -import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "@hyperframes/core/audio-groups"; - -export { isAudibleUnderSolo, isGroupHalfLitUnderSolo }; - -export interface AudioSoloSlice { - soloed: ReadonlySet; - toggleSolo: (id: string, options?: { add?: boolean }) => void; - clearSolo: () => void; -} - -export function createAudioSoloSlice( - set: StoreApi["setState"], - get: StoreApi["getState"], -): AudioSoloSlice { - return { - soloed: new Set(), - toggleSolo: (id, options) => { - const current = get().soloed; - if (options?.add) { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - set({ soloed: next }); - return; - } - // Exclusive click: soloing the only-soloed element again clears it; - // otherwise it replaces the set. - const isOnlyMember = current.size === 1 && current.has(id); - set({ soloed: isOnlyMember ? new Set() : new Set([id]) }); - }, - clearSolo: () => set({ soloed: new Set() }), - }; -} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 9055b4257..3cae18b6d 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -16,7 +16,6 @@ import { } from "./automationSelectionSlice"; import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState"; import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice"; -import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice"; export { liveTime } from "./liveTime"; @@ -48,8 +47,7 @@ function resolveElementSelection( }; } -interface PlayerState - extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, AudioSoloSlice { +interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice { isPlaying: boolean; currentTime: number; duration: number; @@ -278,7 +276,6 @@ export function createTimelineResetState() { automationSelection: null, expandedClipIds: new Set(), // Per-composition: ids from comp A match nothing in B, silencing all of it. - soloed: new Set(), collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), focusedEaseSegment: null, @@ -331,7 +328,6 @@ export const usePlayerStore = create((set, get) => ({ ...createThumbnailSlice(set), ...createAutomationSelectionSlice(set), - ...createAudioSoloSlice(set, get), activeKeyframePct: null, setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),