diff --git a/src/components/AudioLevelMeter.tsx b/src/components/AudioLevelMeter.tsx index 0bb67c0ee..02644ee9f 100644 --- a/src/components/AudioLevelMeter.tsx +++ b/src/components/AudioLevelMeter.tsx @@ -14,10 +14,13 @@ import { import classNames from "classnames"; 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 -// bars means thinner ones, and a finer-grained reading of the level. +// The level is quantised into this many bars. They share the row, so the +// count also sets how thick they are. const BAR_COUNT = 18; /** * Level below which the meter reads as silent. Above the noise floor of a @@ -99,3 +102,22 @@ export const AudioLevelMeter: FC = ({ state }) => { ); }; + +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 = ({ + deviceId, + active, +}) => ( + +); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index e72f2055f..aad0b583d 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -38,8 +38,7 @@ import { type DeviceLabel, } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; -import { AudioLevelMeter } from "./AudioLevelMeter"; -import { useMicrophoneLevel } from "./useMicrophoneLevel"; +import { MicrophoneLevel } from "./AudioLevelMeter"; import { Slider } from "../Slider"; export interface MenuOptions { @@ -132,13 +131,6 @@ export const MediaMuteAndSwitchButton: FC = ({ if (menuOpen) devices.requestDeviceNames(); // No-op after the first call }, [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 toggles: { label: string; enabled: boolean; id: string }[] = []; switch (iconsAndLabels) { @@ -304,7 +296,13 @@ export const MediaMuteAndSwitchButton: FC = ({ controls; pinned to the foot of the scroll port, it stays on screen for as long as any microphone is. */}
- + {/* The capture is bound to the menu being open, so the + microphone is only ever held while the user is looking + at the level. */} +

diff --git a/src/components/useMicrophoneLevel.test.tsx b/src/components/useMicrophoneLevel.test.tsx index a44a0262b..804170a0f 100644 --- a/src/components/useMicrophoneLevel.test.tsx +++ b/src/components/useMicrophoneLevel.test.tsx @@ -6,9 +6,16 @@ Please see LICENSE in the repository root for full details. */ 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", () => { test("level indicator follows the microphone signal level", async () => { @@ -16,7 +23,9 @@ describe("useMicrophoneLevel", () => { const getUserMedia = vi.fn().mockResolvedValue(stream); 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")); expect(result.current).toEqual({ type: "active", level: 0 }); @@ -40,7 +49,9 @@ describe("useMicrophoneLevel", () => { 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")); // A suspended context reports silence however loud the microphone is, @@ -48,13 +59,66 @@ describe("useMicrophoneLevel", () => { 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 () => { const { stream } = fakeStream(); vi.stubGlobal("navigator", { 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")); tick(10); expect(result.current).toEqual({ type: "active", level: 0 }); @@ -70,7 +134,7 @@ describe("useMicrophoneLevel", () => { vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); const { result, rerender } = renderHook( - ({ id }: { id: string }) => useMicrophoneLevel(id, true), + ({ id }: { id: string }) => useMicrophoneLevel(id, true, STEPS), { initialProps: { id: "mic-1" } }, ); await waitFor(() => expect(result.current.type).toBe("active")); @@ -99,7 +163,7 @@ describe("useMicrophoneLevel", () => { vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); const { result, rerender } = renderHook( - ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on), + ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on, STEPS), { initialProps: { on: true } }, ); @@ -122,7 +186,7 @@ describe("useMicrophoneLevel", () => { }); const { result, rerender } = renderHook( - ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on), + ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on, STEPS), { initialProps: { on: true } }, ); await waitFor(() => expect(result.current.type).toBe("active")); @@ -143,7 +207,7 @@ describe("useMicrophoneLevel", () => { vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); const { result, rerender } = renderHook( - ({ id }: { id: string }) => useMicrophoneLevel(id, true), + ({ id }: { id: string }) => useMicrophoneLevel(id, true, STEPS), { initialProps: { id: "mic-1" } }, ); 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", () => { vi.stubGlobal("navigator", {}); - const { result } = renderHook(() => useMicrophoneLevel("mic-1", true)); + const { result } = renderHook(() => + useMicrophoneLevel("mic-1", true, STEPS), + ); expect(result.current).toEqual({ type: "unavailable" }); }); @@ -165,7 +231,9 @@ describe("useMicrophoneLevel", () => { const getUserMedia = vi.fn(); 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(result.current).toEqual({ type: "inactive" }); }); @@ -228,6 +296,30 @@ describe("useMicrophoneLevel", () => { 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(); + return { renders: () => renders, state: () => state }; + } + /** Runs one animation frame, if the hook has asked for one. */ function tick(times = 1): void { for (let i = 0; i < times; i++) { diff --git a/src/components/useMicrophoneLevel.ts b/src/components/useMicrophoneLevel.ts index 0c53454c3..9181a27f2 100644 --- a/src/components/useMicrophoneLevel.ts +++ b/src/components/useMicrophoneLevel.ts @@ -25,6 +25,7 @@ export type MicrophoneLevelState = /** Signal at or below this many dBFS reads as silence. */ const FLOOR_DB = -60; + /** * Smoothing applied to the displayed level. Rises are followed almost * 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 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( deviceId: string | undefined, enabled: boolean, + steps: number, ): MicrophoneLevelState { const [state, setState] = useState({ type: "inactive", @@ -109,7 +113,16 @@ export function useMicrophoneLevel( const level = amplitudeToLevel(rms(samples)); smoothed += (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); }; setState({ type: "active", level: 0 }); @@ -124,7 +137,7 @@ export function useMicrophoneLevel( }); return dispose; - }, [deviceId, enabled]); + }, [deviceId, enabled, steps]); return state; }