+
+ {state.type !== "level" ? (
+
+ {state.type === "permission-denied"
+ ? t("microphone_level.permission_denied")
+ : t("microphone_level.no_device")}
+
+ ) : (
+
+ {Array.from({ length: barCount }, (_, i) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+/**
+ * How many bars fit across `width`, measured off a rendered one rather than
+ * told: a bar's size is a design question, settled in the stylesheet, and
+ * reading it back is what keeps it from being settled twice.
+ */
+function barsThatFit(track: HTMLElement, width: number): number {
+ const gap = Number.parseFloat(getComputedStyle(track).columnGap);
+ const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
+ // A renderer that lays nothing out tells us nothing; keep the full count.
+ if (!(bar > 0) || !(gap >= 0)) return LEVEL_SCALE;
+ return Math.max(1, Math.floor((width + gap) / (bar + gap)));
+}
diff --git a/src/components/useMicrophoneLevel.test.tsx b/src/components/useMicrophoneLevel.test.tsx
new file mode 100644
index 000000000..1258bfeea
--- /dev/null
+++ b/src/components/useMicrophoneLevel.test.tsx
@@ -0,0 +1,42 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { afterEach, describe, expect, test } from "vitest";
+import { renderHook } from "@testing-library/react";
+
+import { useMicrophoneLevel } from "./useMicrophoneLevel";
+import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
+
+// The capture itself, and releasing it, belong to observeMicrophoneState$ and
+// are covered in src/state/MicrophoneLevel.test.ts. What is left here is the
+// bridging: when the hook watches, and what it reports before it has an answer.
+describe("useMicrophoneLevel", () => {
+ afterEach(restoreAudioCapture);
+
+ test("holds no capture while the meter is not shown", () => {
+ const capture = stubAudioCapture();
+
+ renderHook(() => useMicrophoneLevel("mic1", false));
+
+ expect(capture.getUserMedia).not.toHaveBeenCalled();
+ });
+
+ test("starts from nothing rather than the previous device's level", () => {
+ const capture = stubAudioCapture();
+
+ const { result, rerender } = renderHook(
+ ({ id }: { id: string }) => useMicrophoneLevel(id, true),
+ { initialProps: { id: "mic1" } },
+ );
+ capture.grant();
+
+ rerender({ id: "mic2" });
+
+ // No level carried over: the meter reads the new device or nothing at all.
+ expect(result.current).toEqual({ type: "level", level: 0 });
+ });
+});
diff --git a/src/components/useMicrophoneLevel.ts b/src/components/useMicrophoneLevel.ts
new file mode 100644
index 000000000..6e295d3e4
--- /dev/null
+++ b/src/components/useMicrophoneLevel.ts
@@ -0,0 +1,41 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { useEffect, useState } from "react";
+
+import {
+ type MicrophoneState,
+ observeMicrophoneState$,
+} from "../state/MicrophoneLevel";
+
+const IDLE: MicrophoneState = { type: "level", level: 0 };
+
+/**
+ * Reads the live input level of a microphone, while `active`.
+ *
+ * A bridge and nothing else: the capture, its lifetime and the maths belong to
+ * {@link observeMicrophoneState$}. Scoped to `active` so the device is held
+ * only while whatever shows the meter is on screen, rather than for the length
+ * of a call.
+ */
+export function useMicrophoneLevel(
+ deviceId: string | undefined,
+ active: boolean,
+): MicrophoneState {
+ const [state, setState] = useState