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/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/**/*"], 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/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index b1ba184111..e0faab59c0 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -2,12 +2,19 @@ // 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 { AudioMeterStrip } from "./AudioMeterStrip"; +import { 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; @@ -17,6 +24,51 @@ 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; + }; +} + +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(), @@ -39,8 +91,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(() => { @@ -64,7 +118,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" }), @@ -74,7 +128,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(); }); @@ -84,12 +139,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 +176,220 @@ describe("AudioMeterStrip", () => { tick(); expect(second.read).toHaveBeenCalled(); }); + + 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 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", + "1", + "Set volume", + ); + }); + + 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"); + }); + + 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("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.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(); + } + }, + ); + + 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", + ); + }); + + 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"); + }); +}); + +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 9580c1d57c..ac929380eb 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -1,7 +1,27 @@ -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 { + 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"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; import { useStudioShellContext } from "../../contexts/StudioContext"; +import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { METER_DB_MARKS, useProjectHasAudio, @@ -11,8 +31,8 @@ import { } from "../../utils/audioMeterMath"; interface Levels { - master: { l: number; r: number }; - groups: Record; + master: StereoLevel; + groups: Record; } interface AudioMeterHook { start(): void; @@ -22,23 +42,67 @@ 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 MONITOR_LABEL = "Monitor"; + +/** 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[] { 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: MONITOR_LABEL, volume: masterVolume }, + ]; + }, [elements, masterVolume]); +} + +/** Group volume through the existing `data-volume` write path (live while dragging, one + * 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; + 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", formatAudioGain(volume)); + }, + [onSetAudioGroupAttributeLive, setAudioVolume], + ); + const onCommit = useCallback( + (id: string | null, volume: number) => { + if (id === null) setAudioVolume(volume); + else + void onSetAudioGroupAttributeQuiet?.( + id, + "data-volume", + formatAudioGain(volume), + "Set volume", + ); + }, + [onSetAudioGroupAttributeQuiet, setAudioVolume], + ); + return { onLive, onCommit }; } type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined; @@ -59,13 +123,61 @@ function paintPeak(el: HTMLElement | null, peak: number): void { el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none"); } +/** 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 { 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); }); } +/** 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(); @@ -78,34 +190,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; - 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 () => { @@ -119,18 +209,124 @@ function useMeterLoop(strips: Strip[], bars: RefObject; peakRef: Ref }) { +function Bar({ maskRef, peakRef }: { maskRef: Ref; peakRef: Ref }) { return ( -
+
+
+
+
+
+ ); +} + +/** 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(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 positionAt = useCallback( + (clientY: number): number => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect || rect.height === 0) return position; + return ( + AUDIO_GAIN_FADER_MIN + clampNumber(1 - (clientY - rect.top) / rect.height, 0, 1) * span + ); + }, + [position, span], + ); + + 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); + moveTo(e.clientY); + }} + onPointerMove={(e: ReactPointerEvent) => { + if (draggingRef.current === null) return; + moveTo(e.clientY); + }} + onPointerUp={finishDrag} + onPointerCancel={finishDrag} + onLostPointerCapture={finishDrag} + onKeyDown={(e) => { + 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" + > +
); @@ -139,9 +335,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 +351,28 @@ 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 +409,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/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). */