Give the microphone back when the audio graph fails to build

Building the analyser can fail after getUserMedia has already resolved,
and the capture then outlived its own failure: the microphone stayed
open, and its in-use light on, behind a meter reporting the microphone
as unavailable, until the menu closed.

Cleanup did two jobs under one name. Releasing what has been acquired is
now its own step, which teardown and the failure path both take.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-11 11:03:47 +02:00
co-authored by Claude Opus 5
parent 888d663325
commit fb9fd5880c
2 changed files with 39 additions and 2 deletions
@@ -227,6 +227,33 @@ describe("useMicrophoneLevel", () => {
expect(result.current).toEqual({ type: "unavailable" });
});
test("a capture is released when the audio graph fails to build", async () => {
const { stream, stop } = fakeStream();
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
});
// Whatever the browser does after the microphone is already open.
vi.stubGlobal(
"AudioContext",
class {
public constructor() {
throw new Error("no audio backend");
}
},
);
const { result } = renderHook(() =>
useMicrophoneLevel("mic-1", true, STEPS),
);
await waitFor(() =>
expect(result.current).toEqual({ type: "unavailable" }),
);
// Otherwise the microphone stays open, and its in-use light on, behind a
// meter that says it is unavailable.
expect(stop).toHaveBeenCalled();
});
test("a missing microphone is reported as absent, not as silence", () => {
const getUserMedia = vi.fn();
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+12 -2
View File
@@ -82,8 +82,8 @@ export function useMicrophoneLevel(
let context: AudioContext | undefined;
let frame: number | undefined;
const dispose = (): void => {
disposed = true;
/** Gives back whatever has been acquired so far. */
const release = (): void => {
if (frame !== undefined) cancelAnimationFrame(frame);
stream?.getTracks().forEach((t) => t.stop());
// close() rejects if the context is already closed, which is possible if
@@ -94,6 +94,11 @@ export function useMicrophoneLevel(
frame = undefined;
};
const dispose = (): void => {
disposed = true;
release();
};
navigator.mediaDevices
.getUserMedia({ audio: { deviceId: { exact: deviceId } } })
.then((acquired) => {
@@ -137,6 +142,11 @@ export function useMicrophoneLevel(
})
.catch((e: unknown) => {
if (disposed) return;
// The microphone may already be open: building the audio graph can
// fail after getUserMedia has resolved, and the capture would then
// outlive its own failure, holding the microphone-in-use indicator on
// behind a meter that says the microphone is unavailable.
release();
rootLogger
.getChild("[useMicrophoneLevel]")
.warn("Could not open microphone for level metering", e);