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
+22 -10
View File
@@ -70,16 +70,24 @@ function resolveClipTag(clip: ClipManifestClip): string {
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
// walks the whole tree, and a parse touches every clip in it.
const groupLabelCache = new WeakMap<Document, Map<string, string>>();
const groupInfoCache = new WeakMap<Document, Map<string, { label: string; volume: number }>>();
function groupLabelFor(doc: Document | null | undefined, groupId: string): string {
if (!doc) return groupId;
let labels = groupLabelCache.get(doc);
if (!labels) {
labels = new Map(resolveAudioGroups(doc).map((group) => [group.id, group.label]));
groupLabelCache.set(doc, labels);
function groupInfoFor(
doc: Document | null | undefined,
groupId: string,
): { label: string; volume: number } {
if (!doc) return { label: groupId, volume: 1 };
let info = groupInfoCache.get(doc);
if (!info) {
info = new Map(
resolveAudioGroups(doc).map((group) => [
group.id,
{ label: group.label, volume: group.volume },
]),
);
groupInfoCache.set(doc, info);
}
return labels.get(groupId) ?? groupId;
return info.get(groupId) ?? { label: groupId, volume: 1 };
}
// fallow-ignore-next-line complexity
@@ -156,7 +164,9 @@ export function createTimelineElementFromManifestClip(params: {
const audioGroup = hostEl.getAttribute("data-audio-group");
if (audioGroup) {
entry.audioGroup = audioGroup;
entry.audioGroupLabel = groupLabelFor(doc ?? hostEl.ownerDocument, audioGroup);
const info = groupInfoFor(doc ?? hostEl.ownerDocument, audioGroup);
entry.audioGroupLabel = info.label;
entry.audioGroupVolume = info.volume;
}
const fxChain = hostEl.getAttribute("data-fx-chain");
if (fxChain) entry.fxChain = fxChain;
@@ -379,7 +389,9 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
const domAudioGroup = el.getAttribute("data-audio-group");
if (domAudioGroup) {
entry.audioGroup = domAudioGroup;
entry.audioGroupLabel = groupLabelFor(doc, domAudioGroup);
const domGroupInfo = groupInfoFor(doc, domAudioGroup);
entry.audioGroupLabel = domGroupInfo.label;
entry.audioGroupVolume = domGroupInfo.volume;
}
// Sub-compositions