mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user