feat(studio,core)!: remove mute and solo from tracks and groups

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.
This commit is contained in:
Vance Ingalls
2026-08-20 02:19:09 -07:00
parent d07770c893
commit 5175891b62
26 changed files with 68 additions and 820 deletions
-32
View File
@@ -133,35 +133,3 @@ export function audioGroupOf(el: Element): string | null {
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null; if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
return typeof el.getAttribute === "function" ? el.getAttribute(HF_AUDIO_GROUP_ATTR) : 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));
}
+1 -15
View File
@@ -42,7 +42,7 @@ import { applyVariableBindings } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock"; import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport"; 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 { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers"; import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
import type { import type {
@@ -178,20 +178,7 @@ export function initSandboxRuntimeModular(): void {
void webAudio.init().then((ok) => { void webAudio.init().then((ok) => {
webAudioReady = 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 = window.__hf || {};
window.__hf.setAudioSolo = (ids) => {
soloedIds = new Set(ids);
webAudio.setSolo(soloedIds);
};
// Canary states the HOST resolved, keyed by registry name. Core cannot // Canary states the HOST resolved, keyed by registry name. Core cannot
// resolve one itself — bucketing needs an install id it has no access to — // 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 // so every runtime-visible flag arrives through this one channel rather than
@@ -2116,7 +2103,6 @@ export function initSandboxRuntimeModular(): void {
webAudio.setElementVolume(el, authorVolume), webAudio.setElementVolume(el, authorVolume),
isWebAudioOwned: (el) => webAudio.ownsElement(el), isWebAudioOwned: (el) => webAudio.ownsElement(el),
isWebAudioRouted: (el) => webAudio.routesElement(el), isWebAudioRouted: (el) => webAudio.routesElement(el),
isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)),
silenceHiddenAudio: silenceHiddenAudioEnabled(), silenceHiddenAudio: silenceHiddenAudioEnabled(),
onAutoplayBlocked: () => { onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return; if (state.mediaAutoplayBlockedPosted) return;
+2 -12
View File
@@ -217,11 +217,6 @@ export function syncRuntimeMedia(params: {
/** Native media routed through WebAudio keeps its upstream element volume at /** Native media routed through WebAudio keeps its upstream element volume at
* unity; do not mistake that transport write for an authored volume edit. */ * unity; do not mistake that transport write for an authored volume edit. */
isWebAudioRouted?: (el: HTMLMediaElement) => boolean; 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: /** Silence media under a `data-hidden` ancestor, matching the render. Opt-in:
* the host pushes it via `__hf.setCanaries` when the `audio-track-mute` * the host pushes it via `__hf.setCanaries` when the `audio-track-mute`
* canary is on. Absent/false = the shipped behaviour (hidden audio still * 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 // it); preview matches once the host opts in (`silenceHiddenAudio`, the
// `audio-track-mute` canary — see init.ts). Folded into the per-tick // `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 // 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, // flag).
// 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.
const silencedByHidden = params.silenceHiddenAudio const silencedByHidden = params.silenceHiddenAudio
? el.closest("[data-hidden]") !== null ? el.closest("[data-hidden]") !== null
: false; : false;
const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false; const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol);
const effectiveVolume =
silencedByHidden || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
el.volume = effectiveVolume; el.volume = effectiveVolume;
lastRuntimeAppliedVolume.set(el, effectiveVolume); lastRuntimeAppliedVolume.set(el, effectiveVolume);
params.onElementVolume?.(el, effectiveVolume, authorVolume); params.onElementVolume?.(el, effectiveVolume, authorVolume);
@@ -715,25 +715,23 @@ describe("WebAudioTransport", () => {
} }
/** The group's own input gain is built lazily on the first member index /** 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<typeof createGroupMockAudioContext>) => const firstGroupInput = (mock: ReturnType<typeof createGroupMockAudioContext>) =>
mock.gainNodes[2]!; mock.gainNodes[1]!;
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ""; 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(); 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. // One gain per clip now that solo is gone — it goes straight to master.
expect(mock.gainNodes).toHaveLength(2); expect(mock.gainNodes).toHaveLength(1);
const [memberGain, soloGain] = mock.gainNodes; const [clipGain] = mock.gainNodes;
expect(memberGain!.connect).toHaveBeenCalledWith(soloGain); expect(clipGain!.connect).toHaveBeenCalledWith(mock.masterGain);
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 () => { it("two members of the same group land on ONE shared group gain, not master directly", async () => {
@@ -742,27 +740,22 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "vo"); await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo"); await scheduleGrouped(transport, gen, "b", "vo");
// Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3), // Creation order for a: a-gain(0), groupInput(1), groupOutput(2),
// muteGain(4), fader(5) — the group bus is built lazily inside a's // muteGain(3), fader(4) — the group bus is built lazily inside a's
// schedule call. Then b: b-gain(6), b-solo(7). // schedule call. Then b: b-gain(5).
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(8); expect(mock.gainNodes.length).toBeGreaterThanOrEqual(6);
const aGain = mock.gainNodes[0]!; const aGain = mock.gainNodes[0]!;
const aSolo = mock.gainNodes[1]!;
const groupInput = firstGroupInput(mock); const groupInput = firstGroupInput(mock);
const groupOutput = mock.gainNodes[3]!; const groupOutput = mock.gainNodes[2]!;
const muteGain = mock.gainNodes[4]!; const muteGain = mock.gainNodes[3]!;
const fader = mock.gainNodes[5]!; const fader = mock.gainNodes[4]!;
const bGain = mock.gainNodes[6]!; const bGain = mock.gainNodes[5]!;
const bSolo = mock.gainNodes[7]!;
// Each member feeds its own solo gain, and both solo gains feed the // Both members feed the shared bus — neither connects straight to master.
// shared bus — neither connects straight to master. expect(aGain.connect).toHaveBeenCalledWith(groupInput);
expect(aGain.connect).toHaveBeenCalledWith(aSolo); expect(bGain.connect).toHaveBeenCalledWith(groupInput);
expect(bGain.connect).toHaveBeenCalledWith(bSolo); expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(aSolo.connect).toHaveBeenCalledWith(groupInput); expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
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 runs through the // The bus's input never reaches master directly. It runs through the
// chain (dry here — neither member's group has a chain-bearing // chain (dry here — neither member's group has a chain-bearing
@@ -780,11 +773,11 @@ describe("WebAudioTransport", () => {
const { transport, mock, gen } = setupGroupTransport(); const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo"); 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"); await scheduleGrouped(transport, gen, "b", "vo");
// Only b's own gain and its solo gain are new — no second group bus minted. // Only b's own gain is new — no second group bus minted.
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2); expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1);
}); });
it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => { it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
@@ -792,9 +785,9 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
const muteGain = mock.gainNodes[4]!; const muteGain = mock.gainNodes[3]!;
const groupOutput = mock.gainNodes[3]!; const groupOutput = mock.gainNodes[2]!;
const fader = mock.gainNodes[5]!; const fader = mock.gainNodes[4]!;
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(fader); expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(fader);
expect(fader.connect).toHaveBeenCalledWith(muteGain); expect(fader.connect).toHaveBeenCalledWith(muteGain);
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput); expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
@@ -813,7 +806,7 @@ describe("WebAudioTransport", () => {
await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull(); 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 () => { it("leaves the fader at unity when the group carries no data-volume", async () => {
@@ -823,7 +816,7 @@ describe("WebAudioTransport", () => {
// No throw wiring the group's automation reader against a real // No throw wiring the group's automation reader against a real
// <hf-audio-group> element that carries no fx/automation attrs. // <hf-audio-group> element that carries no fx/automation attrs.
await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull(); 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 () => { it("destroy() disposes every group bus", async () => {
@@ -858,7 +851,7 @@ describe("WebAudioTransport", () => {
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`; document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`;
const { transport, mock, gen } = setupGroupTransport(); const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo"); 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). // Something moved the fader mid-pass (a ramp reaching its last point).
fader.gain.value = 0; fader.gain.value = 0;
@@ -882,7 +875,7 @@ describe("WebAudioTransport", () => {
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`; document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`;
const { transport, mock, gen } = setupGroupTransport(); const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo"); await scheduleGrouped(transport, gen, "a", "vo");
const fader = mock.gainNodes[5]!; const fader = mock.gainNodes[4]!;
fader.gain.cancelScheduledValues = vi.fn(() => { fader.gain.cancelScheduledValues = vi.fn(() => {
throw new Error("param is not schedulable"); throw new Error("param is not schedulable");
}); });
@@ -897,76 +890,6 @@ describe("WebAudioTransport", () => {
expect(fader.gain.value).toBeCloseTo(0.5, 6); 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)", () => { describe("group mute (B5)", () => {
it("a group created with data-hidden already set starts muted (mute gain at 0)", async () => { 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>`; document.body.innerHTML = `<hf-audio-group id="vo" data-hidden></hf-audio-group>`;
@@ -974,14 +897,14 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "vo"); await scheduleGrouped(transport, gen, "a", "vo");
const muteGain = mock.gainNodes[4]!; const muteGain = mock.gainNodes[3]!;
expect(muteGain.gain.value).toBe(0); expect(muteGain.gain.value).toBe(0);
}); });
it("setGroupMuted toggles the mute gain on an active group bus", async () => { it("setGroupMuted toggles the mute gain on an active group bus", async () => {
const { transport, mock, gen } = setupGroupTransport(); const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo"); await scheduleGrouped(transport, gen, "a", "vo");
const muteGain = mock.gainNodes[4]!; const muteGain = mock.gainNodes[3]!;
expect(muteGain.gain.value).toBe(1); expect(muteGain.gain.value).toBe(1);
transport.setGroupMuted("vo", true); transport.setGroupMuted("vo", true);
+2 -43
View File
@@ -6,7 +6,7 @@ import {
type AutomationTiming, type AutomationTiming,
} from "../audio/audioFxAutomation.js"; } from "../audio/audioFxAutomation.js";
import { VOLUME_RANGE } from "../audioAutomation.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 { swallow } from "./diagnostics";
import { clampAudioGain } from "../audioGain.js"; import { clampAudioGain } from "../audioGain.js";
import { getDebugSurface } from "./globals.js"; import { getDebugSurface } from "./globals.js";
@@ -101,11 +101,6 @@ function scheduleVolumeLane(
type ScheduledSourceBase = { type ScheduledSourceBase = {
el: HTMLMediaElement; el: HTMLMediaElement;
gainNode: GainNode; 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 chain spliced between source and gain, when the element carries one. */
fx?: ElementFxHandle | null; fx?: ElementFxHandle | null;
compositionStart: number; compositionStart: number;
@@ -171,10 +166,6 @@ export class WebAudioTransport {
private _rate = 1; private _rate = 1;
private _paused = true; private _paused = true;
private _playGeneration = 0; 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<string> = new Set();
async init(): Promise<boolean> { async init(): Promise<boolean> {
try { try {
@@ -506,7 +497,6 @@ export class WebAudioTransport {
sourceNode.disconnect(); sourceNode.disconnect();
scheduled.fx?.dispose(); scheduled.fx?.dispose();
scheduled.gainNode.disconnect(); scheduled.gainNode.disconnect();
scheduled.soloGain.disconnect();
} catch { } catch {
// Already torn down. // Already torn down.
} }
@@ -559,10 +549,7 @@ export class WebAudioTransport {
// output — the same order the offline render uses. Preview and render run // 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. // the identical graph builders, so what is heard here is what is written.
const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing); const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing);
const soloGain = this._ctx.createGain(); gainNode.connect(
soloGain.gain.value = isAudibleUnderSolo(this._soloed, el.id, audioGroupOf(el)) ? 1 : 0;
gainNode.connect(soloGain);
soloGain.connect(
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain, this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
); );
@@ -586,7 +573,6 @@ export class WebAudioTransport {
sourceNode.disconnect(); sourceNode.disconnect();
fx?.dispose(); fx?.dispose();
gainNode.disconnect(); gainNode.disconnect();
soloGain.disconnect();
return null; return null;
} }
@@ -600,7 +586,6 @@ export class WebAudioTransport {
sourceNode, sourceNode,
sourceKind: "buffer", sourceKind: "buffer",
gainNode, gainNode,
soloGain,
compositionStart, compositionStart,
mediaStart, mediaStart,
scheduledAt, scheduledAt,
@@ -682,7 +667,6 @@ export class WebAudioTransport {
source.sourceNode.disconnect(); source.sourceNode.disconnect();
source.fx?.dispose(); source.fx?.dispose();
source.gainNode.disconnect(); source.gainNode.disconnect();
source.soloGain.disconnect();
} catch { } catch {
// already stopped // already stopped
} }
@@ -731,31 +715,6 @@ export class WebAudioTransport {
if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume; 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<string>): 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 { isActive(): boolean {
return this._activeSources.length > 0 && !this._paused; return this._activeSources.length > 0 && !this._paused;
} }
-6
View File
@@ -37,12 +37,6 @@ declare global {
onSwallowed?: (label: string, err: unknown) => void; onSwallowed?: (label: string, err: unknown) => void;
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void; seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
duration?: number; 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 * Canary states resolved by the HOST and pushed in, because core cannot
* resolve one itself: bucketing needs an install id, which lives in the * resolve one itself: bucketing needs an install id, which lives in the
-2
View File
@@ -38,7 +38,6 @@ import { useToast } from "./hooks/useToast";
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader"; import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
import { useStudioUrlState } from "./hooks/useStudioUrlState"; import { useStudioUrlState } from "./hooks/useStudioUrlState";
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration"; import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge";
import { import {
buildStudioContextValue, buildStudioContextValue,
useGlobalFileDrop, useGlobalFileDrop,
@@ -82,7 +81,6 @@ export function StudioApp() {
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion(); const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null); const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null); const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
useAudioSoloBridge(previewIframeRef);
const activeCompPathRef = useRef(activeCompPath); const activeCompPathRef = useRef(activeCompPath);
activeCompPathRef.current = activeCompPath; activeCompPathRef.current = activeCompPath;
const leftSidebarRef = useRef<LeftSidebarHandle>(null); const leftSidebarRef = useRef<LeftSidebarHandle>(null);
@@ -156,7 +156,6 @@ export function PreviewPane({
disabled={timelineDisabled} disabled={timelineDisabled}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
previewIframeRef={iframeRef}
/> />
</div> </div>
</div> </div>
@@ -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]);
}
@@ -6,7 +6,6 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
import { trackStudioEvent } from "../../utils/studioTelemetry"; import { trackStudioEvent } from "../../utils/studioTelemetry";
import { Tooltip } from "../../components/ui"; import { Tooltip } from "../../components/ui";
import { useMountEffect } from "../../hooks/useMountEffect"; import { useMountEffect } from "../../hooks/useMountEffect";
import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
import { ShortcutsPanel } from "./ShortcutsPanel"; import { ShortcutsPanel } from "./ShortcutsPanel";
import { SpeedMenu } from "./SpeedMenu"; import { SpeedMenu } from "./SpeedMenu";
import { VolumeControl } from "./VolumeControl"; 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 (
<div
role="status"
className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
>
<span>
Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> your
export is not affected
</span>
<button
type="button"
onClick={() => clearSolo()}
className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
>
Clear
</button>
</div>
);
});
/* ── Main component ──────────────────────────────────────────────── */ /* ── Main component ──────────────────────────────────────────────── */
interface PlayerControlsProps { interface PlayerControlsProps {
@@ -190,7 +161,6 @@ interface PlayerControlsProps {
disabled?: boolean; disabled?: boolean;
isFullscreen?: boolean; isFullscreen?: boolean;
onToggleFullscreen?: () => void; onToggleFullscreen?: () => void;
previewIframeRef?: { current: HTMLIFrameElement | null };
} }
export const PlayerControls = memo(function PlayerControls({ export const PlayerControls = memo(function PlayerControls({
@@ -199,7 +169,6 @@ export const PlayerControls = memo(function PlayerControls({
disabled = false, disabled = false,
isFullscreen = false, isFullscreen = false,
onToggleFullscreen, onToggleFullscreen,
previewIframeRef,
}: PlayerControlsProps) { }: PlayerControlsProps) {
const isPlaying = usePlayerStore((s) => s.isPlaying); const isPlaying = usePlayerStore((s) => s.isPlaying);
const duration = usePlayerStore((s) => s.duration); const duration = usePlayerStore((s) => s.duration);
@@ -252,7 +221,6 @@ export const PlayerControls = memo(function PlayerControls({
return ( return (
<div> <div>
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
<div <div
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3" className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
aria-disabled={disabled || undefined} aria-disabled={disabled || undefined}
@@ -1,4 +1,3 @@
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { TRACK_H } from "./timelineLayout"; import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme"; import type { TimelineTheme } from "./timelineTheme";
@@ -15,23 +14,13 @@ interface TimelineGroupHeaderProps {
laneCount: number; laneCount: number;
isLaneOpen: boolean; isLaneOpen: boolean;
onToggleLanes: () => void; onToggleLanes: () => 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. */ /** `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. */ /** C1: the group's serialized `data-fx-chain`, when set. */
fxChain?: string; fxChain?: string;
onFxChainChange: (next: HfAudioFxChain) => void; onFxChainChange: (next: HfAudioFxChain) => void;
onFxChainPreview?: (next: HfAudioFxChain) => void; onFxChainPreview?: (next: HfAudioFxChain) => void;
/** Member clips, so hovering a preset auditions where the group sounds. */ /** Member clips, so hovering a preset auditions where the group sounds. */
auditionSpans?: readonly AuditionSpan[]; auditionSpans?: readonly AuditionSpan[];
/** Set the group mute on the running graph only, so an audition can lift it. */
onSetMutedLive?: (muted: boolean) => void;
onOpenFxRack: () => void; onOpenFxRack: () => void;
columnWidth: number; columnWidth: number;
theme: TimelineTheme; theme: TimelineTheme;
@@ -39,7 +28,7 @@ interface TimelineGroupHeaderProps {
/** /**
* A group's own row header: caret (member disclosure) + `` + label + count + * 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({ function GroupNameButton({
label, label,
memberCount, memberCount,
hidden,
onOpenFxRack, onOpenFxRack,
}: { }: {
label: string; label: string;
memberCount: number; memberCount: number;
hidden: boolean;
onOpenFxRack: () => void; onOpenFxRack: () => void;
}) { }) {
return ( return (
@@ -75,13 +62,7 @@ function GroupNameButton({
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50"> <span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
</span> </span>
{/* Struck through, not merely dimmed the designs are explicit that "a <span className="min-w-0 truncate font-medium">{label}</span>
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. */}
<span className={`min-w-0 truncate font-medium${hidden ? " line-through" : ""}`}>
{label}
</span>
<span <span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55" className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true" aria-hidden="true"
@@ -105,16 +86,10 @@ export function TimelineGroupHeader({
laneCount, laneCount,
isLaneOpen, isLaneOpen,
onToggleLanes, onToggleLanes,
hidden,
onToggleHidden,
isSoloed,
isHalfLitSolo,
onToggleSolo,
fxChain, fxChain,
onFxChainChange, onFxChainChange,
onFxChainPreview, onFxChainPreview,
auditionSpans, auditionSpans,
onSetMutedLive,
onOpenFxRack, onOpenFxRack,
columnWidth, columnWidth,
theme, theme,
@@ -154,72 +129,16 @@ export function TimelineGroupHeader({
</span> </span>
</button> </button>
<GroupNameButton <GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
label={label}
memberCount={memberCount}
hidden={hidden}
onOpenFxRack={onOpenFxRack}
/>
</div> </div>
{/* Line two: what you can DO to it. Its own row so the name is not {/* 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. */} squeezed to a few characters by five controls sharing 232px. */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button
type="button"
tabIndex={-1}
aria-label={hidden ? "Unmute group" : `Mute group ${label}`}
title={hidden ? "Unmute group" : `Mute group ${label}`}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleHidden();
}}
>
{hidden ? (
<SpeakerSlash size={14} weight="bold" aria-hidden="true" />
) : (
<SpeakerHigh size={14} weight="bold" aria-hidden="true" />
)}
</button>
<button
type="button"
tabIndex={-1}
aria-pressed={isSoloed}
aria-label="Hear only this"
title="Hear only this"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleSolo({ add: event.metaKey || event.ctrlKey });
}}
>
{/* Three states, not two: filled when this group is soloed, and
HALF-lit when a member is the affordance for "this bus is
passing audio, but I did not solo it" (groups doc §2.2). */}
<span
aria-hidden="true"
className={`flex h-[15px] w-[15px] items-center justify-center rounded-[3px] border text-[10px] font-bold leading-none transition-colors ${
isSoloed
? "border-[#F5C542] bg-[#F5C542] text-black"
: isHalfLitSolo
? "border-[#F5C542]/60 bg-[#F5C542]/25 text-[#F5C542]"
: "border-white/30 text-white/45 hover:border-white/60 hover:text-white/80"
}`}
>
S
</span>
</button>
<TimelineFxButton <TimelineFxButton
fxChainRaw={fxChain} fxChainRaw={fxChain}
onChainChange={onFxChainChange} onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview} onChainPreview={onFxChainPreview}
auditionSpans={auditionSpans} auditionSpans={auditionSpans}
isMuted={hidden}
onSetMutedLive={onSetMutedLive}
onOpenRack={onOpenFxRack} onOpenRack={onOpenFxRack}
/> />
<button <button
@@ -1,6 +1,3 @@
import { usePlayerStore } from "../store/playerStore";
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import { import {
HF_AUDIO_FX_ATTR, HF_AUDIO_FX_ATTR,
serializeAudioFxChain, serializeAudioFxChain,
@@ -78,7 +75,7 @@ export function TimelineGroupRow({
}: TimelineGroupRowProps) { }: TimelineGroupRowProps) {
// From the group, NOT from `tracks`: a collapsed group emits no member rows // From the group, NOT from `tracks`: a collapsed group emits no member rows
// into the display list, and every one of these reads silently degraded to // into the display list, and every one of these reads silently degraded to
// empty in that (default) state — half-lit solo went dark, the lane count // empty in that (default) state — the lane count
// read 0, and the bus strip fell back to "track 1", "track 2". // read 0, and the bus strip fell back to "track 1", "track 2".
const memberElements = group.memberElements; const memberElements = group.memberElements;
// The group wearing a clip's shape so the lane machinery can render it — see // The group wearing a clip's shape so the lane machinery can render it — see
@@ -101,24 +98,11 @@ export function TimelineGroupRow({
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
useTimelineEditContextOptional(); useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional(); const domEditActions = useDomEditActionsContextOptional();
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
// Bare DOM ids: this list is compared against the `soloed` set, which the
// runtime matches on `el.id` (see `runtimeAudioId`). Store keys here made the
// half-lit state unreachable — soloing a member lit nothing on its group.
const memberIds = memberElements.map(runtimeAudioId).filter((id): id is string => id !== null);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => { const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null; const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value); if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset"); else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
}; };
// Hovering a preset on a muted bus is a question about the preset, not about
// the mute — so the audition lifts the mute while it plays and puts it back
// on the way out, the same borrow-and-return it already does with the
// playhead. Live only: `data-hidden` stays in the document, so the row keeps
// reading (and rendering) as muted throughout.
const setGroupMutedLive = (muted: boolean) =>
onSetAudioGroupAttributeLive?.(group.id, "data-hidden", muted ? "" : null);
const openGroupFxRack = () => { const openGroupFxRack = () => {
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById( const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
group.id, group.id,
@@ -155,23 +139,10 @@ export function TimelineGroupRow({
laneCount={groupAutomationLanes([groupElement]).length} laneCount={groupAutomationLanes([groupElement]).length}
isLaneOpen={isLaneOpen} isLaneOpen={isLaneOpen}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
hidden={group.hidden}
onToggleHidden={() =>
onSetAudioGroupAttributeQuiet?.(
group.id,
"data-hidden",
group.hidden ? null : "",
group.hidden ? "Unmute group" : `Mute group ${group.label}`,
)
}
isSoloed={soloed.has(group.id)}
isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
onToggleSolo={(options) => toggleSolo(group.id, options)}
fxChain={group.fxChain} fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)} onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)} onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements} auditionSpans={memberElements}
onSetMutedLive={setGroupMutedLive}
onOpenFxRack={openGroupFxRack} onOpenFxRack={openGroupFxRack}
// Same width as every other row's header. The group row needs a real // Same width as every other row's header. The group row needs a real
// label column, but it gets one by turning `labelMode` on for the whole // label column, but it gets one by turning `labelMode` on for the whole
@@ -1,42 +0,0 @@
/**
* "Hear only this" the boxed `S` beside a track's mute control, which is what
* a solo button looks like in every DAW an author might have met. Session
* state only (see `audioSoloSlice`): a plain click is exclusive, /Ctrl-click
* toggles membership without disturbing the rest of the set.
*/
export function TimelineSoloButton({
isSoloed,
onToggle,
}: {
isSoloed: boolean;
onToggle: (options?: { add?: boolean }) => void;
}) {
return (
<button
type="button"
tabIndex={-1}
aria-pressed={isSoloed}
aria-label="Hear only this"
title="Hear only this"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggle({ add: event.metaKey || event.ctrlKey });
}}
>
{/* Filled when on, outlined when off the state has to read at a glance
from across the track column, and a colour change alone does not. */}
<span
aria-hidden="true"
className={`flex h-[15px] w-[15px] items-center justify-center rounded-[3px] border text-[10px] font-bold leading-none transition-colors ${
isSoloed
? "border-[#F5C542] bg-[#F5C542] text-black"
: "border-white/30 text-white/45 hover:border-white/60 hover:text-white/80"
}`}
>
S
</span>
</button>
);
}
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { defaultTimelineTheme } from "./timelineTheme"; import { defaultTimelineTheme } from "./timelineTheme";
import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { type TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout"; import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { AUTOMATION_LANE_H } from "./automationLaneHeight";
@@ -757,20 +757,6 @@ describe("TimelineTrackHeader", () => {
// The set is pushed straight into the runtime, which compares it against // The set is pushed straight into the runtime, which compares it against
// `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns // `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns
// false for every element, and soloing silences the whole preview. // 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 // 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 // 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. // an eye. B2's design called for the accent rail; only the semantics shipped.
@@ -5,7 +5,7 @@ import {
type HfAudioFxChain, type HfAudioFxChain,
} from "@hyperframes/core/audio-fx"; } from "@hyperframes/core/audio-fx";
import { classifyAudioName } from "@hyperframes/core/audio-carve"; 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 { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; 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. // left an audio clip's envelopes unreachable, since the track could not expand.
const disclosable = lanes.length > 0 || automationRows.length > 0; const disclosable = lanes.length > 0 || automationRows.length > 0;
const isKeyframeLayer = !!keyframeClip && disclosable; 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 // 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 // track holding several ungrouped ones has no single chain — the design
@@ -525,11 +515,8 @@ export function TimelineTrackHeader({
showTrackLabel={showTrackLabel} showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden} isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack} isAudioTrack={isAudioTrack}
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
onToggleTrackHidden={onToggleTrackHidden} 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={ trailing={
<> <>
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && ( {singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
@@ -606,7 +593,6 @@ export function TimelineTrackHeader({
trackNumber={trackNumber} trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber} trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack} visible={!isAudioTrack}
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden} onToggle={onToggleTrackHidden}
/> />
</LayerDisclosureRow> </LayerDisclosureRow>
@@ -1,21 +1,18 @@
import type React from "react"; import type React from "react";
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react"; import { Eye, EyeSlash } from "@phosphor-icons/react";
import { isCanaryEnabled } from "../../telemetry/canary";
import { Music } from "../../icons/SystemIcons"; import { Music } from "../../icons/SystemIcons";
import { TimelineSoloButton } from "./TimelineSoloButton";
import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { TrackClipCount } from "./TrackClipCount"; import { TrackClipCount } from "./TrackClipCount";
import { trackDisplaySuffix } from "./timelineTrackDisplay"; import { trackDisplaySuffix } from "./timelineTrackDisplay";
// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows. // Hide, plainly. The speaker variant was the mute presentation; with mute gone
// Gated: the relabel ships behind the canary, unlike the preview fix. // this is the visibility eye it always was, and audio rows do not render it.
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string { function visibilityButtonLabel(hidden: boolean, suffix: string): string {
if (showAsMute) return hidden ? "Muted" : "Mute";
return hidden ? `Show track${suffix}` : `Hide track${suffix}`; return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
} }
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) { function visibilityButtonIcon(hidden: boolean) {
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye; const Icon = hidden ? EyeSlash : Eye;
return <Icon size={14} weight="bold" aria-hidden="true" />; return <Icon size={14} weight="bold" aria-hidden="true" />;
} }
@@ -24,22 +21,19 @@ export function VisibilityButton({
trackNumber, trackNumber,
trackDisplayNumber, trackDisplayNumber,
visible, visible,
isAudioTrack,
onToggle, onToggle,
}: { }: {
hidden: boolean; hidden: boolean;
trackNumber: number; trackNumber: number;
trackDisplayNumber: number | null; trackDisplayNumber: number | null;
visible: boolean; visible: boolean;
isAudioTrack?: boolean;
onToggle: TimelineEditCallbacks["onToggleTrackHidden"]; onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
}) { }) {
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />; if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
// Display number in the text, real key in the callback. The two must not be // Display number in the text, real key in the callback. The two must not be
// conflated in either direction. // conflated in either direction.
const suffix = trackDisplaySuffix(trackDisplayNumber); const suffix = trackDisplaySuffix(trackDisplayNumber);
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute"); const label = visibilityButtonLabel(hidden, suffix);
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
return ( return (
<button <button
type="button" type="button"
@@ -54,7 +48,7 @@ export function VisibilityButton({
void onToggle?.(trackNumber, !hidden); void onToggle?.(trackNumber, !hidden);
}} }}
> >
{visibilityButtonIcon(showAsMute, hidden)} {visibilityButtonIcon(hidden)}
</button> </button>
); );
} }
@@ -69,9 +63,6 @@ export function PlainTrackHeader({
showTrackLabel, showTrackLabel,
isTrackHidden, isTrackHidden,
isAudioTrack, isAudioTrack,
isGroupMuted,
isSoloed,
onToggleSolo,
onToggleTrackHidden, onToggleTrackHidden,
trailing, trailing,
}: { }: {
@@ -83,9 +74,6 @@ export function PlainTrackHeader({
isAudioTrack: boolean; isAudioTrack: boolean;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
showTrackLabel: boolean; showTrackLabel: boolean;
isGroupMuted: boolean;
isSoloed: boolean;
onToggleSolo?: (options?: { add?: boolean }) => void;
/** Trailing controls that belong on the control line the FX entry points, /** Trailing controls that belong on the control line the FX entry points,
* which the caller owns because only it knows the clip they act on. */ * which the caller owns because only it knows the clip they act on. */
trailing?: React.ReactNode; trailing?: React.ReactNode;
@@ -100,14 +88,7 @@ export function PlainTrackHeader({
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" /> <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)} )}
{showTrackLabel && ( {showTrackLabel && (
<span <span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
className={`min-w-0 flex-1 truncate text-[11px] ${
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
? "line-through"
: ""
}`}
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
>
{trackLabel} {trackLabel}
</span> </span>
)} )}
@@ -124,12 +105,8 @@ export function PlainTrackHeader({
trackNumber={trackNumber} trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber} trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack} visible={!isAudioTrack}
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden} onToggle={onToggleTrackHidden}
/> />
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
)}
{trailing} {trailing}
</div> </div>
</> </>
@@ -19,7 +19,7 @@ function hasKeyframedTimelineClips(
* for the same reason a keyframed clip does a row whose name has nowhere else * 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 * 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 * 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. * side.
* *
* Widening the column for the whole timeline, rather than letting just the * Widening the column for the whole timeline, rather than letting just the
@@ -22,7 +22,7 @@ export interface TimelineTrackGroupInfo {
* *
* Collapsing a group stops emitting its member rows into `tracks`, so anything * 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 * 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 * 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. * a display concern, so it does not travel through the display list.
*/ */
@@ -232,8 +232,8 @@ export function useTimelinePlayer() {
} catch {} } catch {}
}, []); }, []);
const applyPreviewAudioState = useCallback(() => { const applyPreviewAudioState = useCallback(() => {
const { audioMuted, audioVolume, soloed } = usePlayerStore.getState(); const { audioMuted, audioVolume } = usePlayerStore.getState();
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume, soloed); applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
}, []); }, []);
const play = useCallback(() => { const play = useCallback(() => {
stopRAFLoop(); stopRAFLoop();
@@ -12,12 +12,7 @@
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
audioGroupOf,
isAudibleUnderSolo,
isGroupHalfLitUnderSolo,
resolveAudioGroups,
} from "@hyperframes/core/audio-groups";
import { parseTimelineFromDOM } from "./timelineDOM"; import { parseTimelineFromDOM } from "./timelineDOM";
import { runtimeAudioId } from "./timelineElementHelpers"; import { runtimeAudioId } from "./timelineElementHelpers";
@@ -35,51 +30,6 @@ const COMPOSITION = `
<hf-audio-group id="voiceover"></hf-audio-group> <hf-audio-group id="voiceover"></hf-audio-group>
`; `;
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", () => { describe("group membership ids cross into the runtime", () => {
it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => { it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => {
const doc = docWith(COMPOSITION); 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); for (const id of memberIds) expect(clipIds).toContain(id);
}); });
// TimelineGroupRow's half-lit state ("some of what's under here still it("an element with no DOM id is not groupable", () => {
// 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", () => {
const doc = docWith(` const doc = docWith(`
<div data-composition-id="root" data-duration="10"></div> <div data-composition-id="root" data-duration="10"></div>
<audio data-start="0" data-duration="5"></audio> <audio data-start="0" data-duration="5"></audio>
@@ -361,13 +361,12 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
* Studio addresses rows by `buildTimelineElementKey`'s composite * Studio addresses rows by `buildTimelineElementKey`'s composite
* `<sourceFile>#<domId>`, but everything audio in `@hyperframes/core` keys off * `<sourceFile>#<domId>`, but everything audio in `@hyperframes/core` keys off
* the live document: `resolveAudioGroups` collects `member.id`, * the live document: `resolveAudioGroups` collects `member.id`,
* `isAudibleUnderSolo` compares `el.id`, `resolveCarveSourceIds` and * `resolveCarveSourceIds` goes through `getElementById`. Anything crossing into
* `resolveSoloLabel` both go through `getElementById`. Anything crossing into * that space a group membership list, a carve source has to be
* that space a solo id, a group membership list, a carve source has to be
* converted here first; a composite key silently matches nothing. * converted here first; a composite key silently matches nothing.
* *
* `null` for a row with no DOM id at all (selector-addressed elements): such an * `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. * members without an `id` and would build a group that is half there.
*/ */
export function runtimeAudioId(element: { domId?: string | null }): string | null { export function runtimeAudioId(element: { domId?: string | null }): string | null {
@@ -94,9 +94,6 @@ describe("applyPreviewAudioFlags", () => {
const calls: Record<string, unknown[]> = {}; const calls: Record<string, unknown[]> = {};
const win = { const win = {
__hf: { __hf: {
setAudioSolo: (ids: readonly string[]) => {
calls.solo = [...ids];
},
setCanaries: (states: Record<string, boolean>) => { setCanaries: (states: Record<string, boolean>) => {
calls.canaries = [states]; calls.canaries = [states];
}, },
@@ -111,23 +108,13 @@ describe("applyPreviewAudioFlags", () => {
} }
// Everything pushed here is state the runtime loses on reload and nothing // 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 // else re-sends, so the push has to carry all of it every time.
// reload, so the button stayed lit while every track played. it("re-pushes the whole audio state", () => {
it("re-pushes the whole audio state, solo included", () => {
const { iframe, calls } = fakeIframe(); 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. // Every runtime-visible flag in one push, each resolved by the host.
expect(calls.canaries?.[0]).toMatchObject({ "audio-track-mute": expect.any(Boolean) }); 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([]);
});
}); });
@@ -167,23 +167,9 @@ function setPreviewCanaries(iframe: HTMLIFrameElement | null): void {
} catch {} } 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: * 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 * cannot resolve for itself. Called from `applyPreviewAudioState`, which is the
* path that re-runs after a preview reload the runtime comes back with every * 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. * one of these at its default and nothing else pushes them again.
@@ -192,14 +178,12 @@ export function applyPreviewAudioFlags(
iframe: HTMLIFrameElement | null, iframe: HTMLIFrameElement | null,
muted: boolean, muted: boolean,
volume: number, volume: number,
soloed: ReadonlySet<string>,
): void { ): void {
setPreviewMediaMuted(iframe, muted); setPreviewMediaMuted(iframe, muted);
// Volume too: the transport comes back at unity after a reload, so a preview // Volume too: the transport comes back at unity after a reload, so a preview
// the author had turned down came back loud. // the author had turned down came back loud.
setPreviewMediaVolume(iframe, volume); setPreviewMediaVolume(iframe, volume);
setPreviewCanaries(iframe); setPreviewCanaries(iframe);
setPreviewSolo(iframe, [...soloed]);
} }
export function setPreviewPlaybackRate( export function setPreviewPlaybackRate(
@@ -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());
});
});
@@ -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<string>;
toggleSolo: (id: string, options?: { add?: boolean }) => void;
clearSolo: () => void;
}
export function createAudioSoloSlice(
set: StoreApi<AudioSoloSlice>["setState"],
get: StoreApi<AudioSoloSlice>["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() }),
};
}
@@ -16,7 +16,6 @@ import {
} from "./automationSelectionSlice"; } from "./automationSelectionSlice";
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState"; import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice"; import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice";
export type { KeyframeCacheEntry } from "./keyframeSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice";
export { liveTime } from "./liveTime"; export { liveTime } from "./liveTime";
@@ -48,8 +47,7 @@ function resolveElementSelection(
}; };
} }
interface PlayerState interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice {
extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, AudioSoloSlice {
isPlaying: boolean; isPlaying: boolean;
currentTime: number; currentTime: number;
duration: number; duration: number;
@@ -278,7 +276,6 @@ export function createTimelineResetState() {
automationSelection: null, automationSelection: null,
expandedClipIds: new Set<string>(), expandedClipIds: new Set<string>(),
// Per-composition: ids from comp A match nothing in B, silencing all of it. // Per-composition: ids from comp A match nothing in B, silencing all of it.
soloed: new Set<string>(),
collapsedGroupIds: new Set<string>(), collapsedGroupIds: new Set<string>(),
expandedLaneOwnerIds: new Set<string>(), expandedLaneOwnerIds: new Set<string>(),
focusedEaseSegment: null, focusedEaseSegment: null,
@@ -331,7 +328,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
...createThumbnailSlice(set), ...createThumbnailSlice(set),
...createAutomationSelectionSlice(set), ...createAutomationSelectionSlice(set),
...createAudioSoloSlice(set, get),
activeKeyframePct: null, activeKeyframePct: null,
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),