feat(studio,core): a volume and a living meter on the group row

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:30 -07:00
co-authored by Claude Sonnet 5
parent 8052f3e68f
commit e1c9b50948
25 changed files with 838 additions and 44 deletions
@@ -0,0 +1,146 @@
// @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 { 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 }));
}
describe("TimelineGroupBusStrip", () => {
it('renders "Holds …" from the member labels, comma-joined', () => {
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
expect(container.textContent).toContain("Holds vo-1, vo-2");
});
it("falls back to a neutral line when a group has no members yet", () => {
renderStrip({ memberLabels: [] });
expect(container.textContent).toContain("Holds nothing 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, "1.2"));
act(() => setSliderValue(input, "1.5"));
expect(onVolumeChange).toHaveBeenCalledTimes(2);
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
expect(onVolumeCommit).not.toHaveBeenCalled();
act(() => {
input.value = "1.5";
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
});
it("clamps the volume slider to the 0..2 range", () => {
const { onVolumeChange } = renderStrip();
const input = slider();
expect(input.min).toBe("0");
expect(input.max).toBe("2");
act(() => setSliderValue(input, "2"));
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
});
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);
});
});
@@ -0,0 +1,103 @@
/**
* 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;
function clampVolume(value: number): number {
return Math.min(2, Math.max(0, value));
}
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);
const holdsText =
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
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 }}
>
<input
type="range"
aria-label="Group volume"
min={0}
max={2}
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>}
<span className="min-w-0 flex-1 truncate" title={holdsText}>
{holdsText}
</span>
</div>
);
}
@@ -4,8 +4,10 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader";
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { groupAutomationLanes } from "./automationLaneData";
import { LABEL_COL_W } from "./timelineLayout";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
interface TimelineGroupRowProps {
index: number;
@@ -46,6 +48,12 @@ export function TimelineGroupRow({
const memberElements = group.memberTracks.flatMap(
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
);
const memberLabels = group.memberTracks.map((track, i) => {
const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
});
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
return (
<TimelineTrackRow
index={index}
@@ -67,11 +75,30 @@ export function TimelineGroupRow({
isExpanded={expandedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
laneCount={groupAutomationLanes(memberElements).length}
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
isLaneOpen={isLaneOpen}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
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}
/>
)}
</TimelineTrackRow>
);
}
@@ -68,6 +68,15 @@ export interface TimelineEditCallbacks {
options?: { coalesceKey?: string },
) => Promise<void> | void;
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
/** B7's bus strip: live-write the group's own attribute while dragging. */
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
/** ...and persist one undo entry on release. */
onSetAudioGroupAttributeQuiet?: (
groupId: string,
attr: string,
value: string | null,
label: string,
) => Promise<void>;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -7,10 +7,10 @@ export const GUTTER = 32;
export const LABEL_COL_W = 232;
export const TRACK_H = 48;
export const LANE_H = 28;
export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
export interface TimelineBeatEntry {
readonly index: number;
readonly time: number;
@@ -16,31 +16,68 @@ export interface TimelineTrackGroupInfo {
anchorKey: number;
/** Member track numbers, ascending. */
memberTracks: number[];
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
volume: number;
}
interface GroupMembership {
trackToGroupId: Map<number, string>;
memberTracksByGroup: Map<string, number[]>;
labelByGroup: Map<string, string>;
volumeByGroup: Map<string, number>;
}
/** Which track belongs to which group, and each group's label — one pass over raw tracks. */
/** Which track belongs to which group, and each group's label/volume — one pass over raw tracks. */
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
const trackToGroupId = new Map<number, string>();
const memberTracksByGroup = new Map<string, number[]>();
const labelByGroup = new Map<string, string>();
const volumeByGroup = new Map<string, number>();
for (const [trackNum, elements] of rawTracks) {
const owner = elements.find((el) => el.audioGroup);
if (!owner?.audioGroup) continue;
trackToGroupId.set(trackNum, owner.audioGroup);
if (!labelByGroup.has(owner.audioGroup)) {
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
}
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
members.push(trackNum);
memberTracksByGroup.set(owner.audioGroup, members);
}
return { trackToGroupId, memberTracksByGroup, labelByGroup };
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup };
}
/** One group's resolved row info, built once the first time its id is seen. */
function buildGroupInfo(
groupId: string,
fallbackTrackNum: number,
membership: GroupMembership,
): TimelineTrackGroupInfo {
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
(a, b) => a - b,
);
return {
id: groupId,
label: membership.labelByGroup.get(groupId) ?? groupId,
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
memberTracks,
volume: membership.volumeByGroup.get(groupId) ?? 1,
};
}
/** Push a group's synthetic anchor row plus its members' rows, contiguously. */
function emitGroupRows(
info: TimelineTrackGroupInfo,
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
trackGroupOf: Map<number, TimelineTrackGroupInfo>,
tracks: [number, TimelineElement[]][],
): void {
tracks.push([info.anchorKey, []]);
for (const member of info.memberTracks) {
trackGroupOf.set(member, info);
tracks.push([member, rawByTrack.get(member) ?? []]);
}
}
/**
@@ -54,7 +91,7 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
groups: TimelineTrackGroupInfo[];
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
} {
const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
const membership = resolveGroupMembership(rawTracks);
const rawByTrack = new Map(rawTracks);
const groups: TimelineTrackGroupInfo[] = [];
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
@@ -62,26 +99,16 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
const tracks: [number, TimelineElement[]][] = [];
for (const [trackNum, elements] of rawTracks) {
const groupId = trackToGroupId.get(trackNum);
const groupId = membership.trackToGroupId.get(trackNum);
if (!groupId) {
tracks.push([trackNum, elements]);
continue;
}
if (emitted.has(groupId)) continue;
emitted.add(groupId);
const memberTracks = [...(memberTracksByGroup.get(groupId) ?? [])].sort((a, b) => a - b);
const info: TimelineTrackGroupInfo = {
id: groupId,
label: labelByGroup.get(groupId) ?? groupId,
anchorKey: (memberTracks[0] ?? trackNum) - 0.5,
memberTracks,
};
const info = buildGroupInfo(groupId, trackNum, membership);
groups.push(info);
tracks.push([info.anchorKey, []]);
for (const member of memberTracks) {
trackGroupOf.set(member, info);
tracks.push([member, rawByTrack.get(member) ?? []]);
}
emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
}
return { tracks, groups, trackGroupOf };
}
@@ -7,12 +7,14 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import {
STRIP_H,
TRACK_H,
createTimelineRowGeometry,
type TimelineRowGeometry,
trackHeights,
type TimelineTrackHeightClip,
} from "./timelineLayout";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
export { getTrackStyle } from "./timelineIcons";
@@ -127,13 +129,35 @@ function computeLaneCounts(
return laneCounts;
}
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
* looks at a row's clips — always gives them TRACK_H. Override those
* specific rows post-hoc: TRACK_H while collapsed, +STRIP_H once the
* group's own `∿` (bus strip) is open. */
function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[],
groups: readonly TimelineTrackGroupInfo[],
expandedLaneOwnerIds: ReadonlySet<string>,
): number[] {
if (groups.length === 0) return rowHeights;
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
return tracks.map(([track], index) => {
const group = groupByAnchor.get(track);
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
return TRACK_H + STRIP_H;
});
}
function useTimelineRowHeights(
tracks: [number, TimelineElement[]][],
gsapAnimations: Map<string, GsapAnimation[]>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
groups: readonly TimelineTrackGroupInfo[],
) {
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
const { laneCounts, rowGeometry } = useMemo(() => {
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
// Keyframe lanes follow only the active clip, so a track with several
@@ -163,7 +187,12 @@ function useTimelineRowHeights(
},
];
});
const rowHeights = trackHeights(heightTracks, expandedClipIds);
const rowHeights = applyGroupStripHeights(
tracks,
trackHeights(heightTracks, expandedClipIds),
groups,
expandedLaneOwnerIds,
);
return {
laneCounts,
rowGeometry: createTimelineRowGeometry(
@@ -171,7 +200,15 @@ function useTimelineRowHeights(
rowHeights,
),
};
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
}, [
expandedClipIds,
expandedLaneOwnerIds,
gsapAnimations,
groups,
tracks,
selectedElementId,
selectedElementIds,
]);
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
rowGeometryRef.current = rowGeometry;
return {
@@ -197,6 +234,7 @@ export function useTimelineTrackLayout(
gsapAnimations,
selectedElementId,
selectedElementIds,
groups,
);
return {