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 02:19:59 -07:00
parent 94ce6ef61b
commit 7b40334bc7
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
@@ -919,66 +919,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(
@@ -1,36 +0,0 @@
/**
* 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;
}
@@ -1,190 +1,50 @@
// @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 { afterEach, describe, expect, it } 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 }));
function renderStrip(memberLabels: readonly string[]) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root.render(<TimelineGroupBusStrip memberLabels={memberLabels} />));
}
describe("TimelineGroupBusStrip", () => {
// "vo-1 and vo-2", the designs' own phrasing. A comma list reads as data;
// this line is a sentence about what the group holds.
it('renders "Holds …" from the member labels, joined as a sentence', () => {
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
it("joins the member labels as a sentence", () => {
renderStrip(["vo-1", "vo-2"]);
expect(container.textContent).toContain("Holdsvo-1 and vo-2");
});
it("keeps the serial comma out of a two-name list but uses it beyond that", () => {
renderStrip({ memberLabels: ["vo-1", "vo-2", "vo-3"] });
it("keeps the commas beyond two names", () => {
renderStrip(["vo-1", "vo-2", "vo-3"]);
expect(container.textContent).toContain("vo-1, vo-2 and vo-3");
});
it("falls back to a neutral line when a group has no members yet", () => {
renderStrip({ memberLabels: [] });
it("says so when a group holds nothing yet", () => {
renderStrip([]);
expect(container.textContent).toContain("Holdsnothing 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, "0.4"));
act(() => setSliderValue(input, "0.6"));
expect(onVolumeChange).toHaveBeenCalledTimes(2);
expect(onVolumeChange).toHaveBeenLastCalledWith(0.6);
expect(onVolumeCommit).not.toHaveBeenCalled();
act(() => {
input.value = "0.6";
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
expect(onVolumeCommit).toHaveBeenCalledWith(0.6);
});
// Unity is the ceiling because unity is what the pipeline honours: the render
// clamps every track volume to [0,1] and the preview bus clamps to match, so
// the fader's old travel to 2.0 spent its top half writing `data-volume`
// values that both ends discarded — a control promising +6 dB that nothing
// delivered.
it("clamps the volume slider to the 0..1 range the pipeline honours", () => {
// Starts below unity so each write below is a real change — setting a range
// input to the value it already holds fires no change event at all.
const { onVolumeChange } = renderStrip({ volume: 0.5 });
const input = slider();
expect(input.min).toBe("0");
expect(input.max).toBe("1");
act(() => setSliderValue(input, "1"));
expect(onVolumeChange).toHaveBeenLastCalledWith(1);
// Above unity is pulled back to unity rather than written through — the
// element's own max does the first half, `clampVolume` the rest.
act(() => setSliderValue(input, "0.5"));
act(() => setSliderValue(input, "2"));
expect(onVolumeChange).toHaveBeenLastCalledWith(1);
});
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);
});
// The design docs live on their own branch and never reach this PR, so the
// vocabulary rule they carry has to be enforced from inside the code or it
// gets re-broken by whoever reads only the component. It already was once:
// this strip shipped a "Bus level" label and a "how loud this bus is playing"
// tooltip. Mixing-desk nouns are ours, for talking to each other — an author
// has never met a bus, a fader, an insert or a send, and will not learn them
// to put reverb on a voiceover.
it("uses no mixing-desk vocabulary in anything the author can read", () => {
vi.useFakeTimers();
renderStrip({ volume: 0.5 });
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
// Tooltips too, not just text: the regression this catches was a `title`.
const readable = [
container.textContent ?? "",
...Array.from(container.querySelectorAll("[title], [aria-label]")).map(
(el) => `${el.getAttribute("title") ?? ""} ${el.getAttribute("aria-label") ?? ""}`,
),
].join(" ");
expect(readable).not.toMatch(/\b(bus|submix|fader|insert|send)s?\b/i);
// The volume slider and the level meter were removed with mute and solo.
// `data-volume` is still honoured by the preview bus and the render — there
// is just no control for it here, and no level read back out of the graph.
it("offers no volume control and no meter", () => {
renderStrip(["vo-1"]);
expect(container.querySelector("input")).toBeNull();
expect(container.textContent).not.toMatch(/dB|Too loud/i);
});
});
@@ -1,124 +1,34 @@
/**
* 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;
/**
* Unity is the ceiling because unity is what the pipeline honours: the render
* puts every track volume through its own `clampVolume` ([0,1]) before building
* the filter, and the preview bus clamps to match. A fader travelling to 2.0
* therefore spent its top half writing `data-volume` values that BOTH ends
* discard the control promised +6 dB and nothing delivered it.
* What a group's `` area says about the group itself: which tracks it holds.
*
* Raising the ceiling instead would mean changing the render's shared clamp for
* every track, not just group buses; that is a mixer decision, not a slider one.
* B7 also put a volume slider and a live level meter here both removed. The
* group's `data-volume` is still honoured by the preview bus and by the render;
* there is simply no control for it on this row, and nothing reads a level back
* out of the graph any more.
*/
function clampVolume(value: number): number {
return Math.min(1, Math.max(0, value));
}
import { STRIP_H, TRACK_H } from "./timelineLayout";
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);
export function TimelineGroupBusStrip({ memberLabels }: TimelineGroupBusStripProps) {
// "vo-1 and vo-2", the designs' own phrasing — a comma list reads as data,
// and this line is a sentence about what the group is holding.
const holds =
memberLabels.length > 1
? `${memberLabels.slice(0, -1).join(", ")} and ${memberLabels[memberLabels.length - 1]}`
: (memberLabels[0] ?? "nothing yet");
const holdsText = `Holds ${holds}`;
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 }}
>
{/* Named, because unnamed it reads as an unexplained slider next to an
empty capsule. "Volume" is the design mockup's own label (groups doc
§5) B7's list is slider, bar, "Holds …", "⚠ Too loud" and NOTHING
else, and the vocabulary rule bans "bus" from the product outright. */}
<span className="shrink-0 text-white/45">Volume</span>
<input
type="range"
aria-label="Group volume"
min={0}
max={1}
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>}
{/* Label and value, as the designs split them: "Holds" is chrome, the
member list is the answer. */}
<span className="shrink-0 text-white/45">Holds</span>
<span className="min-w-0 flex-1 truncate" title={holdsText}>
<span className="min-w-0 flex-1 truncate" title={`Holds ${holds}`}>
{holds}
</span>
</div>
@@ -152,25 +152,7 @@ export function TimelineGroupRow({
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}
/>
)}
{isLaneOpen && <TimelineGroupBusStrip memberLabels={memberLabels} />}
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
clip's: the binder writes through the dom-edit selection, so a lane is
editable once the group is selected which clicking its name does. */}
@@ -45,7 +45,6 @@ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
import { timelineElementsChanged } from "./timelinePlayerSync";
export function useTimelinePlayer() {
@@ -496,11 +495,6 @@ export function useTimelinePlayer() {
if (data?.source === "hf-preview") {
if (!acceptStudioRuntimeMessage(data)) return;
}
if (data?.source === "hf-preview" && data?.type === "group-levels") {
const levels = parseGroupLevelsMessage(data);
if (levels) groupLevels.notify(levels);
return;
}
if (data?.source === "hf-preview" && data?.type === "state") {
try {
if (usePlayerStore.getState().elements.length === 0) {
@@ -1,33 +0,0 @@
// ponytail: mirrors liveTime.ts's plain pub-sub — same throttle-at-the-edge shape.
/** A group's live meter reading, or absent when idle/unknown (never zero). */
export type GroupLevelReading = { level: number; clipped: boolean };
type Listener = (levels: ReadonlyMap<string, GroupLevelReading>) => void;
const listeners = new Set<Listener>();
let latest: ReadonlyMap<string, GroupLevelReading> = new Map();
export const groupLevels = {
notify: (levels: ReadonlyMap<string, GroupLevelReading>) => {
latest = levels;
listeners.forEach((listener) => listener(levels));
},
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
get: () => latest,
};
/** Turns the runtime's `group-levels` postMessage payload into the map `groupLevels.notify` wants. */
export function parseGroupLevelsMessage(
data: unknown,
): ReadonlyMap<string, GroupLevelReading> | null {
const levels = (data as { levels?: unknown } | null)?.levels;
if (!Array.isArray(levels)) return null;
return new Map(
(levels as Array<{ groupId: string; level: number; clipped: boolean }>).map((entry) => [
entry.groupId,
{ level: entry.level, clipped: entry.clipped },
]),
);
}