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
View File
@@ -2140,6 +2140,25 @@ export function initSandboxRuntimeModular(): void {
scheduleRootStageLayoutDiagnostics();
};
/** One meter reading per group with an active member — polled from the
* transport's analyser, not the DOM, so an idle group (never played, no
* matching `<hf-audio-group>`) is simply absent rather than reported as
* zero. Cheap when nothing is grouped: `groupIds()` is empty. */
const postGroupLevels = () => {
const groupIds = webAudio.groupIds();
if (groupIds.length === 0) return;
const levels = groupIds
.map((groupId) => {
const reading = webAudio.groupLevel(groupId);
return reading ? { groupId, ...reading } : null;
})
.filter(
(entry): entry is { groupId: string; level: number; clipped: boolean } => entry !== null,
);
if (levels.length === 0) return;
postRuntimeMessage({ source: "hf-preview", type: "group-levels", levels });
};
const finitePositiveDuration = (value: number | null | undefined): number =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
@@ -2897,6 +2916,9 @@ export function initSandboxRuntimeModular(): void {
if (transportTickCount % 30 === 0) {
bindMediaMetadataListeners();
}
if (clock.isPlaying()) {
postGroupLevels();
}
// Sync clock duration with the resolved timeline each tick (catches async
// rebinds, live data-duration edits). Never shrink while playing — transient
+11 -1
View File
@@ -197,6 +197,15 @@ export type RuntimePerformanceMessage = {
tags: Record<string, string | number | boolean | null>;
};
/** One audio group's live meter reading, polled from the transport each tick
* while playing. A group id absent from `levels` is idle/unknown (no active
* member) the studio side treats that as "no reading", not zero. */
export type RuntimeGroupLevelsMessage = {
source: "hf-preview";
type: "group-levels";
levels: Array<{ groupId: string; level: number; clipped: boolean }>;
};
export type RuntimeOutboundMessage =
| RuntimeStateMessage
| RuntimeTimelineMessage
@@ -210,7 +219,8 @@ export type RuntimeOutboundMessage =
| RuntimeMediaAutoplayBlockedMessage
| RuntimeReadyMessage
| RuntimeAnalyticsMessage
| RuntimePerformanceMessage;
| RuntimePerformanceMessage
| RuntimeGroupLevelsMessage;
export type RuntimePlayer = {
_timeline: RuntimeTimelineLike | null;
@@ -635,6 +635,12 @@ describe("WebAudioTransport", () => {
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
}[] = [];
const analysers: {
fftSize: number;
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
getFloatTimeDomainData: ReturnType<typeof vi.fn>;
}[] = [];
const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
const ctx = {
currentTime,
@@ -654,10 +660,20 @@ describe("WebAudioTransport", () => {
gainNodes.push(node);
return node;
}),
createAnalyser: vi.fn(() => {
const node = {
fftSize: 2048,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
};
analysers.push(node);
return node;
}),
destination: {},
close: vi.fn(),
};
return { ctx, gainNodes, masterGain };
return { ctx, gainNodes, analysers, masterGain };
}
function setupGroupTransport(currentTime = 100) {
@@ -715,12 +731,14 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
// Member gain nodes: index 0 (a) and index 2 (b) — index 1 is the
// group's own input gain, built inside a's schedule call.
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(3);
// Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the
// group's own input/output gain pair (B7's meter taps `output`),
// built inside a's schedule call.
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4);
const groupInput = firstGroupInput(mock);
const groupOutput = mock.gainNodes[2]!;
const aGain = mock.gainNodes[0]!;
const bGain = mock.gainNodes[2]!;
const bGain = mock.gainNodes[3]!;
// Neither member connects straight to master — both feed the shared bus.
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
@@ -728,9 +746,12 @@ describe("WebAudioTransport", () => {
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
// The bus itself is what reaches master — a plain sum, no processing,
// since neither member's group has a chain-bearing `<hf-audio-group>`.
expect(groupInput.connect).toHaveBeenCalledWith(mock.masterGain);
// The bus's input never reaches master directly — it lands on the
// output gain (the dry passthrough, since neither member's group has a
// chain-bearing `<hf-audio-group>`), and THAT reaches master.
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(groupInput.connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});
it("a second member of an already-open group does not rebuild the group bus", async () => {
@@ -749,7 +770,9 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(mock.masterGain);
const groupOutput = mock.gainNodes[2]!;
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});
it("group volume rides the group's own data-volume via its automation lane, not the member's", async () => {
@@ -784,6 +807,66 @@ describe("WebAudioTransport", () => {
// Still only one group-input gain ever created for "vo".
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
});
describe("groupLevel meter (B7)", () => {
it("groupLevel returns null for an unknown/idle group id", () => {
const { transport } = setupGroupTransport();
expect(transport.groupLevel("never-played")).toBeNull();
});
it("creates exactly one analyser per group, lazily, on first member", async () => {
const { transport, mock, gen } = setupGroupTransport();
expect(mock.analysers).toHaveLength(0);
await scheduleGrouped(transport, gen, "a", "vo");
expect(mock.analysers).toHaveLength(1);
expect(mock.analysers[0]!.fftSize).toBe(256); // level, not spectrum
await scheduleGrouped(transport, gen, "b", "vo");
expect(mock.analysers).toHaveLength(1); // second member reuses the bus
expect(transport.groupIds()).toEqual(["vo"]);
});
it("groupLevel reads RMS off the group's own analyser once a member is scheduled", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const analyser = mock.analysers[0]!;
analyser.getFloatTimeDomainData.mockImplementation((buf: Float32Array) => {
buf.fill(0.5);
});
const reading = transport.groupLevel("vo");
expect(reading).not.toBeNull();
expect(reading!.level).toBeCloseTo(0.5, 5);
expect(reading!.clipped).toBe(false);
});
it("flags clipped when any sample hits the ceiling", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const analyser = mock.analysers[0]!;
analyser.getFloatTimeDomainData.mockImplementation((buf: Float32Array) => {
buf.fill(0.1);
buf[0] = 0.995;
});
expect(transport.groupLevel("vo")!.clipped).toBe(true);
});
it("disposes the analyser along with the rest of the group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const analyser = mock.analysers[0]!;
transport.destroy();
expect(analyser.disconnect).toHaveBeenCalled();
expect(transport.groupLevel("vo")).toBeNull();
});
});
});
describe("decodeAudioElement retry policy (late-asset self-heal)", () => {
+46 -2
View File
@@ -127,7 +127,10 @@ export class WebAudioTransport {
// group is scheduled. Lives for the session (mirrors `_masterGain`'s own
// lifecycle) rather than being torn down on every `stopAll()`, so replaying
// a group does not rebuild its chain; only `destroy()` disposes these.
private _groups = new Map<string, { input: GainNode; dispose(): void }>();
private _groups = new Map<
string,
{ input: GainNode; analyser: AnalyserNode; levelBuf: Float32Array; dispose(): void }
>();
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
// composition time was `_rateAnchorComp`, and time has been advancing at
// `_rate` composition-seconds per wallclock-second since.
@@ -294,22 +297,39 @@ export class WebAudioTransport {
if (!this._ctx || !this._masterGain) return null;
const input = this._ctx.createGain();
// Stable point the FX chain (or, when there's none, the dry passthrough —
// see `attachElementFxChain`'s `detach()`) always lands on before master,
// regardless of whether a chain is attached/detached/rebuilt later. B7's
// meter taps here. B5's group mute gain MUST splice in before `output`
// (between the FX chain and here), never after — the meter is defined to
// read the group's true, honestly-muted level (design doc §5), and this
// node is that contract's anchor.
const output = this._ctx.createGain();
output.connect(this._masterGain);
const analyser = this._ctx.createAnalyser();
analyser.fftSize = 256; // level, not spectrum
output.connect(analyser);
const groupEl = doc.getElementById(groupId);
const fx = attachElementFxChain(
this._ctx,
groupEl ?? { getAttribute: () => null },
input,
this._masterGain,
output,
timing,
);
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
this._groups.set(groupId, {
input,
analyser,
levelBuf: new Float32Array(analyser.fftSize),
dispose: () => {
try {
fx?.dispose();
input.disconnect();
output.disconnect();
analyser.disconnect();
} catch {
// Already torn down.
}
@@ -318,6 +338,30 @@ export class WebAudioTransport {
return input;
}
/** Every group id currently routing audio (built lazily by `groupInput`
* a group with no active member yet has no entry here). */
groupIds(): string[] {
return [...this._groups.keys()];
}
/**
* RMS-ish level 0..1 and whether the last block clipped, for the group's
* meter or null when the group has no active member (idle/unknown).
* Reuses a per-group buffer; no per-frame allocation.
*/
groupLevel(groupId: string): { level: number; clipped: boolean } | null {
const g = this._groups.get(groupId);
if (!g) return null;
g.analyser.getFloatTimeDomainData(g.levelBuf);
let sumSquares = 0;
let clipped = false;
for (const sample of g.levelBuf) {
sumSquares += sample * sample;
if (Math.abs(sample) >= 0.99) clipped = true;
}
return { level: Math.sqrt(sumSquares / g.levelBuf.length), clipped };
}
/** Master, unless `el` belongs to a group then that group's bus (built on
* first use, per `groupInput`). */
private resolveDestination(