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);