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
-22
View File
@@ -2196,25 +2196,6 @@ 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;
@@ -2972,9 +2953,6 @@ 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
@@ -928,66 +928,6 @@ describe("WebAudioTransport", () => {
expect(() => transport.setGroupMuted("never-played", true)).not.toThrow();
});
});
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)", () => {
+3 -32
View File
@@ -147,8 +147,6 @@ export class WebAudioTransport {
/** Post-FX fader: `data-volume` plus the volume lane. */
fader: GainNode;
muteGain: GainNode;
analyser: AnalyserNode;
levelBuf: Float32Array;
/** Kept so `setRate` can re-aim this bus's FX automation, the way it does
* every source's its docblock claims it already did. */
fx: ElementFxHandle | null;
@@ -347,16 +345,10 @@ export class WebAudioTransport {
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. The mute gain splices 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.
// regardless of whether a chain is attached/detached/rebuilt later. The
// mute gain splices in BEFORE `output`, between the FX chain and here.
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 muteGain = this._ctx.createGain();
@@ -384,8 +376,6 @@ export class WebAudioTransport {
input,
fader,
muteGain,
analyser,
levelBuf: new Float32Array(analyser.fftSize),
fx,
generation: this._playGeneration,
reanchor: (at: AutomationTiming) => {
@@ -407,7 +397,6 @@ export class WebAudioTransport {
fader.disconnect();
muteGain.disconnect();
output.disconnect();
analyser.disconnect();
} catch {
// Already torn down.
}
@@ -418,7 +407,7 @@ export class WebAudioTransport {
/**
* Group mute, preview side a separate gain from `input`'s volume fader
* (B7) so a mute toggle never fights `scheduleVolumeLane`'s ramps on the
* so a mute toggle never fights `scheduleVolumeLane`'s ramps on the
* same param (the same hazard the design doc flags for §2.1). A no-op
* until the group has an active member: at that point `groupInput` reads
* the element's own `data-hidden` for its initial value, so there is
@@ -440,24 +429,6 @@ export class WebAudioTransport {
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(