mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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:
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user