Redraw the level meter only when it has something new to show

The meter reported a fresh level on every animation frame, so the whole
menu reconciled around sixty times a second for as long as it was open,
silence included.

The level is now rounded to the number of bars the meter draws, and the
previous value is kept whenever that has not moved, so a frame with
nothing new to draw re-renders nothing and silence costs nothing at all.
The capture hook also moved into a component of its own, so a level that
does change redraws the meter rather than the device rows and the slider
beside it.

The bar count is what sets that resolution, and the hook is told it
rather than naming a number of its own.

Three tests count commits per animation frame, an effect with no
dependencies standing in for a paint: silence commits nothing, a steady
tone commits only until the level settles, and the reported level is
always one the meter can draw.

Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md — no acceptance criterion
changes; AC8 and AC12 hold as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-11 09:17:55 +02:00
co-authored by Claude Opus 5
parent a568275d9b
commit eac200a727
4 changed files with 151 additions and 26 deletions
+25 -3
View File
@@ -14,10 +14,13 @@ import {
import classNames from "classnames"; import classNames from "classnames";
import styles from "./AudioLevelMeter.module.css"; import styles from "./AudioLevelMeter.module.css";
import { type MicrophoneLevelState } from "./useMicrophoneLevel"; import {
useMicrophoneLevel,
type MicrophoneLevelState,
} from "./useMicrophoneLevel";
// The bars share the row evenly, so the count is what sets their width: more // The level is quantised into this many bars. They share the row, so the
// bars means thinner ones, and a finer-grained reading of the level. // count also sets how thick they are.
const BAR_COUNT = 18; const BAR_COUNT = 18;
/** /**
* Level below which the meter reads as silent. Above the noise floor of a * Level below which the meter reads as silent. Above the noise floor of a
@@ -99,3 +102,22 @@ export const AudioLevelMeter: FC<AudioLevelMeterProps> = ({ state }) => {
</div> </div>
); );
}; };
export interface MicrophoneLevelProps {
/** The microphone to follow, or undefined if none is selected. */
deviceId: string | undefined;
/** Whether to hold a capture and report a level at all. */
active: boolean;
}
/**
* The level meter, following a microphone of its own.
*
* Re-renders when the level moves to another bar, and only itself.
*/
export const MicrophoneLevel: FC<MicrophoneLevelProps> = ({
deviceId,
active,
}) => (
<AudioLevelMeter state={useMicrophoneLevel(deviceId, active, BAR_COUNT)} />
);
+8 -10
View File
@@ -38,8 +38,7 @@ import {
type DeviceLabel, type DeviceLabel,
} from "../state/MediaDevices"; } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { AudioLevelMeter } from "./AudioLevelMeter"; import { MicrophoneLevel } from "./AudioLevelMeter";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
export interface MenuOptions { export interface MenuOptions {
@@ -132,13 +131,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]); }, [menuOpen, devices]);
// The meter's capture is bound to the menu being open, so the microphone is
// only ever held while the user is looking at the level.
const micLevel = useMicrophoneLevel(
audioControls?.micDeviceId,
menuOpen && audioControls !== undefined,
);
let button; let button;
let toggles: { label: string; enabled: boolean; id: string }[] = []; let toggles: { label: string; enabled: boolean; id: string }[] = [];
switch (iconsAndLabels) { switch (iconsAndLabels) {
@@ -304,7 +296,13 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
controls; pinned to the foot of the scroll port, it stays on controls; pinned to the foot of the scroll port, it stays on
screen for as long as any microphone is. */} screen for as long as any microphone is. */}
<div className={styles.stickyMeter}> <div className={styles.stickyMeter}>
<AudioLevelMeter state={micLevel} /> {/* The capture is bound to the menu being open, so the
microphone is only ever held while the user is looking
at the level. */}
<MicrophoneLevel
deviceId={audioControls.micDeviceId}
active={menuOpen}
/>
</div> </div>
</div> </div>
<hr /> <hr />
+103 -11
View File
@@ -6,9 +6,16 @@ Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, render, renderHook, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { useMicrophoneLevel } from "./useMicrophoneLevel"; import {
useMicrophoneLevel,
type MicrophoneLevelState,
} from "./useMicrophoneLevel";
/** However many levels a caller says it can draw; the meter asks for 18. */
const STEPS = 18;
describe("useMicrophoneLevel", () => { describe("useMicrophoneLevel", () => {
test("level indicator follows the microphone signal level", async () => { test("level indicator follows the microphone signal level", async () => {
@@ -16,7 +23,9 @@ describe("useMicrophoneLevel", () => {
const getUserMedia = vi.fn().mockResolvedValue(stream); const getUserMedia = vi.fn().mockResolvedValue(stream);
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true)); const { result } = renderHook(() =>
useMicrophoneLevel("mic-1", true, STEPS),
);
await waitFor(() => expect(result.current.type).toBe("active")); await waitFor(() => expect(result.current.type).toBe("active"));
expect(result.current).toEqual({ type: "active", level: 0 }); expect(result.current).toEqual({ type: "active", level: 0 });
@@ -40,7 +49,9 @@ describe("useMicrophoneLevel", () => {
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) }, mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
}); });
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true)); const { result } = renderHook(() =>
useMicrophoneLevel("mic-1", true, STEPS),
);
await waitFor(() => expect(result.current.type).toBe("active")); await waitFor(() => expect(result.current.type).toBe("active"));
// A suspended context reports silence however loud the microphone is, // A suspended context reports silence however loud the microphone is,
@@ -48,13 +59,66 @@ describe("useMicrophoneLevel", () => {
expect(resumed).toBe(1); expect(resumed).toBe(1);
}); });
test("a silent microphone re-renders nothing", async () => {
const { stream } = fakeStream();
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
});
const probe = renderProbe();
await waitFor(() => expect(probe.state().type).toBe("active"));
// The capture keeps sampling, but a frame that would draw the same bars
// must not re-render the menu the meter sits in.
const before = probe.renders();
tick(30);
expect(probe.renders() - before).toBeLessThanOrEqual(1);
});
test("a steady tone re-renders only until the level settles", async () => {
const { stream } = fakeStream();
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
});
const probe = renderProbe();
await waitFor(() => expect(probe.state().type).toBe("active"));
amplitude = 0.2;
tick(20);
expect(probe.state()).toEqual({
type: "active",
level: expect.any(Number),
});
const before = probe.renders();
tick(20);
expect(probe.renders() - before).toBeLessThanOrEqual(1);
});
test("the reported level is one the meter can draw", async () => {
const { stream } = fakeStream();
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
});
const { result } = renderHook(() =>
useMicrophoneLevel("mic-1", true, STEPS),
);
await waitFor(() => expect(result.current.type).toBe("active"));
amplitude = 0.2;
tick(6);
const { level } = result.current as { level: number };
expect(level * STEPS).toBeCloseTo(Math.round(level * STEPS), 9);
});
test("level indicator stays idle for a silent microphone", async () => { test("level indicator stays idle for a silent microphone", async () => {
const { stream } = fakeStream(); const { stream } = fakeStream();
vi.stubGlobal("navigator", { vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) }, mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
}); });
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true)); const { result } = renderHook(() =>
useMicrophoneLevel("mic-1", true, STEPS),
);
await waitFor(() => expect(result.current.type).toBe("active")); await waitFor(() => expect(result.current.type).toBe("active"));
tick(10); tick(10);
expect(result.current).toEqual({ type: "active", level: 0 }); expect(result.current).toEqual({ type: "active", level: 0 });
@@ -70,7 +134,7 @@ describe("useMicrophoneLevel", () => {
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ id }: { id: string }) => useMicrophoneLevel(id, true), ({ id }: { id: string }) => useMicrophoneLevel(id, true, STEPS),
{ initialProps: { id: "mic-1" } }, { initialProps: { id: "mic-1" } },
); );
await waitFor(() => expect(result.current.type).toBe("active")); await waitFor(() => expect(result.current.type).toBe("active"));
@@ -99,7 +163,7 @@ describe("useMicrophoneLevel", () => {
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on), ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on, STEPS),
{ initialProps: { on: true } }, { initialProps: { on: true } },
); );
@@ -122,7 +186,7 @@ describe("useMicrophoneLevel", () => {
}); });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on), ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on, STEPS),
{ initialProps: { on: true } }, { initialProps: { on: true } },
); );
await waitFor(() => expect(result.current.type).toBe("active")); await waitFor(() => expect(result.current.type).toBe("active"));
@@ -143,7 +207,7 @@ describe("useMicrophoneLevel", () => {
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ id }: { id: string }) => useMicrophoneLevel(id, true), ({ id }: { id: string }) => useMicrophoneLevel(id, true, STEPS),
{ initialProps: { id: "mic-1" } }, { initialProps: { id: "mic-1" } },
); );
await waitFor(() => expect(result.current).toEqual({ type: "denied" })); await waitFor(() => expect(result.current).toEqual({ type: "denied" }));
@@ -157,7 +221,9 @@ describe("useMicrophoneLevel", () => {
test("level indicator is unavailable where the page has no media devices", () => { test("level indicator is unavailable where the page has no media devices", () => {
vi.stubGlobal("navigator", {}); vi.stubGlobal("navigator", {});
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true)); const { result } = renderHook(() =>
useMicrophoneLevel("mic-1", true, STEPS),
);
expect(result.current).toEqual({ type: "unavailable" }); expect(result.current).toEqual({ type: "unavailable" });
}); });
@@ -165,7 +231,9 @@ describe("useMicrophoneLevel", () => {
const getUserMedia = vi.fn(); const getUserMedia = vi.fn();
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
const { result } = renderHook(() => useMicrophoneLevel("mic-1", false)); const { result } = renderHook(() =>
useMicrophoneLevel("mic-1", false, STEPS),
);
expect(getUserMedia).not.toHaveBeenCalled(); expect(getUserMedia).not.toHaveBeenCalled();
expect(result.current).toEqual({ type: "inactive" }); expect(result.current).toEqual({ type: "inactive" });
}); });
@@ -228,6 +296,30 @@ describe("useMicrophoneLevel", () => {
return { stream, stop }; return { stream, stop };
} }
/**
* Renders a component around the hook and counts how often it renders, which
* is what the menu around the meter would pay on every animation frame.
*/
function renderProbe(): {
renders: () => number;
state: () => MicrophoneLevelState;
} {
let renders = 0;
let state: MicrophoneLevelState = { type: "inactive" };
function Probe(): null {
const current = useMicrophoneLevel("mic-1", true, STEPS);
// An effect with no dependencies runs once per commit, so a frame
// React bails out of is not counted.
useEffect(() => {
renders++;
state = current;
});
return null;
}
render(<Probe />);
return { renders: () => renders, state: () => state };
}
/** Runs one animation frame, if the hook has asked for one. */ /** Runs one animation frame, if the hook has asked for one. */
function tick(times = 1): void { function tick(times = 1): void {
for (let i = 0; i < times; i++) { for (let i = 0; i < times; i++) {
+15 -2
View File
@@ -25,6 +25,7 @@ export type MicrophoneLevelState =
/** Signal at or below this many dBFS reads as silence. */ /** Signal at or below this many dBFS reads as silence. */
const FLOOR_DB = -60; const FLOOR_DB = -60;
/** /**
* Smoothing applied to the displayed level. Rises are followed almost * Smoothing applied to the displayed level. Rises are followed almost
* immediately so speech registers at once; falls are eased so the bars do not * immediately so speech registers at once; falls are eased so the bars do not
@@ -43,10 +44,13 @@ const RELEASE = 0.12;
* *
* @param deviceId - The microphone to observe, or undefined if none is selected. * @param deviceId - The microphone to observe, or undefined if none is selected.
* @param enabled - Whether to hold a capture at all. * @param enabled - Whether to hold a capture at all.
* @param steps - How many levels the caller can tell apart. The level is
* rounded to one of them, and movement within a step reports nothing new.
*/ */
export function useMicrophoneLevel( export function useMicrophoneLevel(
deviceId: string | undefined, deviceId: string | undefined,
enabled: boolean, enabled: boolean,
steps: number,
): MicrophoneLevelState { ): MicrophoneLevelState {
const [state, setState] = useState<MicrophoneLevelState>({ const [state, setState] = useState<MicrophoneLevelState>({
type: "inactive", type: "inactive",
@@ -109,7 +113,16 @@ export function useMicrophoneLevel(
const level = amplitudeToLevel(rms(samples)); const level = amplitudeToLevel(rms(samples));
smoothed += smoothed +=
(level - smoothed) * (level > smoothed ? ATTACK : RELEASE); (level - smoothed) * (level > smoothed ? ATTACK : RELEASE);
setState({ type: "active", level: smoothed }); // Keep the last value when the level has not moved a whole step:
// a frame that would redraw the same picture must not re-render
// anything. Silence therefore costs nothing at all, since the level
// rests at zero.
const stepped = Math.round(smoothed * steps) / steps;
setState((previous) =>
previous.type === "active" && previous.level === stepped
? previous
: { type: "active", level: stepped },
);
frame = requestAnimationFrame(tick); frame = requestAnimationFrame(tick);
}; };
setState({ type: "active", level: 0 }); setState({ type: "active", level: 0 });
@@ -124,7 +137,7 @@ export function useMicrophoneLevel(
}); });
return dispose; return dispose;
}, [deviceId, enabled]); }, [deviceId, enabled, steps]);
return state; return state;
} }