diff --git a/apps/web/src/dictation.test.ts b/apps/web/src/dictation.test.ts index 1b2c1a57..5200e338 100644 --- a/apps/web/src/dictation.test.ts +++ b/apps/web/src/dictation.test.ts @@ -1,8 +1,16 @@ import { afterEach, expect, spyOn, test } from "bun:test"; -import { openMicrophone } from "./dictation.tsx"; +import { + createElement, + Fragment, + type ReactElement, + type ReactNode, +} from "react"; +import { renderToString } from "react-dom/server"; +import { Dictated, openMicrophone } from "./dictation.tsx"; const realNavigator = globalThis.navigator; const realAudioContext = globalThis.AudioContext; +const realWindow = globalThis.window; afterEach(() => { Object.defineProperty(globalThis, "navigator", { @@ -13,6 +21,10 @@ afterEach(() => { value: realAudioContext, configurable: true, }); + Object.defineProperty(globalThis, "window", { + value: realWindow, + configurable: true, + }); }); test("a level meter that fails after the microphone opens stops the microphone", async () => { @@ -36,3 +48,106 @@ test("a level meter that fails after the microphone opens stops the microphone", expect(stopped).toBe(1); warn.mockRestore(); }); + +test("a dictation started while another waits for its microphone closes the first microphone", async () => { + const microphones: Array<(stream: MediaStream) => void> = []; + let firstMicrophoneStops = 0; + let firstMicrophoneStopped!: () => void; + const firstMicrophoneStop = new Promise((resolve) => { + firstMicrophoneStopped = resolve; + }); + const recognitions: Array<{ started: boolean }> = []; + let recognitionStarted!: () => void; + const recognitionStart = new Promise((resolve) => { + recognitionStarted = resolve; + }); + + class FakeRecognition { + started = false; + start() { + this.started = true; + recognitionStarted(); + } + stop() {} + abort() {} + constructor() { + recognitions.push(this); + } + } + class FakeAudioContext { + createAnalyser() { + return { fftSize: 0, getFloatTimeDomainData() {} }; + } + createMediaStreamSource() { + return { connect() {} }; + } + close() { + return Promise.resolve(); + } + } + Object.defineProperty(globalThis, "window", { + value: { + SpeechRecognition: FakeRecognition, + setInterval: () => 0, + clearInterval() {}, + }, + configurable: true, + }); + Object.defineProperty(globalThis, "navigator", { + value: { + language: "en-US", + mediaDevices: { + getUserMedia: () => new Promise((resolve) => microphones.push(resolve)), + }, + }, + configurable: true, + }); + Object.defineProperty(globalThis, "AudioContext", { + value: FakeAudioContext, + configurable: true, + }); + + const starts: Array<() => void> = []; + const box = (control: ReactNode) => { + starts.push( + (control as ReactElement<{ onToggle: () => void }>).props.onToggle, + ); + return null; + }; + renderToString( + createElement( + Fragment, + null, + createElement(Dictated, { + value: "", + onValueChange: () => {}, + children: box, + }), + createElement(Dictated, { + value: "", + onValueChange: () => {}, + children: box, + }), + ), + ); + + starts[0]!(); + starts[1]!(); + microphones[0]!({ + getTracks: () => [ + { + stop: () => { + firstMicrophoneStops += 1; + firstMicrophoneStopped(); + }, + }, + ], + } as unknown as MediaStream); + microphones[1]!({ getTracks: () => [] } as unknown as MediaStream); + await Promise.all([firstMicrophoneStop, recognitionStart]); + + expect(firstMicrophoneStops).toBe(1); + expect( + recognitions.filter((recognition) => recognition.started), + ).toHaveLength(1); +}); diff --git a/apps/web/src/dictation.tsx b/apps/web/src/dictation.tsx index 0ee8eb1e..20bee21d 100644 --- a/apps/web/src/dictation.tsx +++ b/apps/web/src/dictation.tsx @@ -157,6 +157,23 @@ function useDictation(value: string, onValueChange: (value: string) => void) { finish("Speech recognition is not available in this browser."); return; } + // Registered before the microphone is asked for, which can wait on a + // permission dialog, so a stop, a second box or leaving the screen in the + // meantime reaches this session. + let cancelled = false; + const acquiring: Session = { + stop: () => { + cancelled = true; + finish("stopped"); + }, + abort: () => { + cancelled = true; + finish("stopped"); + }, + }; + session.current = acquiring; + openSession = acquiring; + const recognition = new Recognizer(); recognition.continuous = true; recognition.interimResults = true; @@ -181,6 +198,11 @@ function useDictation(value: string, onValueChange: (value: string) => void) { meter = null; }; const microphone = await openMicrophone(); + if (cancelled) { + microphone?.stream.getTracks().forEach((track) => track.stop()); + void microphone?.context.close(); + return; + } if (microphone !== null) { const { stream, context, analyser } = microphone; const samples = new Float32Array(analyser.fftSize);