Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion apps/web/src/dictation.test.ts
Original file line number Diff line number Diff line change
@@ -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", {
Expand All @@ -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 () => {
Expand All @@ -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<void>((resolve) => {
firstMicrophoneStopped = resolve;
});
const recognitions: Array<{ started: boolean }> = [];
let recognitionStarted!: () => void;
const recognitionStart = new Promise<void>((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);
});
22 changes: 22 additions & 0 deletions apps/web/src/dictation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down