From 1b16a4c877fe9f1743118f29a1aca81ae82d26ee Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 12:20:17 -0700 Subject: [PATCH] feat(studio,core)!: remove the group volume slider and level meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/runtime/init.ts | 22 --- .../src/runtime/webAudioTransport.test.ts | 60 ------ .../core/src/runtime/webAudioTransport.ts | 35 +--- packages/studio/src/hooks/useGroupLevel.ts | 36 ---- .../components/TimelineGroupBusStrip.test.tsx | 178 ++---------------- .../components/TimelineGroupBusStrip.tsx | 106 +---------- .../player/components/TimelineGroupRow.tsx | 20 +- .../src/player/hooks/useTimelinePlayer.ts | 6 - .../studio/src/player/store/groupLevels.ts | 33 ---- 9 files changed, 31 insertions(+), 465 deletions(-) delete mode 100644 packages/studio/src/hooks/useGroupLevel.ts delete mode 100644 packages/studio/src/player/store/groupLevels.ts diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index b1c781888..c53f948b9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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 ``) 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 diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index be21002b0..3c45b6b22 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -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)", () => { diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index da387148b..5e2b13d4c 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -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( diff --git a/packages/studio/src/hooks/useGroupLevel.ts b/packages/studio/src/hooks/useGroupLevel.ts deleted file mode 100644 index 708732af6..000000000 --- a/packages/studio/src/hooks/useGroupLevel.ts +++ /dev/null @@ -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(groupLevels.get().get(groupId) ?? null); - const [, forceRender] = useState(0); - - useEffect(() => { - let timerId: ReturnType | 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; -} diff --git a/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx b/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx index 617c45397..1508855f9 100644 --- a/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx +++ b/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx @@ -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> = {}) { - const onVolumeChange = vi.fn(); - const onVolumeCommit = vi.fn(); - act(() => { - root.render( - , - ); - }); - 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()); } 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); }); }); diff --git a/packages/studio/src/player/components/TimelineGroupBusStrip.tsx b/packages/studio/src/player/components/TimelineGroupBusStrip.tsx index 422693d9f..81e3fc79e 100644 --- a/packages/studio/src/player/components/TimelineGroupBusStrip.tsx +++ b/packages/studio/src/player/components/TimelineGroupBusStrip.tsx @@ -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(null); - const reading = useGroupLevel(groupId); - const [clipped, setClipped] = useState(false); - const clipTimerRef = useRef | 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 (
- {/* 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. */} - Volume - { - const next = clampVolume(Number(event.currentTarget.value)); - setDragValue(next); - onVolumeChange(next); - }} - onPointerUp={(event) => { - const next = clampVolume(Number(event.currentTarget.value)); - setDragValue(null); - onVolumeCommit(next); - }} - /> - diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 071f15a49..c9102ef21 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -152,25 +152,7 @@ export function TimelineGroupRow({ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} theme={theme} /> - {isLaneOpen && ( - - onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value)) - } - onVolumeCommit={(value) => - onSetAudioGroupAttributeQuiet?.( - group.id, - "data-volume", - String(value), - "Set group volume", - ) - } - theme={theme} - /> - )} + {isLaneOpen && } {/* 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. */} diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index cd0a9121f..575df95df 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -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) { diff --git a/packages/studio/src/player/store/groupLevels.ts b/packages/studio/src/player/store/groupLevels.ts deleted file mode 100644 index ef248358a..000000000 --- a/packages/studio/src/player/store/groupLevels.ts +++ /dev/null @@ -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) => void; -const listeners = new Set(); -let latest: ReadonlyMap = new Map(); - -export const groupLevels = { - notify: (levels: ReadonlyMap) => { - 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 | 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 }, - ]), - ); -}