feat(studio,core)!: remove the group volume slider and level meter

Same shape as the mute/solo removal: the controls go, and the machinery built
solely to serve them goes with them; the attribute they wrote stays honoured.

REMOVED
- The volume slider and the level meter from the group's `∿` strip.
- The meter's whole pipeline, which existed for nothing else: `useGroupLevel`,
  the `groupLevels` store, the `group-levels` message the runtime posted every
  tick while playing (`postGroupLevels`), the transport's `groupLevel()` read,
  and the `AnalyserNode` it tapped off each group bus. Two modules deleted.

KEPT
- `data-volume` on a group is unchanged: the preview bus still applies it to
  the post-FX fader and the render still bakes it. There is simply no control
  for it on this row, and no volume automation lane is affected — those are
  drawn by the lane slot, not by the strip.
- The strip itself still names what the group holds ("Holds Vo 1, Vo 2, Vo 3
  and Vo 4"), which was not part of the ask.

`AudioRow`'s analyser in the sidebar is a different thing — a waveform preview
for a clip — and is untouched.

Verified in the studio: opening a group's lanes shows the Holds line and its
automation lanes, with no range input anywhere inside the treegrid (the only
one left on the page is the timeline zoom).

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, core 2382 green, studio 4321 green.
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:08 -07:00
parent 77308f4f37
commit 1b16a4c877
9 changed files with 31 additions and 465 deletions
@@ -1,190 +1,50 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { defaultTimelineTheme } from "./timelineTheme";
import { groupLevels } from "../store/groupLevels";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
groupLevels.notify(new Map());
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
const onVolumeChange = vi.fn();
const onVolumeCommit = vi.fn();
act(() => {
root.render(
<TimelineGroupBusStrip
groupId="vo"
volume={1}
memberLabels={["vo-1", "vo-2"]}
onVolumeChange={onVolumeChange}
onVolumeCommit={onVolumeCommit}
theme={defaultTimelineTheme}
{...overrides}
/>,
);
});
return { onVolumeChange, onVolumeCommit };
}
function slider(): HTMLInputElement {
return container.querySelector('input[type="range"]') as HTMLInputElement;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
/** React tracks the DOM's own value setter to detect real changes — a plain
* `input.value = ...` assignment is invisible to it, so onChange never
* fires. Go through the native setter, same as this codebase's other
* range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
function setSliderValue(input: HTMLInputElement, value: string) {
nativeInputValueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
function renderStrip(memberLabels: readonly string[]) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root.render(<TimelineGroupBusStrip memberLabels={memberLabels} />));
}
describe("TimelineGroupBusStrip", () => {
// "vo-1 and vo-2", the designs' own phrasing. A comma list reads as data;
// this line is a sentence about what the group holds.
it('renders "Holds …" from the member labels, joined as a sentence', () => {
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
it("joins the member labels as a sentence", () => {
renderStrip(["vo-1", "vo-2"]);
expect(container.textContent).toContain("Holdsvo-1 and vo-2");
});
it("keeps the serial comma out of a two-name list but uses it beyond that", () => {
renderStrip({ memberLabels: ["vo-1", "vo-2", "vo-3"] });
it("keeps the commas beyond two names", () => {
renderStrip(["vo-1", "vo-2", "vo-3"]);
expect(container.textContent).toContain("vo-1, vo-2 and vo-3");
});
it("falls back to a neutral line when a group has no members yet", () => {
renderStrip({ memberLabels: [] });
it("says so when a group holds nothing yet", () => {
renderStrip([]);
expect(container.textContent).toContain("Holdsnothing yet");
});
it("live-writes on every drag tick, but only commits once on release", () => {
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
const input = slider();
act(() => setSliderValue(input, "0.4"));
act(() => setSliderValue(input, "0.6"));
expect(onVolumeChange).toHaveBeenCalledTimes(2);
expect(onVolumeChange).toHaveBeenLastCalledWith(0.6);
expect(onVolumeCommit).not.toHaveBeenCalled();
act(() => {
input.value = "0.6";
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
expect(onVolumeCommit).toHaveBeenCalledWith(0.6);
});
// Unity is the ceiling because unity is what the pipeline honours: the render
// clamps every track volume to [0,1] and the preview bus clamps to match, so
// the fader's old travel to 2.0 spent its top half writing `data-volume`
// values that both ends discarded — a control promising +6 dB that nothing
// delivered.
it("clamps the volume slider to the 0..1 range the pipeline honours", () => {
// Starts below unity so each write below is a real change — setting a range
// input to the value it already holds fires no change event at all.
const { onVolumeChange } = renderStrip({ volume: 0.5 });
const input = slider();
expect(input.min).toBe("0");
expect(input.max).toBe("1");
act(() => setSliderValue(input, "1"));
expect(onVolumeChange).toHaveBeenLastCalledWith(1);
// Above unity is pulled back to unity rather than written through — the
// element's own max does the first half, `clampVolume` the rest.
act(() => setSliderValue(input, "0.5"));
act(() => setSliderValue(input, "2"));
expect(onVolumeChange).toHaveBeenLastCalledWith(1);
});
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
vi.useFakeTimers();
renderStrip();
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
});
expect(container.textContent).not.toContain("Too loud");
});
it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
vi.useFakeTimers();
renderStrip();
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent).toContain("Too loud");
// Clipping stops — the warning must still hold for a couple seconds.
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent).toContain("Too loud");
act(() => {
vi.advanceTimersByTime(2001);
});
expect(container.textContent).not.toContain("Too loud");
});
it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
vi.useFakeTimers();
renderStrip({ volume: 1.5 });
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent ?? "").not.toMatch(/dB/i);
});
// The design docs live on their own branch and never reach this PR, so the
// vocabulary rule they carry has to be enforced from inside the code or it
// gets re-broken by whoever reads only the component. It already was once:
// this strip shipped a "Bus level" label and a "how loud this bus is playing"
// tooltip. Mixing-desk nouns are ours, for talking to each other — an author
// has never met a bus, a fader, an insert or a send, and will not learn them
// to put reverb on a voiceover.
it("uses no mixing-desk vocabulary in anything the author can read", () => {
vi.useFakeTimers();
renderStrip({ volume: 0.5 });
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
// Tooltips too, not just text: the regression this catches was a `title`.
const readable = [
container.textContent ?? "",
...Array.from(container.querySelectorAll("[title], [aria-label]")).map(
(el) => `${el.getAttribute("title") ?? ""} ${el.getAttribute("aria-label") ?? ""}`,
),
].join(" ");
expect(readable).not.toMatch(/\b(bus|submix|fader|insert|send)s?\b/i);
// The volume slider and the level meter were removed with mute and solo.
// `data-volume` is still honoured by the preview bus and the render — there
// is just no control for it here, and no level read back out of the graph.
it("offers no volume control and no meter", () => {
renderStrip(["vo-1"]);
expect(container.querySelector("input")).toBeNull();
expect(container.textContent).not.toMatch(/dB|Too loud/i);
});
});
@@ -1,124 +1,34 @@
/**
* B7: the group's own volume slider + a living level bar + "Holds …" — the
* bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
* row (groups doc §5, casual-user section) — a slider, a bar that moves with
* the sound, and the words "⚠ Too loud" when it clips.
*/
import { useEffect, useRef, useState } from "react";
import { useGroupLevel } from "../../hooks/useGroupLevel";
import { STRIP_H, TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
/** How long "Too loud" stays lit after the last clipped block. */
const CLIP_HOLD_MS = 2000;
/**
* Unity is the ceiling because unity is what the pipeline honours: the render
* puts every track volume through its own `clampVolume` ([0,1]) before building
* the filter, and the preview bus clamps to match. A fader travelling to 2.0
* therefore spent its top half writing `data-volume` values that BOTH ends
* discard — the control promised +6 dB and nothing delivered it.
* What a group's `∿` area says about the group itself: which tracks it holds.
*
* Raising the ceiling instead would mean changing the render's shared clamp for
* every track, not just group buses; that is a mixer decision, not a slider one.
* B7 also put a volume slider and a live level meter here — both removed. The
* group's `data-volume` is still honoured by the preview bus and by the render;
* there is simply no control for it on this row, and nothing reads a level back
* out of the graph any more.
*/
function clampVolume(value: number): number {
return Math.min(1, Math.max(0, value));
}
import { STRIP_H, TRACK_H } from "./timelineLayout";
interface TimelineGroupBusStripProps {
groupId: string;
volume: number;
memberLabels: readonly string[];
onVolumeChange: (value: number) => void;
onVolumeCommit: (value: number) => void;
theme: TimelineTheme;
}
export function TimelineGroupBusStrip({
groupId,
volume,
memberLabels,
onVolumeChange,
onVolumeCommit,
theme,
}: TimelineGroupBusStripProps) {
const [dragValue, setDragValue] = useState<number | null>(null);
const reading = useGroupLevel(groupId);
const [clipped, setClipped] = useState(false);
const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!reading?.clipped) return;
setClipped(true);
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
}, [reading?.clipped]);
useEffect(
() => () => {
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
},
[],
);
const shownVolume = dragValue ?? volume;
const level = Math.min(1, reading?.level ?? 0);
export function TimelineGroupBusStrip({ memberLabels }: TimelineGroupBusStripProps) {
// "vo-1 and vo-2", the designs' own phrasing — a comma list reads as data,
// and this line is a sentence about what the group is holding.
const holds =
memberLabels.length > 1
? `${memberLabels.slice(0, -1).join(", ")} and ${memberLabels[memberLabels.length - 1]}`
: (memberLabels[0] ?? "nothing yet");
const holdsText = `Holds ${holds}`;
return (
<div
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
style={{ top: TRACK_H, height: STRIP_H }}
>
{/* Named, because unnamed it reads as an unexplained slider next to an
empty capsule. "Volume" is the design mockup's own label (groups doc
§5) — B7's list is slider, bar, "Holds …", "⚠ Too loud" and NOTHING
else, and the vocabulary rule bans "bus" from the product outright. */}
<span className="shrink-0 text-white/45">Volume</span>
<input
type="range"
aria-label="Group volume"
min={0}
max={1}
step={0.01}
value={shownVolume}
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
onChange={(event) => {
const next = clampVolume(Number(event.currentTarget.value));
setDragValue(next);
onVolumeChange(next);
}}
onPointerUp={(event) => {
const next = clampVolume(Number(event.currentTarget.value));
setDragValue(null);
onVolumeCommit(next);
}}
/>
<div
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
style={{ background: theme.gutterBorder }}
aria-hidden="true"
>
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${level * 100}%`,
background: clipped ? "#ff5c5c" : "#3CE6AC",
}}
/>
</div>
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]"> Too loud</span>}
{/* Label and value, as the designs split them: "Holds" is chrome, the
member list is the answer. */}
<span className="shrink-0 text-white/45">Holds</span>
<span className="min-w-0 flex-1 truncate" title={holdsText}>
<span className="min-w-0 flex-1 truncate" title={`Holds ${holds}`}>
{holds}
</span>
</div>
@@ -152,25 +152,7 @@ export function TimelineGroupRow({
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
{isLaneOpen && (
<TimelineGroupBusStrip
groupId={group.id}
volume={group.volume}
memberLabels={memberLabels}
onVolumeChange={(value) =>
onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
}
onVolumeCommit={(value) =>
onSetAudioGroupAttributeQuiet?.(
group.id,
"data-volume",
String(value),
"Set group volume",
)
}
theme={theme}
/>
)}
{isLaneOpen && <TimelineGroupBusStrip memberLabels={memberLabels} />}
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
clip's: the binder writes through the dom-edit selection, so a lane is
editable once the group is selected — which clicking its name does. */}