mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
* feat(studio,core): a volume and a living meter on the group row B7: the group bus strip — droppable, and deliberately minimal per the casual-user design constraints (groups doc §5): a volume slider, a level bar that moves with the sound, and the words "Too loud" when it clips. No dB numbers, no peak-hold readout, no routing row. Transport (core): groupInput() now routes each group through input -> [FX chain or dry passthrough] -> output -> master, with one AnalyserNode per group tapped off `output` (post-FX, so the meter reads what the bus actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId) returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer (no per-frame allocation), or null when the group is idle/unknown. The runtime posts group-levels messages only while playing, piggybacking the existing message channel rather than adding a new poll loop. Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's shape) fed by useTimelinePlayer's message handler via parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms. TimelineGroupBusStrip renders in the group row's own `∿` lane area (STRIP_H, already sized in B2's row-height pipeline) — drag writes live via onSetAudioGroupAttributeLive, release commits one undo entry via onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/ timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to stay under the 600-line cap; mirrors FxParamRow's live/commit split). "Too loud" holds for ~2s after the last clipped block, tracked in the component, not the transport. volumeByGroup mirrors labelByGroup in useTimelineTrackDerivations.ts so the strip's slider round-trips the group's own data-volume. Fixed two pre-existing group-routing tests in webAudioTransport.test.ts that hardcoded gain-node creation order/count — B7 inserts an extra `output` gain node between the group's input and master (for the meter to tap), which shifted node indices the tests asserted on directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared Two CI gates, both from this branch's own additions. `File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the group-levels branch pushed it to 605 (cap 600). Extracted the `window.message` router — which already carried a `fallow-ignore-next-line complexity` admitting it had outgrown its home — into `previewMessageRouter.ts`, with the fixture lease, sender check and protocol accept-gate collapsed into one `acceptedPreviewMessage` so the listener is a flat dispatch and the suppression is retired rather than moved. Same branches, same refs, no behaviour change; the file lands at 561. `Test: runtime contract`: `levelBuf: Float32Array` resolves to `Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and `getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345). Pinned the field to `Float32Array<ArrayBuffer>`, which is what `new Float32Array(analyser.fftSize)` already produces. Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")` — main deleted that component in favour of `feedback/StudioFeedbackCard`, and touching this file for the group prop put the dangling path in fallow's scope. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
// @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 { 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 }));
|
|
}
|
|
|
|
describe("TimelineGroupBusStrip", () => {
|
|
it('renders "Holds …" from the member labels, comma-joined', () => {
|
|
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
|
|
expect(container.textContent).toContain("Holds vo-1, vo-2");
|
|
});
|
|
|
|
it("falls back to a neutral line when a group has no members yet", () => {
|
|
renderStrip({ memberLabels: [] });
|
|
expect(container.textContent).toContain("Holds nothing 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, "1.2"));
|
|
act(() => setSliderValue(input, "1.5"));
|
|
expect(onVolumeChange).toHaveBeenCalledTimes(2);
|
|
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
|
|
expect(onVolumeCommit).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
input.value = "1.5";
|
|
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
});
|
|
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
|
|
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
|
|
});
|
|
|
|
it("clamps the volume slider to the 0..2 range", () => {
|
|
const { onVolumeChange } = renderStrip();
|
|
const input = slider();
|
|
expect(input.min).toBe("0");
|
|
expect(input.max).toBe("2");
|
|
|
|
act(() => setSliderValue(input, "2"));
|
|
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|