Skip to content

Commit d233cdd

Browse files
Merge pull request #221 from corbitsdev/fix/dictation-stop-during-mic-prompt
fix(web): let dictation be stopped while the mic is still being asked
2 parents d4111e1 + 26cf3b8 commit d233cdd

2 files changed

Lines changed: 138 additions & 1 deletion

File tree

‎apps/web/src/dictation.test.ts‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { afterEach, expect, spyOn, test } from "bun:test";
2-
import { openMicrophone } from "./dictation.tsx";
2+
import {
3+
createElement,
4+
Fragment,
5+
type ReactElement,
6+
type ReactNode,
7+
} from "react";
8+
import { renderToString } from "react-dom/server";
9+
import { Dictated, openMicrophone } from "./dictation.tsx";
310

411
const realNavigator = globalThis.navigator;
512
const realAudioContext = globalThis.AudioContext;
13+
const realWindow = globalThis.window;
614

715
afterEach(() => {
816
Object.defineProperty(globalThis, "navigator", {
@@ -13,6 +21,10 @@ afterEach(() => {
1321
value: realAudioContext,
1422
configurable: true,
1523
});
24+
Object.defineProperty(globalThis, "window", {
25+
value: realWindow,
26+
configurable: true,
27+
});
1628
});
1729

1830
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",
3648
expect(stopped).toBe(1);
3749
warn.mockRestore();
3850
});
51+
52+
test("a dictation started while another waits for its microphone closes the first microphone", async () => {
53+
const microphones: Array<(stream: MediaStream) => void> = [];
54+
let firstMicrophoneStops = 0;
55+
let firstMicrophoneStopped!: () => void;
56+
const firstMicrophoneStop = new Promise<void>((resolve) => {
57+
firstMicrophoneStopped = resolve;
58+
});
59+
const recognitions: Array<{ started: boolean }> = [];
60+
let recognitionStarted!: () => void;
61+
const recognitionStart = new Promise<void>((resolve) => {
62+
recognitionStarted = resolve;
63+
});
64+
65+
class FakeRecognition {
66+
started = false;
67+
start() {
68+
this.started = true;
69+
recognitionStarted();
70+
}
71+
stop() {}
72+
abort() {}
73+
constructor() {
74+
recognitions.push(this);
75+
}
76+
}
77+
class FakeAudioContext {
78+
createAnalyser() {
79+
return { fftSize: 0, getFloatTimeDomainData() {} };
80+
}
81+
createMediaStreamSource() {
82+
return { connect() {} };
83+
}
84+
close() {
85+
return Promise.resolve();
86+
}
87+
}
88+
Object.defineProperty(globalThis, "window", {
89+
value: {
90+
SpeechRecognition: FakeRecognition,
91+
setInterval: () => 0,
92+
clearInterval() {},
93+
},
94+
configurable: true,
95+
});
96+
Object.defineProperty(globalThis, "navigator", {
97+
value: {
98+
language: "en-US",
99+
mediaDevices: {
100+
getUserMedia: () => new Promise((resolve) => microphones.push(resolve)),
101+
},
102+
},
103+
configurable: true,
104+
});
105+
Object.defineProperty(globalThis, "AudioContext", {
106+
value: FakeAudioContext,
107+
configurable: true,
108+
});
109+
110+
const starts: Array<() => void> = [];
111+
const box = (control: ReactNode) => {
112+
starts.push(
113+
(control as ReactElement<{ onToggle: () => void }>).props.onToggle,
114+
);
115+
return null;
116+
};
117+
renderToString(
118+
createElement(
119+
Fragment,
120+
null,
121+
createElement(Dictated, {
122+
value: "",
123+
onValueChange: () => {},
124+
children: box,
125+
}),
126+
createElement(Dictated, {
127+
value: "",
128+
onValueChange: () => {},
129+
children: box,
130+
}),
131+
),
132+
);
133+
134+
starts[0]!();
135+
starts[1]!();
136+
microphones[0]!({
137+
getTracks: () => [
138+
{
139+
stop: () => {
140+
firstMicrophoneStops += 1;
141+
firstMicrophoneStopped();
142+
},
143+
},
144+
],
145+
} as unknown as MediaStream);
146+
microphones[1]!({ getTracks: () => [] } as unknown as MediaStream);
147+
await Promise.all([firstMicrophoneStop, recognitionStart]);
148+
149+
expect(firstMicrophoneStops).toBe(1);
150+
expect(
151+
recognitions.filter((recognition) => recognition.started),
152+
).toHaveLength(1);
153+
});

‎apps/web/src/dictation.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ function useDictation(value: string, onValueChange: (value: string) => void) {
157157
finish("Speech recognition is not available in this browser.");
158158
return;
159159
}
160+
// Registered before the microphone is asked for, which can wait on a
161+
// permission dialog, so a stop, a second box or leaving the screen in the
162+
// meantime reaches this session.
163+
let cancelled = false;
164+
const acquiring: Session = {
165+
stop: () => {
166+
cancelled = true;
167+
finish("stopped");
168+
},
169+
abort: () => {
170+
cancelled = true;
171+
finish("stopped");
172+
},
173+
};
174+
session.current = acquiring;
175+
openSession = acquiring;
176+
160177
const recognition = new Recognizer();
161178
recognition.continuous = true;
162179
recognition.interimResults = true;
@@ -181,6 +198,11 @@ function useDictation(value: string, onValueChange: (value: string) => void) {
181198
meter = null;
182199
};
183200
const microphone = await openMicrophone();
201+
if (cancelled) {
202+
microphone?.stream.getTracks().forEach((track) => track.stop());
203+
void microphone?.context.close();
204+
return;
205+
}
184206
if (microphone !== null) {
185207
const { stream, context, analyser } = microphone;
186208
const samples = new Float32Array(analyser.fftSize);

0 commit comments

Comments
 (0)