From fb9fd5880c0bd7160b63e6ab6e6ff521f339af13 Mon Sep 17 00:00:00 2001 From: fkwp Date: Fri, 11 Sep 2026 11:03:05 +0200 Subject: [PATCH] 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) --- src/components/useMicrophoneLevel.test.tsx | 27 ++++++++++++++++++++++ src/components/useMicrophoneLevel.ts | 14 +++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/components/useMicrophoneLevel.test.tsx b/src/components/useMicrophoneLevel.test.tsx index cb8e7bd47..1fb15ba2a 100644 --- a/src/components/useMicrophoneLevel.test.tsx +++ b/src/components/useMicrophoneLevel.test.tsx @@ -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 } }); diff --git a/src/components/useMicrophoneLevel.ts b/src/components/useMicrophoneLevel.ts index b68b326b9..723a02efd 100644 --- a/src/components/useMicrophoneLevel.ts +++ b/src/components/useMicrophoneLevel.ts @@ -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);