feat(studio): audio level meters at the timeline's right edge (#4193)

* feat(studio): audio level meters at the timeline's right edge

* test(core): cover metering started before the audio context finishes init

* fix(studio): keep meter follow after a dead preview and start bars at silence

* fix(studio): keep the 0 dB peak tick inside the meter

* fix(studio): keep the 0 dB peak tick visible without calc()

* fix(core): tap meters on the program bus, mute on a monitor gain

* style(core): wrap applyMasterGain to satisfy oxfmt
This commit is contained in:
Miguel Ángel
2026-09-21 16:19:30 -04:00
committed by GitHub
parent badf49b551
commit 97420736d5
14 changed files with 784 additions and 34 deletions
+5
View File
@@ -2743,6 +2743,11 @@ export function initSandboxRuntimeModular(): void {
};
window.__hf.leasePausedMedia = leasePausedMedia;
window.__hf.releasePausedMedia = releasePausedMedia;
window.__hf.audioMeter = {
start: () => webAudio.startMetering(),
stop: () => webAudio.stopMetering(),
read: () => webAudio.readLevels(),
};
// Same predicate the media cache uses, so the paused side sees exactly the
// media the transport drives. Reads attributes only; no cache rebuild.
+48
View File
@@ -0,0 +1,48 @@
export interface StereoLevel {
l: number;
r: number;
}
export interface LevelTap {
/** Peak of the last analyser window per channel, linear 0..1+. */
read(): StereoLevel;
dispose(): void;
}
const WINDOW = 2048;
function peak(analyser: AnalyserNode, buf: Float32Array<ArrayBuffer>): number {
analyser.getFloatTimeDomainData(buf);
let max = 0;
for (let i = 0; i < buf.length; i++) max = Math.max(max, Math.abs(buf[i] ?? 0));
return max;
}
/** A side branch off `source`; the explicit-stereo gain up-mixes mono to both channels. */
export function createLevelTap(ctx: BaseAudioContext, source: AudioNode): LevelTap {
const mix = ctx.createGain();
mix.channelCount = 2;
mix.channelCountMode = "explicit";
const splitter = ctx.createChannelSplitter(2);
const left = ctx.createAnalyser();
const right = ctx.createAnalyser();
left.fftSize = right.fftSize = WINDOW;
source.connect(mix);
mix.connect(splitter);
splitter.connect(left, 0);
splitter.connect(right, 1);
const bufL = new Float32Array(new ArrayBuffer(WINDOW * 4));
const bufR = new Float32Array(new ArrayBuffer(WINDOW * 4));
return {
read: () => ({ l: peak(left, bufL), r: peak(right, bufR) }),
dispose: () => {
try {
source.disconnect(mix);
mix.disconnect();
splitter.disconnect();
} catch {
// Source already torn down.
}
},
};
}
@@ -31,6 +31,10 @@ function createMockAudioContext(currentTime = 100) {
gain: { value: 1 },
connect: vi.fn(),
};
const monitorGain = {
gain: { value: 1 },
connect: vi.fn(),
};
const ctx = {
currentTime,
state: "running",
@@ -41,7 +45,7 @@ function createMockAudioContext(currentTime = 100) {
destination: {},
close: vi.fn(),
};
return { ctx, sourceNode, mediaElementSourceNode, gainNode, masterGain, startFn };
return { ctx, sourceNode, mediaElementSourceNode, gainNode, masterGain, monitorGain, startFn };
}
function setupTransport(currentTime = 100) {
@@ -49,6 +53,7 @@ function setupTransport(currentTime = 100) {
const mock = createMockAudioContext(currentTime);
(transport as unknown as { _ctx: unknown })._ctx = mock.ctx;
(transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain;
(transport as unknown as { _monitorGain: unknown })._monitorGain = mock.monitorGain;
const gen = transport.startGeneration();
return { transport, mock, gen };
}
@@ -87,12 +92,12 @@ describe("WebAudioTransport author gain vs user volume", () => {
it("keeps the user's master volume spec-clamped — it is a fader, not a gain", () => {
const transport = new WebAudioTransport();
const master = { gain: { value: 1 }, connect: vi.fn() };
(transport as unknown as { _masterGain: unknown })._masterGain = master;
const monitor = { gain: { value: 1 }, connect: vi.fn() };
(transport as unknown as { _monitorGain: unknown })._monitorGain = monitor;
transport.setVolume(99);
expect(master.gain.value).toBe(1);
expect(monitor.gain.value).toBe(1);
});
});
@@ -341,11 +346,13 @@ describe("WebAudioTransport", () => {
const { transport, mock } = setupTransport();
transport.setVolume(0.4);
transport.setMuted(true);
expect(mock.masterGain.gain.value).toBe(0);
expect(mock.monitorGain.gain.value).toBe(0);
expect(mock.masterGain.gain.value).toBe(1);
transport.setMuted(false);
expect(mock.masterGain.gain.value).toBe(0.4);
expect(mock.monitorGain.gain.value).toBe(0.4);
expect(mock.masterGain.gain.value).toBe(1);
});
it("applies author and user volume once in separate gain layers", async () => {
@@ -354,8 +361,11 @@ describe("WebAudioTransport", () => {
transport.setVolume(0.5);
expect(mock.gainNode.gain.value).toBe(0.8);
expect(mock.masterGain.gain.value).toBe(0.5);
expect(mock.gainNode.gain.value * mock.masterGain.gain.value).toBeCloseTo(0.4);
expect(mock.masterGain.gain.value).toBe(1);
expect(mock.monitorGain.gain.value).toBe(0.5);
expect(
mock.gainNode.gain.value * mock.masterGain.gain.value * mock.monitorGain.gain.value,
).toBeCloseTo(0.4);
});
describe("ownsElement (per-element mute gate)", () => {
@@ -738,6 +748,7 @@ describe("WebAudioTransport", () => {
getFloatTimeDomainData: ReturnType<typeof vi.fn>;
}[] = [];
const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
const monitorGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
const mediaElementSource = { connect: vi.fn(), disconnect: vi.fn() };
const ctx = {
currentTime,
@@ -784,6 +795,7 @@ describe("WebAudioTransport", () => {
analysers.push(node);
return node;
}),
createChannelSplitter: vi.fn(() => ({ connect: vi.fn(), disconnect: vi.fn() })),
// The media-element route needs this as much as the decoded one: without
// it `scheduleMediaElementPlayback` throws and its catch returns null,
// which reads as "the member did not play" rather than a missing stub.
@@ -791,7 +803,7 @@ describe("WebAudioTransport", () => {
destination: {},
close: vi.fn(),
};
return { ctx, gainNodes, analysers, masterGain, mediaElementSource };
return { ctx, gainNodes, analysers, masterGain, monitorGain, mediaElementSource };
}
function setupGroupTransport(currentTime = 100) {
@@ -799,6 +811,7 @@ describe("WebAudioTransport", () => {
const mock = createGroupMockAudioContext(currentTime);
(transport as unknown as { _ctx: unknown })._ctx = mock.ctx;
(transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain;
(transport as unknown as { _monitorGain: unknown })._monitorGain = mock.monitorGain;
const gen = transport.startGeneration();
return { transport, mock, gen };
}
@@ -1085,6 +1098,113 @@ describe("WebAudioTransport", () => {
expect(() => transport.setGroupMuted("never-played", true)).not.toThrow();
});
});
describe("level metering", () => {
const addGroup = (id: string) => {
document.body.insertAdjacentHTML(
"beforeend",
`<hf-audio-group id="${id}"></hf-audio-group>`,
);
};
const setLevel = (analyser: { getFloatTimeDomainData: unknown }, peak: number) => {
(analyser.getFloatTimeDomainData as ReturnType<typeof vi.fn>).mockImplementation(
(buf: Float32Array) => buf.fill(0).fill(-peak, 3, 4),
);
};
it("creates nothing until startMetering", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
expect(mock.ctx.createAnalyser).not.toHaveBeenCalled();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });
});
it("attaches the master tap once init() lands, even when startMetering ran while the context was still warming up", async () => {
const mock = createGroupMockAudioContext();
class MockAudioContext {
constructor() {
return mock.ctx as unknown as MockAudioContext;
}
}
vi.stubGlobal("AudioContext", MockAudioContext);
const transport = new WebAudioTransport();
transport.startMetering();
expect(mock.ctx.createAnalyser).not.toHaveBeenCalled();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });
const ok = await transport.init();
expect(ok).toBe(true);
expect(mock.ctx.createAnalyser).toHaveBeenCalled();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });
vi.unstubAllGlobals();
});
it("taps master and each group as side branches, once however often it starts", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const groupOutput = mock.gainNodes[2]!;
const connectsBefore = groupOutput.connect.mock.calls.length;
transport.startMetering();
transport.startMetering();
expect(mock.analysers).toHaveLength(4);
expect(groupOutput.connect).toHaveBeenCalledTimes(connectsBefore + 1);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
expect(mock.masterGain.connect).toHaveBeenCalledTimes(1);
expect(mock.monitorGain.connect).not.toHaveBeenCalled();
});
it("keeps program taps off the monitor fader", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
transport.startMetering();
transport.setVolume(0.5);
transport.setMuted(true);
const groupOutput = mock.gainNodes.find((n) =>
n.connect.mock.calls.some((call) => call[0] === mock.masterGain),
);
expect(mock.masterGain.gain.value).toBe(1);
expect(groupOutput?.gain.value).toBe(1);
expect(mock.monitorGain.gain.value).toBe(0);
});
it("reads the peak per channel and stops cleanly", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
transport.startMetering();
const [masterL, , groupL, groupR] = mock.analysers;
setLevel(masterL!, 0.5);
setLevel(groupL!, 0.25);
setLevel(groupR!, 0.125);
const levels = transport.readLevels();
expect(levels.master).toEqual({ l: 0.5, r: 0 });
expect(levels.groups.vo).toEqual({ l: 0.25, r: 0.125 });
transport.stopMetering();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });
expect(mock.gainNodes[2]!.disconnect).toHaveBeenCalled();
});
it("a group built after start is metered, and one removed from the document leaves the read", async () => {
const { transport, mock, gen } = setupGroupTransport();
transport.startMetering();
addGroup("late");
await scheduleGrouped(transport, gen, "a", "late");
expect(Object.keys(transport.readLevels().groups)).toEqual(["late"]);
document.querySelector("hf-audio-group")!.remove();
expect(transport.readLevels().groups).toEqual({});
expect(mock.analysers).toHaveLength(4);
});
});
});
describe("decodeAudioElement retry policy (late-asset self-heal)", () => {
+56 -2
View File
@@ -8,6 +8,7 @@ import {
import { VOLUME_RANGE } from "../audioAutomation.js";
import { audioGroupOf, readAudioGroupVolume, resolveGroupElement } from "../audioGroups.js";
import { swallow } from "./diagnostics";
import { createLevelTap, type LevelTap, type StereoLevel } from "./levelTap.js";
import { clampAudioGain } from "../audioGain.js";
import { getDebugSurface } from "./globals.js";
import { readElementPlaybackRate } from "./media.js";
@@ -140,6 +141,8 @@ export class WebAudioTransport {
private _mediaElementSources = new WeakMap<HTMLMediaElement, MediaElementAudioSourceNode>();
private _activeSources: ScheduledSource[] = [];
private _masterGain: GainNode | null = null;
/** Preview volume and mute. Downstream of `_masterGain`; meters tap the program. */
private _monitorGain: GainNode | null = null;
private _masterVolume = 1;
private _masterMuted = false;
// One shared bus per group id, lazily built the first time a member of that
@@ -150,6 +153,10 @@ export class WebAudioTransport {
string,
{
input: GainNode;
/** Where the bus meets master; the level meter taps here. */
output: GainNode;
/** False once the group's element is gone from the document. */
isPresent(): boolean;
/** Post-FX fader: `data-volume` plus the volume lane. */
fader: GainNode;
muteGain: GainNode;
@@ -162,6 +169,10 @@ export class WebAudioTransport {
dispose(): void;
}
>();
// Level taps exist only between `startMetering()` and `stopMetering()`.
private _metering = false;
private _masterTap: LevelTap | null = null;
private _groupTaps = new Map<string, LevelTap>();
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
// composition time was `_rateAnchorComp`, and time has been advancing at
// `_rate` composition-seconds per wallclock-second since.
@@ -175,8 +186,11 @@ export class WebAudioTransport {
try {
this._ctx = new AudioContext();
this._masterGain = this._ctx.createGain();
this._masterGain.connect(this._ctx.destination);
this._monitorGain = this._ctx.createGain();
this._masterGain.connect(this._monitorGain);
this._monitorGain.connect(this._ctx.destination);
this.applyMasterGain();
if (this._metering) this.attachMasterTap();
return true;
} catch {
return false;
@@ -454,6 +468,8 @@ export class WebAudioTransport {
this._groups.set(groupId, {
input,
output,
isPresent: () => resolveEl() !== null,
fader,
muteGain,
fx,
@@ -485,9 +501,44 @@ export class WebAudioTransport {
}
},
});
this.attachGroupTap(groupId, output);
return input;
}
startMetering(): void {
this._metering = true;
this.attachMasterTap();
for (const [id, group] of this._groups) this.attachGroupTap(id, group.output);
}
stopMetering(): void {
this._metering = false;
this._masterTap?.dispose();
this._masterTap = null;
for (const tap of this._groupTaps.values()) tap.dispose();
this._groupTaps.clear();
}
readLevels(): { master: StereoLevel; groups: Record<string, StereoLevel> } {
const groups: Record<string, StereoLevel> = {};
for (const [id, tap] of this._groupTaps) {
if (this._groups.get(id)?.isPresent()) groups[id] = tap.read();
}
return { master: this._masterTap?.read() ?? { l: 0, r: 0 }, groups };
}
private attachMasterTap(): void {
if (this._ctx && this._masterGain && !this._masterTap) {
this._masterTap = createLevelTap(this._ctx, this._masterGain);
}
}
private attachGroupTap(id: string, output: GainNode): void {
if (this._metering && this._ctx && !this._groupTaps.has(id)) {
this._groupTaps.set(id, createLevelTap(this._ctx, output));
}
}
/**
* Group mute, preview side — a separate gain from `input`'s volume fader
* so a mute toggle never fights `scheduleVolumeLane`'s ramps on the
@@ -760,7 +811,8 @@ export class WebAudioTransport {
}
private applyMasterGain(): void {
if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
if (this._monitorGain)
this._monitorGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
}
isActive(): boolean {
@@ -780,6 +832,7 @@ export class WebAudioTransport {
destroy(): void {
this.stopAll();
this.stopMetering();
for (const group of this._groups.values()) group.dispose();
this._groups.clear();
this._bufferCache.clear();
@@ -794,6 +847,7 @@ export class WebAudioTransport {
}
this._ctx = null;
this._masterGain = null;
this._monitorGain = null;
this._masterVolume = 1;
this._masterMuted = false;
}
+10
View File
@@ -56,6 +56,16 @@ declare global {
* runtime's paused-side enforcement leaves it alone. Always release. */
leasePausedMedia?: (el: HTMLMediaElement) => void;
releasePausedMedia?: (el: HTMLMediaElement) => void;
/** Read-only level taps for the Studio meters: nothing exists until
* `start()`, and `stop()` removes every tap. Peaks are linear per channel. */
audioMeter?: {
start(): void;
stop(): void;
read(): {
master: { l: number; r: number };
groups: Record<string, { l: number; r: number }>;
};
};
/** Declared-compute hold for setup no adapter can see (mesh building,
* shader compiles). Runtime and player hold until every promise
* registered here, under any key unique to your piece, resolves. */
@@ -4,6 +4,7 @@ import {
Magnet,
MagnifyingGlassMinus,
MagnifyingGlassPlus,
SpeakerHigh,
Waves,
} from "@phosphor-icons/react";
import {
@@ -28,6 +29,8 @@ import { Scissors } from "../icons/SystemIcons";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
import { useProjectHasAudio } from "../utils/audioMeterMath";
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
interface DomEditSessionSlice extends EnableKeyframesSession {
@@ -141,6 +144,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode);
const thumbnailsVisible = thumbnailMode === "adaptive";
const audioMetersVisible = useAudioMetersVisible((s) => s.visible);
const setAudioMetersVisible = useAudioMetersVisible((s) => s.setVisible);
const projectHasAudio = useProjectHasAudio();
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
const currentTime = usePlayerStore((s) => s.currentTime);
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
@@ -249,6 +255,19 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
<Waves size={16} weight="bold" aria-hidden="true" />
</button>
</Tooltip>
{projectHasAudio && (
<Tooltip label={audioMetersVisible ? "Hide audio meters" : "Show audio meters"}>
<button
type="button"
onClick={() => setAudioMetersVisible(!audioMetersVisible)}
aria-label="Toggle audio meters"
aria-pressed={audioMetersVisible}
className={audioMetersVisible ? flatActive : flatIdle}
>
<SpeakerHigh size={16} weight="bold" aria-hidden="true" />
</button>
</Tooltip>
)}
{/* Always rendered (CapCut-style): with no keyframeable selection the
button fades to a disabled state instead of unmounting, so the
toolbar layout never shifts. */}
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
// fallow-ignore-file code-duplication
import { act } from "react";
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";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const iframe = { contentWindow: null as unknown };
const previewIframeRef = { current: iframe };
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContext: () => ({ previewIframeRef }),
}));
const makeHook = (groups: Record<string, { l: number; r: number }> = {}) => ({
start: vi.fn(),
stop: vi.fn(),
read: vi.fn(() => ({ master: { l: 1, r: 0.5 }, groups })),
});
const setHook = (hook: ReturnType<typeof makeHook> | null) => {
iframe.contentWindow = hook ? { __hf: { audioMeter: hook } } : null;
};
const clip = (extra: Partial<TimelineElement>) =>
({ id: "a", tag: "audio", ...extra }) as TimelineElement;
let frames: FrameRequestCallback[] = [];
const tick = () => {
const run = frames;
frames = [];
act(() => run.forEach((cb) => cb(performance.now() + 16)));
};
beforeEach(() => {
frames = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
vi.stubGlobal("cancelAnimationFrame", () => {});
usePlayerStore.setState({ elements: [] });
useAudioMetersVisible.setState({ visible: true });
});
const roots: Root[] = [];
afterEach(() => {
act(() => roots.splice(0).forEach((root) => root.unmount()));
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
function mount() {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => root.render(<AudioMeterStrip />));
roots.push(root);
return { host, root };
}
describe("AudioMeterStrip", () => {
it("is absent when the project has no audio", () => {
usePlayerStore.setState({ elements: [clip({ tag: "div" })] });
expect(mount().host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
});
it("shows one strip per group plus Master, and hides when toggled off", () => {
usePlayerStore.setState({
elements: [
clip({ id: "1", audioGroup: "music", audioGroupLabel: "Music" }),
clip({ id: "2", audioGroup: "vo" }),
],
});
const { host } = mount();
expect(host.textContent).toContain("Music");
expect(host.textContent).toContain("vo");
expect(host.textContent).toContain("Master");
act(() => useAudioMetersVisible.getState().setVisible(false));
expect(host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
});
it("starts the taps once, drives the bars, follows a swapped preview window, and stops on unmount", () => {
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
const first = makeHook({ vo: { l: 1, r: 1 } });
setHook(first);
const { host, root } = mount();
const fill = host.querySelector<HTMLElement>("[class*=bg-green-500]")!;
expect(fill.style.transform).toBe("scaleY(0)");
tick();
tick();
expect(first.start).toHaveBeenCalledTimes(1);
expect(fill.style.transform).toBe("scaleY(1)");
const peak = host.querySelector<HTMLElement>("[data-testid=meter-peak]")!;
expect(peak.style.bottom).toBe("100%");
expect(peak.style.transform).toBe("translateY(1px)");
const second = makeHook();
setHook(second);
tick();
expect(first.stop).toHaveBeenCalledTimes(1);
expect(second.start).toHaveBeenCalledTimes(1);
act(() => root.unmount());
roots.length = 0;
expect(second.stop).toHaveBeenCalledTimes(1);
});
it("follows a new preview hook even when the old stop() throws", () => {
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
const first = makeHook({ vo: { l: 1, r: 1 } });
first.stop.mockImplementation(() => {
throw new Error("dead realm");
});
setHook(first);
mount();
tick();
const second = makeHook();
setHook(second);
tick();
expect(second.start).toHaveBeenCalledTimes(1);
tick();
expect(second.read).toHaveBeenCalled();
});
});
@@ -0,0 +1,213 @@
import { memo, useEffect, useMemo, useRef, type Ref, type RefObject } from "react";
import { usePlayerStore } from "../../player";
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
import { useStudioShellContext } from "../../contexts/StudioContext";
import {
METER_DB_MARKS,
useProjectHasAudio,
markFraction,
stepPair,
type MeterPair as Pair,
} from "../../utils/audioMeterMath";
interface Levels {
master: { l: number; r: number };
groups: Record<string, { l: number; r: number }>;
}
interface AudioMeterHook {
start(): void;
stop(): void;
read(): Levels;
}
interface Strip {
id: string | null;
label: string;
}
type Bars = { fill: HTMLElement | null; peak: HTMLElement | null };
type StripBars = [Bars, Bars];
const MASTER: Strip = { id: null, label: "Master" };
function useStrips(): Strip[] {
const elements = usePlayerStore((s) => s.elements);
return useMemo(() => {
const labels = new Map<string, string>();
for (const el of elements) {
if (el.audioGroup && !labels.has(el.audioGroup)) {
labels.set(el.audioGroup, el.audioGroupLabel ?? el.audioGroup);
}
}
return [...[...labels].map(([id, label]) => ({ id, label })), MASTER];
}, [elements]);
}
type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
try {
return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
} catch {
return null;
}
}
/** 0 dB is bottom 100% plus 1px down, so the tick stays inside overflow-hidden.
* A calc() bottom does not stick as an inline style on Windows happy-dom. */
function paintPeak(el: HTMLElement | null, peak: number): void {
if (!el) return;
el.style.setProperty("bottom", `${peak * 100}%`);
el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
}
function paint(bars: StripBars | undefined, channels: Pair): void {
channels.forEach((ch, i) => {
bars?.[i]?.fill?.style.setProperty("transform", `scaleY(${ch.level})`);
paintPeak(bars?.[i]?.peak ?? null, ch.peak);
});
}
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
const { previewIframeRef } = useStudioShellContext();
const stripsRef = useRef(strips);
stripsRef.current = strips;
useEffect(() => {
let raf = 0;
let active: AudioMeterHook | null = null;
let last = performance.now();
const state = new Map<string | null, Pair>();
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;
}
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);
}
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
try {
active?.stop();
} catch {
// Preview iframe already gone.
}
};
}, [previewIframeRef, bars]);
}
function Bar({ fillRef, peakRef }: { fillRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
return (
<div className="relative h-full w-1.5 overflow-hidden rounded-[1px] bg-neutral-900">
<div
ref={fillRef}
className="absolute inset-0 origin-bottom bg-green-500"
style={{ transform: "scaleY(0)" }}
/>
<div
ref={peakRef}
data-testid="meter-peak"
className="absolute inset-x-0 bottom-0 h-px bg-green-400"
/>
</div>
);
}
function MeterStrip({
strip,
register,
}: {
strip: Strip;
register: (id: string | null, bars: StripBars | null) => void;
}) {
const refs = [
useRef<HTMLDivElement>(null),
useRef<HTMLDivElement>(null),
useRef<HTMLDivElement>(null),
useRef<HTMLDivElement>(null),
] as const;
useEffect(() => {
register(strip.id, [
{ fill: refs[0].current, peak: refs[1].current },
{ fill: 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 (
<div className="flex w-16 shrink-0 flex-col items-center gap-1 px-1 pt-2 pb-1">
<div
className="flex min-h-0 flex-1 items-stretch gap-0.5"
aria-label={`${strip.label} level`}
>
<Bar fillRef={refs[0]} peakRef={refs[1]} />
<Bar fillRef={refs[2]} peakRef={refs[3]} />
<div className="relative ml-1 w-5 font-mono text-[9px] leading-none text-neutral-500">
{METER_DB_MARKS.map((db) => (
<span
key={db}
className="absolute right-0 translate-y-1/2"
style={{ bottom: `${markFraction(db) * 100}%` }}
>
{db}
</span>
))}
</div>
</div>
<span className="max-w-full truncate text-[10px] text-neutral-400" title={strip.label}>
{strip.label}
</span>
</div>
);
}
export const AudioMeterStrip = memo(function AudioMeterStrip() {
const visible = useAudioMetersVisible((s) => s.visible);
const projectHasAudio = useProjectHasAudio();
if (!visible || !projectHasAudio) return null;
return <MeterStripBody />;
});
function MeterStripBody() {
const strips = useStrips();
const bars = useRef(new Map<string | null, StripBars>());
const register = useRef((id: string | null, b: StripBars | null) => {
if (b) bars.current.set(id, b);
else bars.current.delete(id);
}).current;
useMeterLoop(strips, bars);
return (
<div
data-testid="audio-meter-strip"
className="flex shrink-0 overflow-x-auto border-l border-neutral-800/50 bg-neutral-950"
>
{strips.map((strip) => (
<MeterStrip key={strip.id ?? "master"} strip={strip} register={register} />
))}
</div>
);
}
@@ -2,6 +2,7 @@ import { useCallback, type ReactNode } from "react";
import { Timeline } from "../../player";
import type { TimelineElement, TimelineTimeRange } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { AudioMeterStrip } from "./AudioMeterStrip";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
import { useNLEContext } from "./NLEContext";
@@ -271,29 +272,34 @@ export function TimelinePane({
}}
>
<div className="shrink-0">{timelineToolbar}</div>
<Timeline
sessionEpoch={timelineSessionEpoch}
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
onFileDrop={onFileDrop}
onDeleteElement={handleDeleteElement}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onCompositionDrop={onCompositionDrop}
onMoveElement={handleMoveElement}
onMoveElements={handleMoveElements}
onResizeElement={handleResizeElement}
onResizeElements={handleResizeElements}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={handleSplitElement}
onSelectElement={onSelectTimelineElement}
onRangeSelect={onRangeSelect}
onCopyClip={onCopyClip}
onPasteClip={onPasteClip}
onDuplicateClip={onDuplicateClip}
canPasteClip={canPasteClip}
/>
<div className="flex min-h-0 flex-1">
<div className="min-w-0 flex-1">
<Timeline
sessionEpoch={timelineSessionEpoch}
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
onFileDrop={onFileDrop}
onDeleteElement={handleDeleteElement}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onCompositionDrop={onCompositionDrop}
onMoveElement={handleMoveElement}
onMoveElements={handleMoveElements}
onResizeElement={handleResizeElement}
onResizeElements={handleResizeElements}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={handleSplitElement}
onSelectElement={onSelectTimelineElement}
onRangeSelect={onRangeSelect}
onCopyClip={onCopyClip}
onPasteClip={onPasteClip}
onDuplicateClip={onDuplicateClip}
canPasteClip={canPasteClip}
/>
</div>
<AudioMeterStrip />
</div>
</div>
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
{timelineOverlay && (
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
levelToFraction,
markFraction,
SILENT_CHANNEL,
stepChannel,
stepPair,
} from "./audioMeterMath";
describe("levelToFraction", () => {
it("puts each dB mark on its equally spaced stop", () => {
[1, 0.8, 0.6, 0.4, 0.2].forEach((f, i) =>
expect(markFraction([0, -3, -6, -12, -24][i]!)).toBeCloseTo(f, 6),
);
});
it("clamps silence and overs", () => {
expect(levelToFraction(0)).toBe(0);
expect(levelToFraction(4)).toBe(1);
});
it("is monotonic between marks", () => {
expect(levelToFraction(0.5)).toBeGreaterThan(markFraction(-12));
expect(levelToFraction(0.5)).toBeLessThan(markFraction(-3));
});
});
describe("stepChannel", () => {
it("attacks instantly, holds the peak, then falls", () => {
let ch = stepChannel(SILENT_CHANNEL, 1, 0, 16);
expect(ch.level).toBe(1);
ch = stepChannel(ch, 0, 500, 16);
expect(ch.peak).toBe(1);
expect(ch.level).toBeLessThan(1);
ch = stepChannel(ch, 0, 2000, 500);
expect(ch.peak).toBeLessThan(1);
});
});
describe("stepPair", () => {
it("returns the same pair while silent at rest, and moves on sound", () => {
const rest = stepPair(undefined, undefined, 0, 16);
expect(stepPair(rest, { l: 0, r: 0 }, 16, 16)).toBe(rest);
expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
});
});
@@ -0,0 +1,78 @@
import { useMemo } from "react";
import { usePlayerStore, type TimelineElement } from "../player";
import { isAudioTimelineElement } from "./timelineInspector";
/** 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;
const FLOOR_DB = -60;
const STOPS = [...METER_DB_MARKS, FLOOR_DB];
const FALL_PER_SECOND = 0.9;
const HOLD_MS = 1200;
/** Linear peak (0..1) to a 0..1 bar fraction along the piecewise-linear dB scale. */
export function levelToFraction(linear: number): number {
const db = linear > 0 ? 20 * Math.log10(linear) : FLOOR_DB;
if (db >= 0) return 1;
if (db <= FLOOR_DB) return 0;
const j = STOPS.findIndex((stop) => db >= stop);
const hi = STOPS[j - 1] as number;
const lo = STOPS[j] as number;
return (STOPS.length - 1 - j + (db - lo) / (hi - lo)) / (STOPS.length - 1);
}
/** Fraction of the bar height at which a dB mark sits, 0 at the bottom. */
export function markFraction(db: number): number {
return levelToFraction(10 ** (db / 20));
}
export interface MeterChannel {
level: number;
peak: number;
peakAt: number;
}
export const SILENT_CHANNEL: MeterChannel = { level: 0, peak: 0, peakAt: 0 };
/** Instant attack, steady fall; the peak holds for HOLD_MS, then falls with the bar. */
export function stepChannel(
prev: MeterChannel,
linear: number,
now: number,
dtMs: number,
): MeterChannel {
const fall = (dtMs / 1000) * FALL_PER_SECOND;
const target = levelToFraction(linear);
const level = Math.max(target, prev.level - fall);
if (target >= prev.peak) return { level, peak: target, peakAt: now };
if (now - prev.peakAt < HOLD_MS) return { level, peak: prev.peak, peakAt: prev.peakAt };
return { level, peak: Math.max(target, prev.peak - fall), peakAt: prev.peakAt };
}
export type MeterPair = [MeterChannel, MeterChannel];
const atRest = (ch: MeterChannel): boolean => ch.level === 0 && ch.peak === 0;
/** Steps both channels; returns `prev` itself while silent and at rest, so callers can skip the paint. */
export function stepPair(
prev: MeterPair | undefined,
raw: { l: number; r: number } | undefined,
now: number,
dtMs: number,
): MeterPair {
const [l, r] = prev ?? [SILENT_CHANNEL, SILENT_CHANNEL];
const silent = !raw?.l && !raw?.r;
if (prev && silent && atRest(l) && atRest(r)) return prev;
return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
}
/** The meters appear only for a project with at least one audio clip or group. */
function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
return elements.some((el) => isAudioTimelineElement(el) || el.audioGroup !== undefined);
}
/** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
export function useProjectHasAudio(): boolean {
const elements = usePlayerStore((s) => s.elements);
return useMemo(() => hasProjectAudio(elements), [elements]);
}
@@ -0,0 +1,14 @@
import { create } from "zustand";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
/** Whether the audio meter strip is shown; persisted, on unless the user hid it. */
export const useAudioMetersVisible = create<{
visible: boolean;
setVisible: (visible: boolean) => void;
}>((set) => ({
visible: readStudioUiPreferences().audioMetersVisible ?? true,
setVisible: (visible) => {
writeStudioUiPreferences({ audioMetersVisible: visible });
set({ visible });
},
}));
@@ -161,3 +161,13 @@ describe("timeline zoom pin persistence", () => {
expect(prefs.timelineManualZoomPercent).toBeUndefined();
});
});
describe("audioMetersVisible preference", () => {
it("round-trips and ignores a non-boolean", () => {
const storage = createStorage();
writeStudioUiPreferences({ audioMetersVisible: false }, storage);
expect(readStudioUiPreferences(storage).audioMetersVisible).toBe(false);
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ audioMetersVisible: "no" }));
expect(readStudioUiPreferences(storage).audioMetersVisible).toBeUndefined();
});
});
@@ -25,6 +25,8 @@ 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. */
audioMetersVisible?: boolean;
/** Keeps the main track gapless: deleting a clip closes the gap. Distinct
* from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */
rippleEditEnabled?: boolean;
@@ -140,6 +142,9 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
if (typeof parsed.timelineSnapEnabled === "boolean") {
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
}
if (typeof parsed.audioMetersVisible === "boolean") {
preferences.audioMetersVisible = parsed.audioMetersVisible;
}
if (typeof parsed.rippleEditEnabled === "boolean") {
preferences.rippleEditEnabled = parsed.rippleEditEnabled;
}