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 16:40:07 -07:00
parent 82cfb361ef
commit 2199f55c3b
26 changed files with 68 additions and 820 deletions
@@ -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 (
<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 ──────────────────────────────────────────────── */
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 (
<div>
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
<div
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
aria-disabled={disabled || undefined}
@@ -1,4 +1,3 @@
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
@@ -15,23 +14,13 @@ interface TimelineGroupHeaderProps {
laneCount: number;
isLaneOpen: boolean;
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. */
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({
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
</span>
{/* 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. */}
<span className={`min-w-0 truncate font-medium${hidden ? " line-through" : ""}`}>
{label}
</span>
<span className="min-w-0 truncate font-medium">{label}</span>
<span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true"
@@ -105,16 +86,10 @@ export function TimelineGroupHeader({
laneCount,
isLaneOpen,
onToggleLanes,
hidden,
onToggleHidden,
isSoloed,
isHalfLitSolo,
onToggleSolo,
fxChain,
onFxChainChange,
onFxChainPreview,
auditionSpans,
onSetMutedLive,
onOpenFxRack,
columnWidth,
theme,
@@ -154,72 +129,16 @@ export function TimelineGroupHeader({
</span>
</button>
<GroupNameButton
label={label}
memberCount={memberCount}
hidden={hidden}
onOpenFxRack={onOpenFxRack}
/>
<GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
</div>
{/* 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. */}
<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
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
auditionSpans={auditionSpans}
isMuted={hidden}
onSetMutedLive={onSetMutedLive}
onOpenRack={onOpenFxRack}
/>
<button
@@ -1,6 +1,3 @@
import { usePlayerStore } from "../store/playerStore";
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
@@ -78,7 +75,7 @@ export function TimelineGroupRow({
}: TimelineGroupRowProps) {
// 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
// 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".
const memberElements = group.memberElements;
// 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 } =
useTimelineEditContextOptional();
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 value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
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 target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
group.id,
@@ -155,23 +139,10 @@ export function TimelineGroupRow({
laneCount={groupAutomationLanes([groupElement]).length}
isLaneOpen={isLaneOpen}
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}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
onSetMutedLive={setGroupMutedLive}
onOpenFxRack={openGroupFxRack}
// 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
@@ -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 { 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.
@@ -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}
/>
</LayerDisclosureRow>
@@ -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 <Icon size={14} weight="bold" aria-hidden="true" />;
}
@@ -24,22 +21,19 @@ export function VisibilityButton({
trackNumber,
trackDisplayNumber,
visible,
isAudioTrack,
onToggle,
}: {
hidden: boolean;
trackNumber: number;
trackDisplayNumber: number | null;
visible: boolean;
isAudioTrack?: boolean;
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
}) {
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
// conflated in either direction.
const suffix = trackDisplaySuffix(trackDisplayNumber);
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
const label = visibilityButtonLabel(hidden, suffix);
return (
<button
type="button"
@@ -54,7 +48,7 @@ export function VisibilityButton({
void onToggle?.(trackNumber, !hidden);
}}
>
{visibilityButtonIcon(showAsMute, hidden)}
{visibilityButtonIcon(hidden)}
</button>
);
}
@@ -69,9 +63,6 @@ export function PlainTrackHeader({
showTrackLabel,
isTrackHidden,
isAudioTrack,
isGroupMuted,
isSoloed,
onToggleSolo,
onToggleTrackHidden,
trailing,
}: {
@@ -83,9 +74,6 @@ export function PlainTrackHeader({
isAudioTrack: boolean;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
showTrackLabel: boolean;
isGroupMuted: boolean;
isSoloed: boolean;
onToggleSolo?: (options?: { add?: boolean }) => void;
/** 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. */
trailing?: React.ReactNode;
@@ -100,14 +88,7 @@ export function PlainTrackHeader({
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{showTrackLabel && (
<span
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}
>
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
{trackLabel}
</span>
)}
@@ -124,12 +105,8 @@ export function PlainTrackHeader({
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack}
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden}
/>
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
)}
{trailing}
</div>
</>
@@ -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
@@ -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.
*/