Files
hyperframes/packages/studio/src/player/store/audioSoloSlice.test.ts
T
Vance IngallsandClaude Sonnet 5 ba1d807621 feat(studio,core): mute groups, and hear-only-this that cannot reach the export
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 02:13:19 -07:00

114 lines
4.8 KiB
TypeScript

// @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);
});
});