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

* 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>

* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-21 10:13:42 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 99f42be04c
commit 5fd84c395b
26 changed files with 976 additions and 92 deletions
@@ -0,0 +1,167 @@
import { useCallback } from "react";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
import {
applyPatchByTarget,
buildPatchTarget,
readFileContent,
type RecordEditInput,
} from "./timelineEditingHelpers";
import type {
MutableRef,
UseTimelineElementVisibilityEditingInput,
} from "./timelineTrackVisibility";
/** Direct DOM write on the group element for the gesture in progress — no
* file write, no history entry (mirrors FxParamRow's live/commit split). */
function patchLiveGroupAttribute(
iframe: HTMLIFrameElement | null,
groupId: string,
attr: string,
value: string | null,
): void {
const target = iframe?.contentDocument?.getElementById(groupId);
if (!target) return;
if (value === null) target.removeAttribute(attr);
else target.setAttribute(attr, value);
}
interface SetAudioGroupAttributeInput {
projectId: string;
activeCompPath: string | null;
groupId: string;
attr: string;
value: string | null;
label: string;
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
/**
* Persist one attribute on the group element itself — e.g. the bus strip's
* volume slider writing `data-volume` on release. One undo entry; mirrors
* `createAudioGroupAndAssignMembers`'s save shape but for a single element
* and attribute rather than a member-assignment sweep.
*/
async function setAudioGroupAttribute({
projectId,
activeCompPath,
groupId,
attr,
value,
label,
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetAudioGroupAttributeInput): Promise<string[]> {
const targetPath = activeCompPath || "index.html";
const patchTarget = buildPatchTarget({ domId: groupId });
if (!patchTarget) return [];
const previousValue =
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null;
patchLiveGroupAttribute(previewIframe, groupId, attr, value);
const before = await readFileContent(projectId, targetPath);
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
throw new Error(`Unable to patch audio group ${groupId} in ${targetPath}`);
}
const operation: PatchOperation = { type: "attribute", property: attr, value };
const patched = applyPatchByTarget(before, patchTarget, operation);
pendingTimelineEditPathRef.current.add(targetPath);
domEditSaveTimestampRef.current = Date.now();
try {
const changedPaths = await saveProjectFilesWithHistory({
projectId,
label,
kind: "timeline",
files: { [targetPath]: patched },
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
writeFile: writeProjectFile,
recordEdit,
});
domEditSaveTimestampRef.current = Date.now();
return changedPaths;
} catch (error) {
// The optimistic live write already ran; unwind it on a save failure so
// the preview doesn't show a value that never reached disk.
patchLiveGroupAttribute(previewIframe, groupId, attr, previousValue);
throw error;
}
}
/**
* B7's bus strip: live-write the group's own attribute (`data-volume`, so
* far — B5's mute will reuse this too) while dragging, persist one undo entry
* on release. Unlike `useAudioGroupCarveAssignment`, this never touches
* member elements — the group id doubles as its own DOM id, so no selection
* or expanded-rows resolution is needed to find it.
*/
export function useSetAudioGroupAttribute({
projectIdRef,
activeCompPath,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
}: UseTimelineElementVisibilityEditingInput): {
setLive: (groupId: string, attr: string, value: string | null) => void;
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
} {
const setLive = useCallback(
(groupId: string, attr: string, value: string | null) => {
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
},
[previewIframeRef],
);
const setQuiet = useCallback(
async (groupId: string, attr: string, value: string | null, label: string) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
try {
await setAudioGroupAttribute({
projectId: pid,
activeCompPath,
groupId,
attr,
value,
label,
previewIframe: previewIframeRef.current,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
} catch (error) {
console.error("[Timeline] Failed to set group attribute", error);
const message = error instanceof Error ? error.message : "Failed to update group";
showToast(message);
}
},
[
activeCompPath,
previewIframeRef,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
isRecordingRef,
showToast,
projectIdRef,
],
);
return { setLive, setQuiet };
}
@@ -18,7 +18,7 @@ import {
type RecordEditInput,
} from "./timelineEditingHelpers";
interface MutableRef<T> {
export interface MutableRef<T> {
current: T;
}
@@ -68,7 +68,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
forceReloadSdkSession?: () => void;
}
interface UseTimelineElementVisibilityEditingInput extends Omit<
export interface UseTimelineElementVisibilityEditingInput extends Omit<
ToggleTimelineElementHiddenInput,
"projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
> {
@@ -0,0 +1,36 @@
/**
* A group's live meter reading (0..1 RMS-ish level, whether it just clipped),
* or null when the group is idle/unknown — never zero for "not playing yet".
*
* Mirrors useLivePlayheadTime's shape: the runtime posts readings at its own
* cadence (only while playing — see `postGroupLevels` in init.ts), this just
* throttles the re-render, it does not add its own polling loop.
*/
import { useEffect, useRef, useState } from "react";
import { groupLevels, type GroupLevelReading } from "../player/store/groupLevels";
const THROTTLE_MS = 33;
export function useGroupLevel(groupId: string): GroupLevelReading | null {
const latestRef = useRef<GroupLevelReading | null>(groupLevels.get().get(groupId) ?? null);
const [, forceRender] = useState(0);
useEffect(() => {
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
const unsubscribe = groupLevels.subscribe((levels) => {
latestRef.current = levels.get(groupId) ?? null;
if (!timerId) {
timerId = setTimeout(() => {
timerId = 0;
forceRender((v) => v + 1);
}, THROTTLE_MS);
}
});
return () => {
unsubscribe();
if (timerId) clearTimeout(timerId);
};
}, [groupId]);
return latestRef.current;
}
@@ -27,6 +27,7 @@ import {
} from "./timelineTimingSync";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
import {
useAudioGroupCarveAssignment,
useTimelineElementVisibilityEditing,
@@ -401,6 +402,18 @@ export function useTimelineEditing({
isRecordingRef,
});
const setAudioGroupAttribute = useSetAudioGroupAttribute({
projectIdRef,
activeCompPath,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
});
// fallow-ignore-next-line complexity
const handleTimelineElementsDelete = useCallback(
// fallow-ignore-next-line complexity
@@ -572,6 +585,7 @@ export function useTimelineEditing({
handleToggleTrackHidden,
handleToggleElementHidden,
handleAutoGroupCarveSources,
setAudioGroupAttribute,
handleTimelineElementDelete,
handleTimelineElementsDelete,
handleTimelineElementSplit: handleRazorSplit,