From aacc84d79ef4bc9870d01f0f58cc3af7869649b1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 16:44:15 -0400 Subject: [PATCH 01/11] feat(studio): audio meter faders for groups and master --- .../components/nle/AudioMeterStrip.test.tsx | 88 +++++++- .../src/components/nle/AudioMeterStrip.tsx | 197 ++++++++++++++++-- .../studio/src/utils/audioMeterMath.test.ts | 18 ++ packages/studio/src/utils/audioMeterMath.ts | 15 ++ 4 files changed, 294 insertions(+), 24 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index b1ba184111..aba54eee94 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -17,6 +17,35 @@ vi.mock("../../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ previewIframeRef }), })); +const onSetAudioGroupAttributeLive = vi.fn(); +const onSetAudioGroupAttributeQuiet = vi.fn(); +vi.mock("../../contexts/TimelineEditContext", () => ({ + useTimelineEditContextOptional: () => ({ + onSetAudioGroupAttributeLive, + onSetAudioGroupAttributeQuiet, + }), +})); + +function stubTrackRect(): () => void { + const original = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (): DOMRect { + return { + left: 0, + top: 0, + right: 8, + bottom: 100, + width: 8, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }; + return () => { + Element.prototype.getBoundingClientRect = original; + }; +} + const makeHook = (groups: Record = {}) => ({ start: vi.fn(), stop: vi.fn(), @@ -39,8 +68,10 @@ beforeEach(() => { frames = []; vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb)); vi.stubGlobal("cancelAnimationFrame", () => {}); - usePlayerStore.setState({ elements: [] }); + usePlayerStore.setState({ elements: [], audioVolume: 1 }); useAudioMetersVisible.setState({ visible: true }); + onSetAudioGroupAttributeLive.mockClear(); + onSetAudioGroupAttributeQuiet.mockClear(); }); const roots: Root[] = []; afterEach(() => { @@ -84,12 +115,12 @@ describe("AudioMeterStrip", () => { const first = makeHook({ vo: { l: 1, r: 1 } }); setHook(first); const { host, root } = mount(); - const fill = host.querySelector("[class*=bg-green-500]")!; - expect(fill.style.transform).toBe("scaleY(0)"); + const mask = host.querySelector("[data-testid=meter-mask]")!; + expect(mask.style.height).toBe("100%"); tick(); tick(); expect(first.start).toHaveBeenCalledTimes(1); - expect(fill.style.transform).toBe("scaleY(1)"); + expect(mask.style.height).toBe("0%"); const peak = host.querySelector("[data-testid=meter-peak]")!; expect(peak.style.bottom).toBe("100%"); expect(peak.style.transform).toBe("translateY(1px)"); @@ -121,4 +152,53 @@ describe("AudioMeterStrip", () => { tick(); expect(second.read).toHaveBeenCalled(); }); + + it("drags a group fader through the live/quiet data-volume path, and the master fader through the player store", () => { + const restoreRect = stubTrackRect(); + const originalPointerCapture = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = vi.fn(); + usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupLabel: "VO" })] }); + const { host } = mount(); + + const groupFader = host.querySelector('[aria-label="VO volume"]')!; + act(() => { + groupFader.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientY: 25 }), + ); + }); + expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith( + "vo", + "data-volume", + expect.any(String), + ); + expect(onSetAudioGroupAttributeQuiet).not.toHaveBeenCalled(); + act(() => { + groupFader.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientY: 25 }), + ); + }); + expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledWith( + "vo", + "data-volume", + expect.any(String), + "Set volume", + ); + + const masterFader = host.querySelector('[aria-label="Master volume"]')!; + act(() => { + masterFader.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientY: 0 }), + ); + }); + expect(usePlayerStore.getState().audioVolume).toBe(1); + act(() => { + masterFader.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientY: 100 }), + ); + }); + expect(usePlayerStore.getState().audioVolume).toBe(0); + + Element.prototype.setPointerCapture = originalPointerCapture; + restoreRect(); + }); }); diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index 9580c1d57c..c379cb0ffd 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -1,10 +1,22 @@ -import { memo, useEffect, useMemo, useRef, type Ref, type RefObject } from "react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + type PointerEvent as ReactPointerEvent, + type Ref, + type RefObject, +} from "react"; import { usePlayerStore } from "../../player"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; import { useStudioShellContext } from "../../contexts/StudioContext"; +import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { METER_DB_MARKS, useProjectHasAudio, + fractionToLevel, + levelToFraction, markFraction, stepPair, type MeterPair as Pair, @@ -22,23 +34,61 @@ interface AudioMeterHook { interface Strip { id: string | null; label: string; + volume: number; } -type Bars = { fill: HTMLElement | null; peak: HTMLElement | null }; +type Bars = { mask: HTMLElement | null; peak: HTMLElement | null }; type StripBars = [Bars, Bars]; -const MASTER: Strip = { id: null, label: "Master" }; +const MASTER_LABEL = "Master"; + +/** Where the fill turns yellow, then red, on the same piecewise dB scale the marks use. */ +const YELLOW_AT = markFraction(-6); +const RED_AT = markFraction(-3); function useStrips(): Strip[] { const elements = usePlayerStore((s) => s.elements); + const masterVolume = usePlayerStore((s) => s.audioVolume); return useMemo(() => { const labels = new Map(); + const volumes = new Map(); for (const el of elements) { if (el.audioGroup && !labels.has(el.audioGroup)) { labels.set(el.audioGroup, el.audioGroupLabel ?? el.audioGroup); + volumes.set(el.audioGroup, el.audioGroupVolume ?? 1); } } - return [...[...labels].map(([id, label]) => ({ id, label })), MASTER]; - }, [elements]); + return [ + ...[...labels].map(([id, label]) => ({ id, label, volume: volumes.get(id) ?? 1 })), + { id: null, label: MASTER_LABEL, volume: masterVolume }, + ]; + }, [elements, masterVolume]); +} + +/** Group volume through the existing `data-volume` write path (live while dragging, one + * undo entry on release); master volume through the player store's own volume action — + * the same one `VolumeControl` in `PlayerControls` already drives. */ +function useVolumeHandlers(): { + onLive: (id: string | null, volume: number) => void; + onCommit: (id: string | null, volume: number) => void; +} { + const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = + useTimelineEditContextOptional(); + const setAudioVolume = usePlayerStore((s) => s.setAudioVolume); + const onLive = useCallback( + (id: string | null, volume: number) => { + if (id === null) setAudioVolume(volume); + else onSetAudioGroupAttributeLive?.(id, "data-volume", String(volume)); + }, + [onSetAudioGroupAttributeLive, setAudioVolume], + ); + const onCommit = useCallback( + (id: string | null, volume: number) => { + if (id === null) setAudioVolume(volume); + else void onSetAudioGroupAttributeQuiet?.(id, "data-volume", String(volume), "Set volume"); + }, + [onSetAudioGroupAttributeQuiet, setAudioVolume], + ); + return { onLive, onCommit }; } type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined; @@ -59,9 +109,12 @@ function paintPeak(el: HTMLElement | null, peak: number): void { el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none"); } +/** The fill is a fixed green/yellow/red backdrop; painting only moves the dark + * mask that covers the unlit top portion, so a loud peak lights the real red + * band instead of tinting a flat colour brighter. */ function paint(bars: StripBars | undefined, channels: Pair): void { channels.forEach((ch, i) => { - bars?.[i]?.fill?.style.setProperty("transform", `scaleY(${ch.level})`); + bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`); paintPeak(bars?.[i]?.peak ?? null, ch.peak); }); } @@ -119,18 +172,103 @@ function useMeterLoop(strips: Strip[], bars: RefObject; peakRef: Ref }) { +function Bar({ maskRef, peakRef }: { maskRef: Ref; peakRef: Ref }) { return ( -
+
+
+
+
+
+ ); +} + +/** A vertical fader beside the meter, reusing the meter's own dB scale for its + * travel so the thumb lines up with the marks. Dragging anywhere on the + * track jumps the thumb there, not just grabbing it exactly. */ +function Fader({ + label, + volume, + onLive, + onCommit, +}: { + label: string; + volume: number; + onLive: (v: number) => void; + onCommit: (v: number) => void; +}) { + const trackRef = useRef(null); + const draggingRef = useRef(false); + const fraction = levelToFraction(Math.min(volume, 1)); + + const fractionAt = useCallback( + (clientY: number): number => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect || rect.height === 0) return fraction; + return Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height)); + }, + [fraction], + ); + + const nudge = useCallback( + (delta: number) => onCommit(Math.max(0, Math.min(1, volume + delta))), + [onCommit, volume], + ); + + return ( +
) => { + e.currentTarget.setPointerCapture(e.pointerId); + draggingRef.current = true; + onLive(fractionToLevel(fractionAt(e.clientY))); + }} + onPointerMove={(e: ReactPointerEvent) => { + if (!draggingRef.current) return; + onLive(fractionToLevel(fractionAt(e.clientY))); + }} + onPointerUp={(e: ReactPointerEvent) => { + if (!draggingRef.current) return; + draggingRef.current = false; + onCommit(fractionToLevel(fractionAt(e.clientY))); + }} + onKeyDown={(e) => { + if (e.key === "ArrowUp") nudge(0.02); + else if (e.key === "ArrowDown") nudge(-0.02); + else return; + e.preventDefault(); + }} + className="relative h-full w-2 shrink-0 cursor-ns-resize touch-none rounded-full bg-neutral-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent" + > +
); @@ -139,9 +277,13 @@ function Bar({ fillRef, peakRef }: { fillRef: Ref; peakRef: Ref< function MeterStrip({ strip, register, + onLive, + onCommit, }: { strip: Strip; register: (id: string | null, bars: StripBars | null) => void; + onLive: (id: string | null, volume: number) => void; + onCommit: (id: string | null, volume: number) => void; }) { const refs = [ useRef(null), @@ -151,22 +293,26 @@ function MeterStrip({ ] as const; useEffect(() => { register(strip.id, [ - { fill: refs[0].current, peak: refs[1].current }, - { fill: refs[2].current, peak: refs[3].current }, + { mask: refs[0].current, peak: refs[1].current }, + { mask: refs[2].current, peak: refs[3].current }, ]); return () => register(strip.id, null); // refs are stable objects // eslint-disable-next-line react-hooks/exhaustive-deps }, [strip.id, register]); return ( -
+
- - -
+ onLive(strip.id, v)} + onCommit={(v) => onCommit(strip.id, v)} + /> +
{METER_DB_MARKS.map((db) => ( ))}
+
+ + +
{strip.label} @@ -199,6 +349,7 @@ function MeterStripBody() { if (b) bars.current.set(id, b); else bars.current.delete(id); }).current; + const { onLive, onCommit } = useVolumeHandlers(); useMeterLoop(strips, bars); return (
{strips.map((strip) => ( - + ))}
); diff --git a/packages/studio/src/utils/audioMeterMath.test.ts b/packages/studio/src/utils/audioMeterMath.test.ts index 95884a0581..a914309d60 100644 --- a/packages/studio/src/utils/audioMeterMath.test.ts +++ b/packages/studio/src/utils/audioMeterMath.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + fractionToLevel, levelToFraction, markFraction, SILENT_CHANNEL, @@ -23,6 +24,23 @@ describe("levelToFraction", () => { }); }); +describe("fractionToLevel", () => { + it("round-trips through levelToFraction at each dB mark", () => { + [0, -3, -6, -12, -24].forEach((db) => { + const f = markFraction(db); + expect(levelToFraction(fractionToLevel(f))).toBeCloseTo(f, 6); + }); + }); + it("pins the ends: silence at 0, unity at 1", () => { + expect(fractionToLevel(0)).toBe(0); + expect(fractionToLevel(1)).toBe(1); + }); + it("clamps out-of-range fractions", () => { + expect(fractionToLevel(-0.5)).toBe(0); + expect(fractionToLevel(1.5)).toBe(1); + }); +}); + describe("stepChannel", () => { it("attacks instantly, holds the peak, then falls", () => { let ch = stepChannel(SILENT_CHANNEL, 1, 0, 16); diff --git a/packages/studio/src/utils/audioMeterMath.ts b/packages/studio/src/utils/audioMeterMath.ts index d1ce0c9dc2..5df4dcae84 100644 --- a/packages/studio/src/utils/audioMeterMath.ts +++ b/packages/studio/src/utils/audioMeterMath.ts @@ -26,6 +26,21 @@ export function markFraction(db: number): number { return levelToFraction(10 ** (db / 20)); } +/** Inverse of `levelToFraction`: a 0..1 fader fraction back to a linear volume. */ +export function fractionToLevel(fraction: number): number { + const f = Math.max(0, Math.min(1, fraction)); + if (f <= 0) return 0; + if (f >= 1) return 1; + const n = STOPS.length - 1; + const scaled = f * n; + const seg = Math.min(n - 1, Math.floor(scaled)); + const segFraction = scaled - seg; + const j = n - seg; + const hi = STOPS[j - 1] as number; + const lo = STOPS[j] as number; + return 10 ** ((lo + segFraction * (hi - lo)) / 20); +} + export interface MeterChannel { level: number; peak: number; From c486c425fee1d0aa17c6dd80892b9e701deaae4a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 17:12:57 -0400 Subject: [PATCH 02/11] fix(studio): meter's mid band uses amber-500, the theme's actual token yellow-500 was reset to nothing by palette-reset.css (--color-*: initial) and never redefined in theme.css, so the mask never revealed any color for -6 to -3dB; the token gate test already catches this and was red. --- packages/studio/src/components/nle/AudioMeterStrip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index c379cb0ffd..622fa433a8 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -180,7 +180,7 @@ function Bar({ maskRef, peakRef }: { maskRef: Ref; peakRef: Ref< style={{ height: `${YELLOW_AT * 100}%` }} />
Date: Sat, 19 Sep 2026 17:15:55 -0400 Subject: [PATCH 03/11] refactor(studio): rename YELLOW_AT to AMBER_AT to match the amber token --- packages/studio/src/components/nle/AudioMeterStrip.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index 622fa433a8..e3aedd6d16 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -41,8 +41,8 @@ type StripBars = [Bars, Bars]; const MASTER_LABEL = "Master"; -/** Where the fill turns yellow, then red, on the same piecewise dB scale the marks use. */ -const YELLOW_AT = markFraction(-6); +/** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */ +const AMBER_AT = markFraction(-6); const RED_AT = markFraction(-3); function useStrips(): Strip[] { @@ -109,7 +109,7 @@ function paintPeak(el: HTMLElement | null, peak: number): void { el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none"); } -/** The fill is a fixed green/yellow/red backdrop; painting only moves the dark +/** The fill is a fixed green/amber/red backdrop; painting only moves the dark * mask that covers the unlit top portion, so a loud peak lights the real red * band instead of tinting a flat colour brighter. */ function paint(bars: StripBars | undefined, channels: Pair): void { @@ -177,11 +177,11 @@ function Bar({ maskRef, peakRef }: { maskRef: Ref; peakRef: Ref<
Date: Sat, 19 Sep 2026 17:52:00 -0400 Subject: [PATCH 04/11] fix(studio): fader nudge and aria-valuenow match the visual dB position --- .../components/nle/AudioMeterStrip.test.tsx | 40 ++++++++++++++++--- .../src/components/nle/AudioMeterStrip.tsx | 6 +-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index aba54eee94..eed0f21097 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore } from "../../player/store/playerStore"; import type { TimelineElement } from "../../player/store/timelineElement"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; +import { fractionToLevel, levelToFraction } from "../../utils/audioMeterMath"; import { AudioMeterStrip } from "./AudioMeterStrip"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -166,11 +167,10 @@ describe("AudioMeterStrip", () => { new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientY: 25 }), ); }); - expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith( - "vo", - "data-volume", - expect.any(String), - ); + // clientY 25 on a 0..100 track (stubTrackRect) is fraction 0.75; asserting + // the exact computed value (not just "a string") also catches a NaN regression. + const expectedVolume = String(fractionToLevel(0.75)); + expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", expectedVolume); expect(onSetAudioGroupAttributeQuiet).not.toHaveBeenCalled(); act(() => { groupFader.dispatchEvent( @@ -180,7 +180,7 @@ describe("AudioMeterStrip", () => { expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledWith( "vo", "data-volume", - expect.any(String), + expectedVolume, "Set volume", ); @@ -201,4 +201,32 @@ describe("AudioMeterStrip", () => { Element.prototype.setPointerCapture = originalPointerCapture; restoreRect(); }); + + it("nudges a consistent step in the visual (dB-scale) position, and aria-valuenow tracks the thumb", () => { + usePlayerStore.setState({ elements: [clip({})], audioVolume: 0.5 }); + const { host } = mount(); + const masterFader = host.querySelector('[aria-label="Master volume"]')!; + const startFraction = levelToFraction(0.5); + expect(masterFader.getAttribute("aria-valuenow")).toBe(String(Math.round(startFraction * 100))); + + act(() => { + masterFader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + }); + const afterUp = usePlayerStore.getState().audioVolume; + const fractionAfterUp = levelToFraction(afterUp); + // A flat step in raw volume (the old, buggy behaviour) would move the + // visual position by a very different amount depending on where it starts; + // stepping in fraction space keeps every step visually the same size. + expect(fractionAfterUp - startFraction).toBeCloseTo(0.02, 6); + expect(masterFader.getAttribute("aria-valuenow")).toBe( + String(Math.round(fractionAfterUp * 100)), + ); + + act(() => usePlayerStore.setState({ audioVolume: 0.5 })); + act(() => { + masterFader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + }); + const afterDown = usePlayerStore.getState().audioVolume; + expect(startFraction - levelToFraction(afterDown)).toBeCloseTo(0.02, 6); + }); }); diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index e3aedd6d16..ddc9c5f87e 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -230,8 +230,8 @@ function Fader({ ); const nudge = useCallback( - (delta: number) => onCommit(Math.max(0, Math.min(1, volume + delta))), - [onCommit, volume], + (delta: number) => onCommit(fractionToLevel(Math.max(0, Math.min(1, fraction + delta)))), + [onCommit, fraction], ); return ( @@ -243,7 +243,7 @@ function Fader({ aria-orientation="vertical" aria-valuemin={0} aria-valuemax={100} - aria-valuenow={Math.round(Math.min(volume, 1) * 100)} + aria-valuenow={Math.round(fraction * 100)} onPointerDown={(e: ReactPointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); draggingRef.current = true; From 2d14f0e7acf5b23b12e0b75ca242218081971418 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:25:38 -0400 Subject: [PATCH 05/11] refactor(studio): use core StereoLevel for meter channel shape --- packages/core/package-subpaths.json | 6 ++++++ packages/core/package.json | 10 ++++++++++ packages/studio/src/components/nle/AudioMeterStrip.tsx | 5 +++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 6a390f486a..1a669fc32a 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -272,6 +272,12 @@ "types": "./dist/runtime/webAudioRoute.d.ts", "environments": ["browser", "bun", "node"] }, + "./runtime/levelTap": { + "source": "./src/runtime/levelTap.ts", + "runtime": "./dist/runtime/levelTap.js", + "types": "./dist/runtime/levelTap.d.ts", + "environments": ["browser", "bun", "node"] + }, "./compiler/html-document": { "source": "./src/compiler/htmlDocument.ts", "runtime": "./dist/compiler/htmlDocument.js", diff --git a/packages/core/package.json b/packages/core/package.json index 78c53c3237..0dac8c85f8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -281,6 +281,12 @@ "import": "./src/runtime/webAudioRoute.ts", "types": "./src/runtime/webAudioRoute.ts" }, + "./runtime/levelTap": { + "bun": "./src/runtime/levelTap.ts", + "node": "./dist/runtime/levelTap.js", + "import": "./src/runtime/levelTap.ts", + "types": "./src/runtime/levelTap.ts" + }, "./compiler/html-document": { "bun": "./src/compiler/htmlDocument.ts", "node": "./dist/compiler/htmlDocument.js", @@ -599,6 +605,10 @@ "import": "./dist/runtime/webAudioRoute.js", "types": "./dist/runtime/webAudioRoute.d.ts" }, + "./runtime/levelTap": { + "import": "./dist/runtime/levelTap.js", + "types": "./dist/runtime/levelTap.d.ts" + }, "./compiler/html-document": { "import": "./dist/compiler/htmlDocument.js", "types": "./dist/compiler/htmlDocument.d.ts" diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index ddc9c5f87e..b119710d96 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -8,6 +8,7 @@ import { type Ref, type RefObject, } from "react"; +import type { StereoLevel } from "@hyperframes/core/runtime/levelTap"; import { usePlayerStore } from "../../player"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; import { useStudioShellContext } from "../../contexts/StudioContext"; @@ -23,8 +24,8 @@ import { } from "../../utils/audioMeterMath"; interface Levels { - master: { l: number; r: number }; - groups: Record; + master: StereoLevel; + groups: Record; } interface AudioMeterHook { start(): void; From 9fbdf7658f59465563cf003c6ea159d4d1fe58fb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:50:26 -0400 Subject: [PATCH 06/11] fix(core): emit levelTap in the packed core build --- packages/core/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 774cb58776..8956a57d01 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -21,6 +21,7 @@ "src/runtime/protocol.ts", "src/runtime/stackingContext.ts", "src/runtime/webAudioRoute.ts", + "src/runtime/levelTap.ts", "src/runtime/wiggleEase.ts" ], "include": ["src/**/*"], From e3e7ff07c881776d3a697f5f9309c4e2a64a6fec Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 17:05:29 -0400 Subject: [PATCH 07/11] fix(studio): name the preview fader Monitor and share clampNumber --- .../components/nle/AudioMeterStrip.test.tsx | 35 +++++++++++++++---- .../src/components/nle/AudioMeterStrip.tsx | 17 ++++++--- .../studio/src/utils/audioMeterMath.test.ts | 4 +++ packages/studio/src/utils/audioMeterMath.ts | 4 ++- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index eed0f21097..c548d5c101 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -96,7 +96,7 @@ describe("AudioMeterStrip", () => { expect(mount().host.querySelector("[data-testid=audio-meter-strip]")).toBeNull(); }); - it("shows one strip per group plus Master, and hides when toggled off", () => { + it("shows one strip per group plus Monitor, and hides when toggled off", () => { usePlayerStore.setState({ elements: [ clip({ id: "1", audioGroup: "music", audioGroupLabel: "Music" }), @@ -106,7 +106,8 @@ describe("AudioMeterStrip", () => { const { host } = mount(); expect(host.textContent).toContain("Music"); expect(host.textContent).toContain("vo"); - expect(host.textContent).toContain("Master"); + expect(host.textContent).toContain("Monitor"); + expect(host.textContent).not.toContain("Master"); act(() => useAudioMetersVisible.getState().setVisible(false)); expect(host.querySelector("[data-testid=audio-meter-strip]")).toBeNull(); }); @@ -184,15 +185,15 @@ describe("AudioMeterStrip", () => { "Set volume", ); - const masterFader = host.querySelector('[aria-label="Master volume"]')!; + const monitorFader = host.querySelector('[aria-label="Monitor volume"]')!; act(() => { - masterFader.dispatchEvent( + monitorFader.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientY: 0 }), ); }); expect(usePlayerStore.getState().audioVolume).toBe(1); act(() => { - masterFader.dispatchEvent( + monitorFader.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientY: 100 }), ); }); @@ -202,10 +203,32 @@ describe("AudioMeterStrip", () => { restoreRect(); }); + it("the monitor fader writes setAudioVolume and is labeled Monitor", () => { + const restoreRect = stubTrackRect(); + const originalPointerCapture = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = vi.fn(); + const setAudioVolume = vi.fn((volume: number) => { + usePlayerStore.setState({ audioVolume: volume }); + }); + usePlayerStore.setState({ elements: [clip({})], audioVolume: 1, setAudioVolume }); + const { host } = mount(); + expect(host.textContent).toContain("Monitor"); + const fader = host.querySelector('[aria-label="Monitor volume"]')!; + expect(fader.getAttribute("title")).toBe("Preview monitor volume"); + act(() => { + fader.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientY: 100 }), + ); + }); + expect(setAudioVolume).toHaveBeenCalledWith(0); + Element.prototype.setPointerCapture = originalPointerCapture; + restoreRect(); + }); + it("nudges a consistent step in the visual (dB-scale) position, and aria-valuenow tracks the thumb", () => { usePlayerStore.setState({ elements: [clip({})], audioVolume: 0.5 }); const { host } = mount(); - const masterFader = host.querySelector('[aria-label="Master volume"]')!; + const masterFader = host.querySelector('[aria-label="Monitor volume"]')!; const startFraction = levelToFraction(0.5); expect(masterFader.getAttribute("aria-valuenow")).toBe(String(Math.round(startFraction * 100))); diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index b119710d96..9d458fd455 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -10,6 +10,7 @@ import { } from "react"; import type { StereoLevel } from "@hyperframes/core/runtime/levelTap"; import { usePlayerStore } from "../../player"; +import { clampNumber } from "../../utils/studioHelpers"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; import { useStudioShellContext } from "../../contexts/StudioContext"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; @@ -40,7 +41,7 @@ interface Strip { type Bars = { mask: HTMLElement | null; peak: HTMLElement | null }; type StripBars = [Bars, Bars]; -const MASTER_LABEL = "Master"; +const MONITOR_LABEL = "Monitor"; /** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */ const AMBER_AT = markFraction(-6); @@ -60,13 +61,13 @@ function useStrips(): Strip[] { } return [ ...[...labels].map(([id, label]) => ({ id, label, volume: volumes.get(id) ?? 1 })), - { id: null, label: MASTER_LABEL, volume: masterVolume }, + { id: null, label: MONITOR_LABEL, volume: masterVolume }, ]; }, [elements, masterVolume]); } /** Group volume through the existing `data-volume` write path (live while dragging, one - * undo entry on release); master volume through the player store's own volume action — + * undo entry on release); monitor volume through the player store's own volume action — * the same one `VolumeControl` in `PlayerControls` already drives. */ function useVolumeHandlers(): { onLive: (id: string | null, volume: number) => void; @@ -149,6 +150,8 @@ function useMeterLoop(strips: Strip[], bars: RefObject s.id)); + for (const id of state.keys()) if (!liveIds.has(id)) state.delete(id); for (const { id } of stripsRef.current) { const next = stepPair( state.get(id), @@ -208,11 +211,13 @@ function Bar({ maskRef, peakRef }: { maskRef: Ref; peakRef: Ref< * track jumps the thumb there, not just grabbing it exactly. */ function Fader({ label, + title, volume, onLive, onCommit, }: { label: string; + title: string; volume: number; onLive: (v: number) => void; onCommit: (v: number) => void; @@ -225,13 +230,13 @@ function Fader({ (clientY: number): number => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect || rect.height === 0) return fraction; - return Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height)); + return clampNumber(1 - (clientY - rect.top) / rect.height, 0, 1); }, [fraction], ); const nudge = useCallback( - (delta: number) => onCommit(fractionToLevel(Math.max(0, Math.min(1, fraction + delta)))), + (delta: number) => onCommit(fractionToLevel(clampNumber(fraction + delta, 0, 1))), [onCommit, fraction], ); @@ -241,6 +246,7 @@ function Fader({ role="slider" tabIndex={0} aria-label={label} + title={title} aria-orientation="vertical" aria-valuemin={0} aria-valuemax={100} @@ -309,6 +315,7 @@ function MeterStrip({ > onLive(strip.id, v)} onCommit={(v) => onCommit(strip.id, v)} diff --git a/packages/studio/src/utils/audioMeterMath.test.ts b/packages/studio/src/utils/audioMeterMath.test.ts index a914309d60..deecfa8d00 100644 --- a/packages/studio/src/utils/audioMeterMath.test.ts +++ b/packages/studio/src/utils/audioMeterMath.test.ts @@ -39,6 +39,10 @@ describe("fractionToLevel", () => { expect(fractionToLevel(-0.5)).toBe(0); expect(fractionToLevel(1.5)).toBe(1); }); + it("returns 0 for non-finite input", () => { + expect(fractionToLevel(Number.NaN)).toBe(0); + expect(fractionToLevel(Number.POSITIVE_INFINITY)).toBe(0); + }); }); describe("stepChannel", () => { diff --git a/packages/studio/src/utils/audioMeterMath.ts b/packages/studio/src/utils/audioMeterMath.ts index 5df4dcae84..ef33d48600 100644 --- a/packages/studio/src/utils/audioMeterMath.ts +++ b/packages/studio/src/utils/audioMeterMath.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; import { isAudioTimelineElement } from "./timelineInspector"; +import { clampNumber } from "./studioHelpers"; /** dB stops of the meter scale, top to bottom; equal spacing between stops. */ export const METER_DB_MARKS = [0, -3, -6, -12, -24] as const; @@ -28,7 +29,8 @@ export function markFraction(db: number): number { /** Inverse of `levelToFraction`: a 0..1 fader fraction back to a linear volume. */ export function fractionToLevel(fraction: number): number { - const f = Math.max(0, Math.min(1, fraction)); + if (!Number.isFinite(fraction)) return 0; + const f = clampNumber(fraction, 0, 1); if (f <= 0) return 0; if (f >= 1) return 1; const n = STOPS.length - 1; From 0c0165b557d9bbce34fef00d449690b95519056b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 17:28:38 -0400 Subject: [PATCH 08/11] fix(studio): split the meter rAF tick and cover its branches --- .../components/nle/AudioMeterStrip.test.tsx | 82 ++++++++++++++++++- .../src/components/nle/AudioMeterStrip.tsx | 75 +++++++++++------ 2 files changed, 128 insertions(+), 29 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index c548d5c101..709e52cd79 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -7,8 +7,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore } from "../../player/store/playerStore"; import type { TimelineElement } from "../../player/store/timelineElement"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; -import { fractionToLevel, levelToFraction } from "../../utils/audioMeterMath"; -import { AudioMeterStrip } from "./AudioMeterStrip"; +import { fractionToLevel, levelToFraction, SILENT_CHANNEL } from "../../utils/audioMeterMath"; +import { + AudioMeterStrip, + evictGoneMeterState, + followMeterHook, + stepAndPaintStrips, +} from "./AudioMeterStrip"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -253,3 +258,76 @@ describe("AudioMeterStrip", () => { expect(startFraction - levelToFraction(afterDown)).toBeCloseTo(0.02, 6); }); }); + +describe("followMeterHook", () => { + it("leaves the same hook attached", () => { + const hook = makeHook(); + expect(followMeterHook(hook, hook)).toBe(hook); + expect(hook.stop).not.toHaveBeenCalled(); + expect(hook.start).not.toHaveBeenCalled(); + }); + + it("stops the old hook and starts the new one", () => { + const first = makeHook(); + const second = makeHook(); + expect(followMeterHook(first, second)).toBe(second); + expect(first.stop).toHaveBeenCalledTimes(1); + expect(second.start).toHaveBeenCalledTimes(1); + }); + + it("still attaches the new hook when stop() throws", () => { + const first = makeHook(); + first.stop.mockImplementation(() => { + throw new Error("dead realm"); + }); + const second = makeHook(); + expect(followMeterHook(first, second)).toBe(second); + expect(second.start).toHaveBeenCalledTimes(1); + }); + + it("returns the live hook even when start() throws", () => { + const live = makeHook(); + live.start.mockImplementation(() => { + throw new Error("not ready"); + }); + expect(followMeterHook(null, live)).toBe(live); + }); +}); + +describe("evictGoneMeterState", () => { + it("drops ids that are no longer in the strip list", () => { + const rest: [typeof SILENT_CHANNEL, typeof SILENT_CHANNEL] = [SILENT_CHANNEL, SILENT_CHANNEL]; + const state = new Map([ + ["gone", rest], + ["vo", rest], + [null, rest], + ]); + evictGoneMeterState(state, new Set(["vo", null])); + expect([...state.keys()]).toEqual(["vo", null]); + }); +}); + +describe("stepAndPaintStrips", () => { + it("paints a loud group and skips a silent strip already at rest", () => { + const loudMask = document.createElement("div"); + const restMask = document.createElement("div"); + restMask.style.height = "50%"; + const rest: [typeof SILENT_CHANNEL, typeof SILENT_CHANNEL] = [SILENT_CHANNEL, SILENT_CHANNEL]; + const state = new Map([["rest", rest]]); + const bars = new Map([ + ["loud", [{ mask: loudMask, peak: null }, { mask: null, peak: null }] as const], + ["rest", [{ mask: restMask, peak: null }, { mask: null, peak: null }] as const], + ]); + stepAndPaintStrips( + [{ id: "loud" }, { id: "rest" }], + state, + bars as never, + { master: { l: 0, r: 0 }, groups: { loud: { l: 1, r: 1 } } }, + 0, + 16, + ); + expect(loudMask.style.height).toBe("0%"); + expect(restMask.style.height).toBe("50%"); + expect(state.has("loud")).toBe(true); + }); +}); diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index 9d458fd455..33ca524d2c 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -121,6 +121,51 @@ function paint(bars: StripBars | undefined, channels: Pair): void { }); } +/** Attach to the live preview hook. Always returns `live`, even if start/stop throw. */ +export function followMeterHook( + active: AudioMeterHook | null, + live: AudioMeterHook | null, +): AudioMeterHook | null { + if (live === active) return active; + try { + active?.stop(); + } catch { + // Preview iframe was torn down; the old hook is uncallable. + } + try { + live?.start(); + } catch { + // New preview is not ready to attach yet. + } + return live; +} + +export function evictGoneMeterState( + state: Map, + liveIds: ReadonlySet, +): void { + for (const id of [...state.keys()]) { + if (!liveIds.has(id)) state.delete(id); + } +} + +export function stepAndPaintStrips( + strips: readonly { id: string | null }[], + state: Map, + bars: Map, + levels: Levels | undefined, + now: number, + dt: number, +): void { + for (const { id } of strips) { + const prev = state.get(id); + const next = stepPair(prev, id === null ? levels?.master : levels?.groups[id], now, dt); + if (next === prev) continue; + state.set(id, next); + paint(bars.get(id), next); + } +} + /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */ function useMeterLoop(strips: Strip[], bars: RefObject>) { const { previewIframeRef } = useStudioShellContext(); @@ -133,36 +178,12 @@ function useMeterLoop(strips: Strip[], bars: RefObject(); const tick = (now: number) => { raf = requestAnimationFrame(tick); - const live = readHook(previewIframeRef.current); - if (live !== active) { - try { - active?.stop(); - } catch { - // Preview iframe was torn down; the old hook is uncallable. - } - try { - live?.start(); - } catch { - // New preview is not ready to attach yet. - } - active = live; - } + active = followMeterHook(active, readHook(previewIframeRef.current)); const levels = active?.read(); const dt = now - last; last = now; - const liveIds = new Set(stripsRef.current.map((s) => s.id)); - for (const id of state.keys()) if (!liveIds.has(id)) state.delete(id); - for (const { id } of stripsRef.current) { - const next = stepPair( - state.get(id), - id === null ? levels?.master : levels?.groups[id], - now, - dt, - ); - if (next === state.get(id)) continue; - state.set(id, next); - paint(bars.current.get(id), next); - } + evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id))); + stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt); }; raf = requestAnimationFrame(tick); return () => { From b1a30c92650acb2695525a57325ffddc36d75714 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 17:37:09 -0400 Subject: [PATCH 09/11] style(studio): oxfmt AudioMeterStrip tests --- .../src/components/nle/AudioMeterStrip.test.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index 709e52cd79..cad1bb4c62 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -315,8 +315,20 @@ describe("stepAndPaintStrips", () => { const rest: [typeof SILENT_CHANNEL, typeof SILENT_CHANNEL] = [SILENT_CHANNEL, SILENT_CHANNEL]; const state = new Map([["rest", rest]]); const bars = new Map([ - ["loud", [{ mask: loudMask, peak: null }, { mask: null, peak: null }] as const], - ["rest", [{ mask: restMask, peak: null }, { mask: null, peak: null }] as const], + [ + "loud", + [ + { mask: loudMask, peak: null }, + { mask: null, peak: null }, + ] as const, + ], + [ + "rest", + [ + { mask: restMask, peak: null }, + { mask: null, peak: null }, + ] as const, + ], ]); stepAndPaintStrips( [{ id: "loud" }, { id: "rest" }], From e847e8c6a99e57817efbf678cb3cceb4da36b644 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 18:44:20 -0400 Subject: [PATCH 10/11] fix(studio): preserve authored gain in audio meter faders --- .../components/nle/AudioMeterStrip.test.tsx | 216 +++++++++++------- .../src/components/nle/AudioMeterStrip.tsx | 97 +++++--- .../studio/src/utils/audioMeterMath.test.ts | 22 -- packages/studio/src/utils/audioMeterMath.ts | 17 -- 4 files changed, 197 insertions(+), 155 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index cad1bb4c62..e0faab59c0 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -2,12 +2,13 @@ // fallow-ignore-file code-duplication import { act } from "react"; +import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore } from "../../player/store/playerStore"; import type { TimelineElement } from "../../player/store/timelineElement"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; -import { fractionToLevel, levelToFraction, SILENT_CHANNEL } from "../../utils/audioMeterMath"; +import { SILENT_CHANNEL } from "../../utils/audioMeterMath"; import { AudioMeterStrip, evictGoneMeterState, @@ -52,6 +53,22 @@ function stubTrackRect(): () => void { }; } +function drag(fader: HTMLElement, clientY: number) { + const restoreRect = stubTrackRect(); + const original = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = vi.fn(); + try { + for (const type of ["pointerdown", "pointermove", "pointerup"]) { + act(() => + fader.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientY })), + ); + } + } finally { + Element.prototype.setPointerCapture = original; + restoreRect(); + } +} + const makeHook = (groups: Record = {}) => ({ start: vi.fn(), stop: vi.fn(), @@ -160,102 +177,135 @@ describe("AudioMeterStrip", () => { expect(second.read).toHaveBeenCalled(); }); - it("drags a group fader through the live/quiet data-volume path, and the master fader through the player store", () => { - const restoreRect = stubTrackRect(); - const originalPointerCapture = Element.prototype.setPointerCapture; - Element.prototype.setPointerCapture = vi.fn(); - usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupLabel: "VO" })] }); + it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => { + usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] }); const { host } = mount(); - - const groupFader = host.querySelector('[aria-label="VO volume"]')!; - act(() => { - groupFader.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientY: 25 }), - ); - }); - // clientY 25 on a 0..100 track (stubTrackRect) is fraction 0.75; asserting - // the exact computed value (not just "a string") also catches a NaN regression. - const expectedVolume = String(fractionToLevel(0.75)); - expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", expectedVolume); - expect(onSetAudioGroupAttributeQuiet).not.toHaveBeenCalled(); - act(() => { - groupFader.dispatchEvent( - new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientY: 25 }), - ); - }); - expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledWith( + const fader = host.querySelector('[aria-label="vo volume"]')!; + expect(Number(fader.getAttribute("aria-valuenow"))).toBeGreaterThan(0); + expect(Number(fader.getAttribute("aria-valuenow"))).toBeLessThan(100); + expect(fader.getAttribute("aria-valuetext")).toBe("+6.0 dB"); + expect(parseFloat(fader.querySelector("div")!.style.bottom)).toBeGreaterThan(50); + drag(fader, 50); + expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", "1"); + expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith( "vo", "data-volume", - expectedVolume, + "1", "Set volume", ); + }); - const monitorFader = host.querySelector('[aria-label="Monitor volume"]')!; - act(() => { - monitorFader.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientY: 0 }), - ); - }); - expect(usePlayerStore.getState().audioVolume).toBe(1); - act(() => { - monitorFader.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientY: 100 }), - ); - }); - expect(usePlayerStore.getState().audioVolume).toBe(0); + it("drags a group to the shared +12 dB ceiling", () => { + usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] }); + const fader = mount().host.querySelector('[aria-label="vo volume"]')!; + drag(fader, 0); + expect(Number(onSetAudioGroupAttributeQuiet.mock.calls[0]![2])).toBeCloseTo(MAX_AUDIO_GAIN, 6); + expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", "3.981072"); + }); - Element.prototype.setPointerCapture = originalPointerCapture; - restoreRect(); + it.each([0.5, 1, 2, 3.98])( + "round-trips authored group gain %s through its displayed thumb", + (gain) => { + usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: gain })] }); + const fader = mount().host.querySelector('[aria-label="vo volume"]')!; + const thumb = fader.firstElementChild as HTMLElement; + drag(fader, 100 - parseFloat(thumb.style.bottom)); + expect(Number(onSetAudioGroupAttributeQuiet.mock.calls[0]![2])).toBeCloseTo(gain, 6); + }, + ); + + it("preserves low authored gain instead of rounding it to mute", () => { + usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] }); + const fader = mount().host.querySelector('[aria-label="vo volume"]')!; + drag(fader, 85); + expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith( + "vo", + "data-volume", + "0.007943", + "Set volume", + ); }); - it("the monitor fader writes setAudioVolume and is labeled Monitor", () => { - const restoreRect = stubTrackRect(); - const originalPointerCapture = Element.prototype.setPointerCapture; - Element.prototype.setPointerCapture = vi.fn(); - const setAudioVolume = vi.fn((volume: number) => { - usePlayerStore.setState({ audioVolume: volume }); - }); - usePlayerStore.setState({ elements: [clip({})], audioVolume: 1, setAudioVolume }); - const { host } = mount(); - expect(host.textContent).toContain("Monitor"); - const fader = host.querySelector('[aria-label="Monitor volume"]')!; - expect(fader.getAttribute("title")).toBe("Preview monitor volume"); - act(() => { - fader.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientY: 100 }), - ); - }); - expect(setAudioVolume).toHaveBeenCalledWith(0); - Element.prototype.setPointerCapture = originalPointerCapture; - restoreRect(); + it("keeps the Monitor fader within the player store range", () => { + usePlayerStore.setState({ elements: [clip({})] }); + const fader = mount().host.querySelector('[aria-label="Monitor volume"]')!; + expect(fader.getAttribute("aria-valuemin")).toBe("-100"); + expect(fader.getAttribute("aria-valuemax")).toBe("0"); + drag(fader, 100); + expect(usePlayerStore.getState().audioVolume).toBe(0); + drag(fader, 0); + expect(usePlayerStore.getState().audioVolume).toBe(1); + act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }))); + expect(usePlayerStore.getState().audioVolume).toBe(1); + expect(fader.getAttribute("aria-valuetext")).toBe("0.0 dB"); + expect(onSetAudioGroupAttributeQuiet).not.toHaveBeenCalled(); }); - it("nudges a consistent step in the visual (dB-scale) position, and aria-valuenow tracks the thumb", () => { - usePlayerStore.setState({ elements: [clip({})], audioVolume: 0.5 }); - const { host } = mount(); - const masterFader = host.querySelector('[aria-label="Monitor volume"]')!; - const startFraction = levelToFraction(0.5); - expect(masterFader.getAttribute("aria-valuenow")).toBe(String(Math.round(startFraction * 100))); + it.each(["pointercancel", "lostpointercapture"])( + "finishes %s once and ignores subsequent hovering", + (event) => { + const restoreRect = stubTrackRect(); + const original = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = vi.fn(); + try { + usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] }); + const fader = mount().host.querySelector('[aria-label="vo volume"]')!; + for (const [type, clientY] of [ + ["pointerdown", 50], + [event, 0], + ["pointermove", 0], + ["pointerup", 0], + ] as const) { + act(() => + fader.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientY })), + ); + } + expect(onSetAudioGroupAttributeLive).toHaveBeenCalledExactlyOnceWith( + "vo", + "data-volume", + "1", + ); + expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith( + "vo", + "data-volume", + "1", + "Set volume", + ); + } finally { + Element.prototype.setPointerCapture = original; + restoreRect(); + } + }, + ); - act(() => { - masterFader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); - }); - const afterUp = usePlayerStore.getState().audioVolume; - const fractionAfterUp = levelToFraction(afterUp); - // A flat step in raw volume (the old, buggy behaviour) would move the - // visual position by a very different amount depending on where it starts; - // stepping in fraction space keeps every step visually the same size. - expect(fractionAfterUp - startFraction).toBeCloseTo(0.02, 6); - expect(masterFader.getAttribute("aria-valuenow")).toBe( - String(Math.round(fractionAfterUp * 100)), + it.each([ + ["Home", "0"], + ["End", "3.981072"], + ["PageUp", "1.318257"], + ["PageDown", "0.251189"], + ])("supports the %s slider key", (key, gain) => { + usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] }); + const fader = mount().host.querySelector('[aria-label="vo volume"]')!; + act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }))); + expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith( + "vo", + "data-volume", + gain, + "Set volume", ); + }); - act(() => usePlayerStore.setState({ audioVolume: 0.5 })); - act(() => { - masterFader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); - }); - const afterDown = usePlayerStore.getState().audioVolume; - expect(startFraction - levelToFraction(afterDown)).toBeCloseTo(0.02, 6); + it("nudges by an equal visual step and exposes the gain readout", () => { + usePlayerStore.setState({ elements: [clip({})], audioVolume: 1 }); + const fader = mount().host.querySelector('[aria-label="Monitor volume"]')!; + act(() => + fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })), + ); + expect(fader.getAttribute("aria-valuenow")).toBe("-2"); + expect(fader.getAttribute("aria-valuetext")).toBe("-1.2 dB"); + act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }))); + expect(usePlayerStore.getState().audioVolume).toBeCloseTo(1, 12); + expect(fader.getAttribute("aria-valuenow")).toBe("0"); }); }); diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index 33ca524d2c..ac929380eb 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -8,6 +8,14 @@ import { type Ref, type RefObject, } from "react"; +import { + AUDIO_GAIN_FADER_MIN, + AUDIO_GAIN_FADER_MAX, + audioGainToFaderPosition, + audioFaderPositionToGain, + audioGainToText, + formatAudioGain, +} from "@hyperframes/core/audio-gain"; import type { StereoLevel } from "@hyperframes/core/runtime/levelTap"; import { usePlayerStore } from "../../player"; import { clampNumber } from "../../utils/studioHelpers"; @@ -17,8 +25,6 @@ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditConte import { METER_DB_MARKS, useProjectHasAudio, - fractionToLevel, - levelToFraction, markFraction, stepPair, type MeterPair as Pair, @@ -79,14 +85,20 @@ function useVolumeHandlers(): { const onLive = useCallback( (id: string | null, volume: number) => { if (id === null) setAudioVolume(volume); - else onSetAudioGroupAttributeLive?.(id, "data-volume", String(volume)); + else onSetAudioGroupAttributeLive?.(id, "data-volume", formatAudioGain(volume)); }, [onSetAudioGroupAttributeLive, setAudioVolume], ); const onCommit = useCallback( (id: string | null, volume: number) => { if (id === null) setAudioVolume(volume); - else void onSetAudioGroupAttributeQuiet?.(id, "data-volume", String(volume), "Set volume"); + else + void onSetAudioGroupAttributeQuiet?.( + id, + "data-volume", + formatAudioGain(volume), + "Set volume", + ); }, [onSetAudioGroupAttributeQuiet, setAudioVolume], ); @@ -227,39 +239,59 @@ function Bar({ maskRef, peakRef }: { maskRef: Ref; peakRef: Ref< ); } -/** A vertical fader beside the meter, reusing the meter's own dB scale for its - * travel so the thumb lines up with the marks. Dragging anywhere on the - * track jumps the thumb there, not just grabbing it exactly. */ +/** Uses the authored clip-gain scale; meter readings have a separate scale. */ function Fader({ label, title, volume, + maxPosition, onLive, onCommit, }: { label: string; title: string; volume: number; + maxPosition: number; onLive: (v: number) => void; onCommit: (v: number) => void; }) { const trackRef = useRef(null); - const draggingRef = useRef(false); - const fraction = levelToFraction(Math.min(volume, 1)); + const draggingRef = useRef(null); + const position = audioGainToFaderPosition(volume); + const span = maxPosition - AUDIO_GAIN_FADER_MIN; + const fraction = (position - AUDIO_GAIN_FADER_MIN) / span; + const readout = audioGainToText(volume); - const fractionAt = useCallback( + const positionAt = useCallback( (clientY: number): number => { const rect = trackRef.current?.getBoundingClientRect(); - if (!rect || rect.height === 0) return fraction; - return clampNumber(1 - (clientY - rect.top) / rect.height, 0, 1); + if (!rect || rect.height === 0) return position; + return ( + AUDIO_GAIN_FADER_MIN + clampNumber(1 - (clientY - rect.top) / rect.height, 0, 1) * span + ); }, - [fraction], + [position, span], ); - const nudge = useCallback( - (delta: number) => onCommit(fractionToLevel(clampNumber(fraction + delta, 0, 1))), - [onCommit, fraction], - ); + const moveTo = (clientY: number) => { + const gain = audioFaderPositionToGain(positionAt(clientY)); + draggingRef.current = gain; + onLive(gain); + }; + const finishDrag = () => { + if (draggingRef.current === null) return; + const gain = draggingRef.current; + draggingRef.current = null; + onCommit(gain); + }; + const keyPositions = new Map([ + ["ArrowUp", position + span * 0.02], + ["ArrowDown", position - span * 0.02], + ["PageUp", position + span * 0.1], + ["PageDown", position - span * 0.1], + ["Home", AUDIO_GAIN_FADER_MIN], + ["End", maxPosition], + ]); return (
) => { e.currentTarget.setPointerCapture(e.pointerId); - draggingRef.current = true; - onLive(fractionToLevel(fractionAt(e.clientY))); + moveTo(e.clientY); }} onPointerMove={(e: ReactPointerEvent) => { - if (!draggingRef.current) return; - onLive(fractionToLevel(fractionAt(e.clientY))); - }} - onPointerUp={(e: ReactPointerEvent) => { - if (!draggingRef.current) return; - draggingRef.current = false; - onCommit(fractionToLevel(fractionAt(e.clientY))); + if (draggingRef.current === null) return; + moveTo(e.clientY); }} + onPointerUp={finishDrag} + onPointerCancel={finishDrag} + onLostPointerCapture={finishDrag} onKeyDown={(e) => { - if (e.key === "ArrowUp") nudge(0.02); - else if (e.key === "ArrowDown") nudge(-0.02); - else return; + const next = keyPositions.get(e.key); + if (next === undefined) return; + onCommit(audioFaderPositionToGain(clampNumber(next, AUDIO_GAIN_FADER_MIN, maxPosition))); e.preventDefault(); }} className="relative h-full w-2 shrink-0 cursor-ns-resize touch-none rounded-full bg-neutral-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent" @@ -338,6 +368,7 @@ function MeterStrip({ label={`${strip.label} volume`} title={strip.id === null ? "Preview monitor volume" : `${strip.label} volume`} volume={strip.volume} + maxPosition={strip.id === null ? audioGainToFaderPosition(1) : AUDIO_GAIN_FADER_MAX} onLive={(v) => onLive(strip.id, v)} onCommit={(v) => onCommit(strip.id, v)} /> diff --git a/packages/studio/src/utils/audioMeterMath.test.ts b/packages/studio/src/utils/audioMeterMath.test.ts index deecfa8d00..95884a0581 100644 --- a/packages/studio/src/utils/audioMeterMath.test.ts +++ b/packages/studio/src/utils/audioMeterMath.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - fractionToLevel, levelToFraction, markFraction, SILENT_CHANNEL, @@ -24,27 +23,6 @@ describe("levelToFraction", () => { }); }); -describe("fractionToLevel", () => { - it("round-trips through levelToFraction at each dB mark", () => { - [0, -3, -6, -12, -24].forEach((db) => { - const f = markFraction(db); - expect(levelToFraction(fractionToLevel(f))).toBeCloseTo(f, 6); - }); - }); - it("pins the ends: silence at 0, unity at 1", () => { - expect(fractionToLevel(0)).toBe(0); - expect(fractionToLevel(1)).toBe(1); - }); - it("clamps out-of-range fractions", () => { - expect(fractionToLevel(-0.5)).toBe(0); - expect(fractionToLevel(1.5)).toBe(1); - }); - it("returns 0 for non-finite input", () => { - expect(fractionToLevel(Number.NaN)).toBe(0); - expect(fractionToLevel(Number.POSITIVE_INFINITY)).toBe(0); - }); -}); - describe("stepChannel", () => { it("attacks instantly, holds the peak, then falls", () => { let ch = stepChannel(SILENT_CHANNEL, 1, 0, 16); diff --git a/packages/studio/src/utils/audioMeterMath.ts b/packages/studio/src/utils/audioMeterMath.ts index ef33d48600..d1ce0c9dc2 100644 --- a/packages/studio/src/utils/audioMeterMath.ts +++ b/packages/studio/src/utils/audioMeterMath.ts @@ -1,7 +1,6 @@ import { useMemo } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; import { isAudioTimelineElement } from "./timelineInspector"; -import { clampNumber } from "./studioHelpers"; /** dB stops of the meter scale, top to bottom; equal spacing between stops. */ export const METER_DB_MARKS = [0, -3, -6, -12, -24] as const; @@ -27,22 +26,6 @@ export function markFraction(db: number): number { return levelToFraction(10 ** (db / 20)); } -/** Inverse of `levelToFraction`: a 0..1 fader fraction back to a linear volume. */ -export function fractionToLevel(fraction: number): number { - if (!Number.isFinite(fraction)) return 0; - const f = clampNumber(fraction, 0, 1); - if (f <= 0) return 0; - if (f >= 1) return 1; - const n = STOPS.length - 1; - const scaled = f * n; - const seg = Math.min(n - 1, Math.floor(scaled)); - const segFraction = scaled - seg; - const j = n - seg; - const hi = STOPS[j - 1] as number; - const lo = STOPS[j] as number; - return 10 ** ((lo + segFraction * (hi - lo)) / 20); -} - export interface MeterChannel { level: number; peak: number; From 01c9d3d2591fd3d943af526c36f484ef1041436c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 19:03:52 -0400 Subject: [PATCH 11/11] fix(studio): make timeline audio meters opt-in --- .../src/components/TimelineToolbar.test.tsx | 42 +++++++++++++++++++ .../studio/src/utils/audioMeterVisibility.ts | 4 +- .../studio/src/utils/studioUiPreferences.ts | 2 +- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx index 416307e9ab..55b6aecaae 100644 --- a/packages/studio/src/components/TimelineToolbar.test.tsx +++ b/packages/studio/src/components/TimelineToolbar.test.tsx @@ -6,10 +6,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; import { makeSelection } from "../hooks/domSelectionTestHarness"; +import { useAudioMetersVisible } from "../utils/audioMeterVisibility"; +import { readStudioUiPreferences } from "../utils/studioUiPreferences"; +import { AudioMeterStrip } from "./nle/AudioMeterStrip"; import { TimelineToolbar } from "./TimelineToolbar"; vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ + previewIframeRef: { current: null }, editHistory: { canUndo: false, canRedo: false }, handleUndo: vi.fn(), handleRedo: vi.fn(), @@ -172,3 +176,41 @@ describe("TimelineToolbar — keyframes on audio tracks", () => { act(() => root.unmount()); }); }); + +describe("TimelineToolbar audio meters", () => { + it("keeps fresh preferences hidden until the user opts in and persists the choice", () => { + localStorage.clear(); + useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState()); + usePlayerStore.setState({ + elements: [{ id: "music", key: "music", tag: "audio", start: 0, duration: 10, track: 1 }], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + act(() => + root.render( + <> + + + , + ), + ); + const button = host.querySelector( + 'button[aria-label="Toggle audio meters"]', + ); + expect(readStudioUiPreferences().audioMetersVisible).toBeUndefined(); + expect(button?.getAttribute("aria-pressed")).toBe("false"); + expect(host.querySelector('[data-testid="audio-meter-strip"]')).toBeNull(); + if (!button) throw new Error("audio meter toggle not rendered"); + act(() => button.click()); + expect(button.getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector('[data-testid="audio-meter-strip"]')).not.toBeNull(); + expect(readStudioUiPreferences().audioMetersVisible).toBe(true); + } finally { + act(() => root.unmount()); + useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState()); + localStorage.clear(); + } + }); +}); diff --git a/packages/studio/src/utils/audioMeterVisibility.ts b/packages/studio/src/utils/audioMeterVisibility.ts index 3aa6002dff..215eff6c3d 100644 --- a/packages/studio/src/utils/audioMeterVisibility.ts +++ b/packages/studio/src/utils/audioMeterVisibility.ts @@ -1,12 +1,12 @@ import { create } from "zustand"; import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences"; -/** Whether the audio meter strip is shown; persisted, on unless the user hid it. */ +/** Whether the audio meter strip is shown; persisted, off unless the user showed it. */ export const useAudioMetersVisible = create<{ visible: boolean; setVisible: (visible: boolean) => void; }>((set) => ({ - visible: readStudioUiPreferences().audioMetersVisible ?? true, + visible: readStudioUiPreferences().audioMetersVisible ?? false, setVisible: (visible) => { writeStudioUiPreferences({ audioMetersVisible: visible }); set({ visible }); diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts index 041df9c08e..ef384f21a0 100644 --- a/packages/studio/src/utils/studioUiPreferences.ts +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -25,7 +25,7 @@ export interface StudioUiPreferences { snapToGrid?: boolean; /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */ timelineSnapEnabled?: boolean; - /** Audio level meters at the timeline's right edge; shown unless hidden here. */ + /** Audio level meters at the timeline's right edge; hidden unless enabled here. */ audioMetersVisible?: boolean; /** Keeps the main track gapless: deleting a clip closes the gap. Distinct * from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */