mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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)", () => {
|
||||
|
||||
@@ -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 },
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user