Draw the microphone level without re-rendering

- The level state carries a Behavior, so the state changes only when its kind
  does.
- The meter renders its bars once and an effect draws the level into them.
- The render-counting test now counts every commit, and asserts none while the
  level moves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-24 11:00:53 +02:00
co-authored by Claude Opus 5.5
parent 02a49928b0
commit 2aa7ff6ccc
8 changed files with 101 additions and 59 deletions
@@ -15,24 +15,16 @@ import {
type RenderResult, type RenderResult,
} from "@testing-library/react"; } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type JSX, useState, type ReactNode } from "react"; import { Profiler, type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { import {
MediaMuteAndSwitchButton, MediaMuteAndSwitchButton,
type MenuOptions, type MenuOptions,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { MediaDevicesContext, useMediaDevices } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test"; import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
import type * as MediaDevicesContextModule from "../MediaDevicesContext";
// The menu reads the devices on every render and the meter never does, so
// these calls count the menu's own renders.
vi.mock("../MediaDevicesContext", async (importOriginal) => {
const actual = await importOriginal<typeof MediaDevicesContextModule>();
return { ...actual, useMediaDevices: vi.fn(actual.useMediaDevices) };
});
interface RenderOptions { interface RenderOptions {
requestDeviceNames: () => void; requestDeviceNames: () => void;
@@ -488,15 +480,19 @@ describe("MediaMuteAndSwitchButton", () => {
).not.toHaveAttribute("aria-disabled", "true"); ).not.toHaveAttribute("aria-disabled", "true");
}); });
test("redraws the meter and not the device rows around it", async () => { test("moves the level without re-rendering anything", async () => {
// The level is held by the meter, so a moving level doesn't re-render the // The level is drawn into the DOM, so a moving level commits nothing.
// menu.
const capture = stubAudioCapture(); const capture = stubAudioCapture();
const user = userEvent.setup(); const user = userEvent.setup();
const menuRenders = vi.mocked(useMediaDevices); let commits = 0;
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<> <Profiler
id="menu"
onRender={(): void => {
commits++;
}}
>
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
@@ -513,13 +509,16 @@ describe("MediaMuteAndSwitchButton", () => {
selectedOutputOption="spk1" selectedOutputOption="spk1"
onSelectOutput={vi.fn()} onSelectOutput={vi.fn()}
/> />
</>, </Profiler>,
); );
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
capture.grant(); capture.grant();
await vi.waitFor(() => expect(capture.contexts).toHaveLength(1));
const meter = await screen.findByRole("meter"); const meter = await screen.findByRole("meter");
const settled = menuRenders.mock.calls.length; // The capture's arrival is one render: the idle level swapped for its own.
await act(async () => {});
const settled = commits;
// The meter smooths by elapsed time, so hand-driven frames need a clock. // The meter smooths by elapsed time, so hand-driven frames need a clock.
let elapsed = performance.now(); let elapsed = performance.now();
@@ -538,7 +537,7 @@ describe("MediaMuteAndSwitchButton", () => {
clock.mockRestore(); clock.mockRestore();
expect(meter.getAttribute("aria-valuenow")).not.toBe("0"); expect(meter.getAttribute("aria-valuenow")).not.toBe("0");
expect(menuRenders.mock.calls.length - settled).toBe(0); expect(commits - settled).toBe(0);
}); });
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => { test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
@@ -15,6 +15,7 @@ import {
} from "./MicrophoneLevelMeter"; } from "./MicrophoneLevelMeter";
import styles from "./MicrophoneLevelMeter.module.css"; import styles from "./MicrophoneLevelMeter.module.css";
import { LEVEL_SCALE } from "../state/MicrophoneLevel"; import { LEVEL_SCALE } from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
/** Roughly the menu's width. It only decides how many bars fit. */ /** Roughly the menu's width. It only decides how many bars fit. */
const STORY_WIDTH = 256; const STORY_WIDTH = 256;
@@ -41,18 +42,18 @@ type Story = StoryObj<typeof meta>;
/** A quiet room: hiss below the noise floor lights nothing. */ /** A quiet room: hiss below the noise floor lights nothing. */
export const Silent: Story = { export const Silent: Story = {
args: { state: { type: "level", level: 0 } }, args: { state: { type: "level", level: constant(0) } },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
await expect(litSegments(canvasElement)).toBe(0); await expect(litSegments(canvasElement)).toBe(0);
}, },
}; };
export const QuietSpeech: Story = { export const QuietSpeech: Story = {
args: { state: { type: "level", level: 5 } }, args: { state: { type: "level", level: constant(5) } },
}; };
export const NormalSpeech: Story = { export const NormalSpeech: Story = {
args: { state: { type: "level", level: 12 } }, args: { state: { type: "level", level: constant(12) } },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
// A floor, not a count: the count follows from the design's bar and gap sizes. // A floor, not a count: the count follows from the design's bar and gap sizes.
await expect( await expect(
@@ -62,16 +63,20 @@ export const NormalSpeech: Story = {
}; };
export const LoudSpeech: Story = { export const LoudSpeech: Story = {
args: { state: { type: "level", level: LEVEL_SCALE } }, args: { state: { type: "level", level: constant(LEVEL_SCALE) } },
}; };
/** The three volumes differ in how many bars are lit, not only in colour. */ /** The three volumes differ in how many bars are lit, not only in colour. */
export const VolumesAreDistinguishable: Story = { export const VolumesAreDistinguishable: Story = {
args: { state: { type: "level", level: 5 } }, args: { state: { type: "level", level: constant(5) } },
play: async ({ canvasElement, mount }) => { play: async ({ canvasElement, mount }) => {
const lit: number[] = []; const lit: number[] = [];
for (const level of [5, 12, LEVEL_SCALE]) { for (const level of [5, 12, LEVEL_SCALE]) {
await mount(<MicrophoneLevelMeter state={{ type: "level", level }} />); await mount(
<MicrophoneLevelMeter
state={{ type: "level", level: constant(level) }}
/>,
);
lit.push(litSegments(canvasElement)); lit.push(litSegments(canvasElement));
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute( await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
"aria-valuenow", "aria-valuenow",
@@ -116,7 +121,7 @@ export const NoDevice: Story = {
/** The same meter at two widths: the bars keep their size and only their count changes. */ /** The same meter at two widths: the bars keep their size and only their count changes. */
export const ShapeStaysTheSameAtAnyWidth: Story = { export const ShapeStaysTheSameAtAnyWidth: Story = {
args: { state: { type: "level", level: 12 } }, args: { state: { type: "level", level: constant(12) } },
play: async ({ mount, args }) => { play: async ({ mount, args }) => {
const narrow = await measureAt(mount, args, 180); const narrow = await measureAt(mount, args, 180);
const wide = await measureAt(mount, args, 400); const wide = await measureAt(mount, args, 400);
+4 -1
View File
@@ -10,10 +10,13 @@ import { render, screen } from "@testing-library/react";
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter"; import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
import { LEVEL_SCALE } from "../state/MicrophoneLevel"; import { LEVEL_SCALE } from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
describe("MicrophoneLevelMeter", () => { describe("MicrophoneLevelMeter", () => {
test("announces the level rather than relying on hue", () => { test("announces the level rather than relying on hue", () => {
render(<MicrophoneLevelMeter state={{ type: "level", level: 6 }} />); render(
<MicrophoneLevelMeter state={{ type: "level", level: constant(6) }} />,
);
const meter = screen.getByRole("meter", { name: "Microphone level" }); const meter = screen.getByRole("meter", { name: "Microphone level" });
expect(meter).toHaveAttribute("aria-valuenow", "6"); expect(meter).toHaveAttribute("aria-valuenow", "6");
+38 -13
View File
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { useCallback, useState, type FC, type Ref } from "react"; import {
useCallback,
useLayoutEffect,
useRef,
useState,
type FC,
type Ref,
} from "react";
import { Text } from "@vector-im/compound-web"; import { Text } from "@vector-im/compound-web";
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames"; import classNames from "classnames";
@@ -57,20 +64,45 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
// Bars keep one size, so their count follows the width. Starts full so the // Bars keep one size, so their count follows the width. Starts full so the
// first paint, and jsdom, draw a whole meter. // first paint, and jsdom, draw a whole meter.
const [barCount, setBarCount] = useState(LEVEL_SCALE); const [barCount, setBarCount] = useState(LEVEL_SCALE);
const segments = useRef<HTMLDivElement | null>(null);
const track = useCallback( const track = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => { (element: HTMLDivElement | null): (() => void) | undefined => {
if (element === null) return; if (element === null) return;
segments.current = element;
const subscription = observeElementSize$(element) const subscription = observeElementSize$(element)
.pipe( .pipe(
map(({ width }) => barsThatFit(element, width)), map(({ width }) => barsThatFit(element, width)),
distinctUntilChanged(), distinctUntilChanged(),
) )
.subscribe(setBarCount); .subscribe(setBarCount);
return (): void => subscription.unsubscribe(); return (): void => {
subscription.unsubscribe();
segments.current = null;
};
}, },
[], [],
); );
// Drawn straight into the DOM: the level changes many times a second, and
// re-rendering for each change is what this avoids.
useLayoutEffect(() => {
const element = segments.current;
if (state.type !== "level" || element === null) return;
const subscription = state.level.subscribe((level) => {
element.setAttribute("aria-valuenow", String(level));
element.setAttribute(
"aria-valuetext",
t("microphone_level.value", { level, max: LEVEL_SCALE }),
);
// The level is a share of the scale, not a bar count.
const lit = Math.round((level / LEVEL_SCALE) * barCount);
Array.from(element.children).forEach((bar, i) =>
bar.classList.toggle(styles.segmentLit, i < lit),
);
});
return (): void => subscription.unsubscribe();
}, [state, barCount, t]);
return ( return (
<div ref={ref} className={classNames(styles.meter, className)}> <div ref={ref} className={classNames(styles.meter, className)}>
<MicOnIcon className={styles.icon} aria-hidden /> <MicOnIcon className={styles.icon} aria-hidden />
@@ -88,22 +120,15 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
aria-label={t("microphone_level.label")} aria-label={t("microphone_level.label")}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={LEVEL_SCALE} aria-valuemax={LEVEL_SCALE}
aria-valuenow={state.level} // The first paint's value; the effect above keeps it current.
aria-valuenow={state.level.value}
aria-valuetext={t("microphone_level.value", { aria-valuetext={t("microphone_level.value", {
level: state.level, level: state.level.value,
max: LEVEL_SCALE, max: LEVEL_SCALE,
})} })}
> >
{Array.from({ length: barCount }, (_, i) => ( {Array.from({ length: barCount }, (_, i) => (
<span <span key={i} aria-hidden className={styles.segment} />
key={i}
aria-hidden
className={classNames(styles.segment, {
// The level is a share of the scale, not a bar count.
[styles.segmentLit]:
i < Math.round((state.level / LEVEL_SCALE) * barCount),
})}
/>
))} ))}
</div> </div>
)} )}
+2 -1
View File
@@ -35,6 +35,7 @@ describe("useMicrophoneLevel", () => {
rerender({ id: "mic2" }); rerender({ id: "mic2" });
// No level carried over from the previous device. // No level carried over from the previous device.
expect(result.current).toEqual({ type: "level", level: 0 }); const state = result.current;
expect(state.type === "level" ? state.level.value : state.type).toBe(0);
}); });
}); });
+2 -1
View File
@@ -11,8 +11,9 @@ import {
type MicrophoneState, type MicrophoneState,
observeMicrophoneState$, observeMicrophoneState$,
} from "../state/MicrophoneLevel"; } from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
const IDLE: MicrophoneState = { type: "level", level: 0 }; const IDLE: MicrophoneState = { type: "level", level: constant(0) };
/** The live level of a microphone, captured only while `active`. */ /** The live level of a microphone, captured only while `active`. */
export function useMicrophoneLevel( export function useMicrophoneLevel(
+11 -7
View File
@@ -157,16 +157,20 @@ describe("observeMicrophoneState$", () => {
test("says nothing on a frame that did not change the level", async () => { test("says nothing on a frame that did not change the level", async () => {
const capture = stubAudioCapture(); const capture = stubAudioCapture();
let emissions = 0; const states: string[] = [];
const subscription = observeMicrophoneState$("mic1").subscribe( let levels = 0;
() => emissions++, const subscription = observeMicrophoneState$("mic1").subscribe((state) => {
); states.push(state.type);
if (state.type === "level") state.level.subscribe(() => levels++);
});
capture.grant(); capture.grant();
await vi.waitFor(() => expect(emissions).toBe(1)); await vi.waitFor(() => expect(levels).toBe(1));
// Read every frame, but a steady signal emits once. // Read every frame, but a steady signal emits once, and the state itself
// arrives only once.
capture.drawFrames(20); capture.drawFrames(20);
expect(emissions).toBe(1); expect(levels).toBe(1);
expect(states).toEqual(["level"]);
subscription.unsubscribe(); subscription.unsubscribe();
}); });
+15 -11
View File
@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { distinctUntilChanged, Observable } from "rxjs"; import { BehaviorSubject, Observable } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "./Behavior";
/** What the microphone picks up, or why it can't be read. */ /** What the microphone picks up, or why it can't be read. */
export type MicrophoneState = export type MicrophoneState =
| { type: "level"; level: number } // A Behavior, so a changing level can be drawn without re-rendering.
| { type: "level"; level: Behavior<number> }
| { type: "permission-denied" } | { type: "permission-denied" }
| { type: "no-device" }; | { type: "no-device" };
@@ -29,6 +32,7 @@ export function observeMicrophoneState$(
let stream: MediaStream | undefined; let stream: MediaStream | undefined;
let context: AudioContext | undefined; let context: AudioContext | undefined;
let frame: number | undefined; let frame: number | undefined;
let level: BehaviorSubject<number> | undefined;
// Idempotent: teardown and start can both call it. // Idempotent: teardown and start can both call it.
const release = (): void => { const release = (): void => {
@@ -38,6 +42,8 @@ export function observeMicrophoneState$(
frame = undefined; frame = undefined;
stream = undefined; stream = undefined;
context = undefined; context = undefined;
level?.complete();
level = undefined;
}; };
const start = async (): Promise<void> => { const start = async (): Promise<void> => {
@@ -59,6 +65,9 @@ export function observeMicrophoneState$(
const samples = new Uint8Array(analyser.fftSize); const samples = new Uint8Array(analyser.fftSize);
let displayed = 0; let displayed = 0;
let previousFrame = performance.now(); let previousFrame = performance.now();
const current = new BehaviorSubject(0);
level = current;
subscriber.next({ type: "level", level: current });
const read = (): void => { const read = (): void => {
analyser.getByteTimeDomainData(samples); analyser.getByteTimeDomainData(samples);
@@ -75,7 +84,9 @@ export function observeMicrophoneState$(
now - previousFrame, now - previousFrame,
); );
previousFrame = now; previousFrame = now;
subscriber.next({ type: "level", level: segmentsForVolume(displayed) }); // Frames that don't move the quantised level say nothing.
const next = segmentsForVolume(displayed);
if (next !== current.value) current.next(next);
frame = requestAnimationFrame(read); frame = requestAnimationFrame(read);
}; };
read(); read();
@@ -88,14 +99,7 @@ export function observeMicrophoneState$(
}); });
return release; return release;
}).pipe( });
// Frames that don't move the quantised level don't reach React.
distinctUntilChanged(
(a, b) =>
a.type === b.type &&
(a.type !== "level" || b.type !== "level" || a.level === b.level),
),
);
} }
/** What a failure to open the microphone means for the person using it. */ /** What a failure to open the microphone means for the person using it. */