MiniSearch / client /modules /speechToText.test.ts
system's picture
system HF Staff
Sync from felladrin/MiniSearch@998b6c8
d84bd10 verified
Raw
History Blame Contribute Delete
20.7 kB
import {
DictationError,
type DictationSession,
getDictationEngine,
resampleTo16k,
setWorkerFactory,
startDictation,
} from "@/modules/speechToText";
import type { WorkerResponse } from "@/modules/speechToTextWorkerProtocol";
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
terminated = false;
messages: unknown[] = [];
constructor() {
FakeWorker.instances.push(this);
}
postMessage(message: unknown, _transfer?: Transferable[]) {
this.messages.push(message);
}
terminate() {
this.terminated = true;
}
respond(response: WorkerResponse) {
this.onmessage?.({ data: response } as MessageEvent);
}
}
class FakeMediaStreamTrack {
stopped = false;
stop() {
this.stopped = true;
}
}
function makeMediaStream() {
const tracks = [new FakeMediaStreamTrack(), new FakeMediaStreamTrack()];
return {
tracks,
stream: {
getTracks: () => tracks,
} as unknown as MediaStream,
};
}
function stubWasmSupport(getUserMedia: ReturnType<typeof vi.fn>) {
vi.stubGlobal("Worker", FakeWorker);
vi.stubGlobal("WebAssembly", { instantiate: vi.fn() });
vi.stubGlobal(
"AudioContext",
class {
sampleRate = 48000;
createMediaStreamSource() {
return { connect: vi.fn(), disconnect: vi.fn() };
}
createScriptProcessor() {
return {
onaudioprocess: null,
connect: vi.fn(),
disconnect: vi.fn(),
};
}
close() {
return Promise.resolve();
}
},
);
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia },
});
}
const originalMediaDevices = navigator.mediaDevices;
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: originalMediaDevices,
});
delete (window as unknown as Record<string, unknown>).webkitSpeechRecognition;
setWorkerFactory(() => {
throw new Error("no worker factory configured");
});
FakeWorker.instances = [];
});
describe("getDictationEngine", () => {
function installRecognitionFake() {
class FakeRecognition {
start = vi.fn();
}
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
FakeRecognition;
}
it("reports the wasm engine when it is preferred and every piece is present", () => {
stubWasmSupport(vi.fn());
expect(getDictationEngine(true)).toBe("wasm");
});
it("falls back to web-speech when the wasm pieces are missing", () => {
installRecognitionFake();
expect(getDictationEngine(true)).toBe("web-speech");
});
it("prefers web-speech over a capable wasm path when the local model is off", () => {
stubWasmSupport(vi.fn());
installRecognitionFake();
expect(getDictationEngine(false)).toBe("web-speech");
});
it("keeps the wasm engine when the local model is off but no recognizer exists", () => {
// The preference reorders engines, it never empties the list: a browser
// without `SpeechRecognition` (Firefox, for one) must still get a
// working Dictate button with the setting off.
stubWasmSupport(vi.fn());
expect(getDictationEngine(false)).toBe("wasm");
});
it("reports no engine when neither path is available", () => {
expect(getDictationEngine(true)).toBeNull();
expect(getDictationEngine(false)).toBeNull();
});
});
describe("resampleTo16k", () => {
it("passes 16 kHz audio through untouched", () => {
const input = new Float32Array([0.5, -0.5]);
expect(resampleTo16k(input, 16000)).toBe(input);
});
it("resamples 48 kHz audio to a third of the length with interpolated values", () => {
const input = new Float32Array([0, 1, 2, 3, 4, 5]);
const output = resampleTo16k(input, 48000);
expect(output.length).toBe(2);
expect(output[0]).toBeCloseTo(0);
expect(output[1]).toBeCloseTo(3);
});
});
describe("startDictation with the wasm engine", () => {
it("loads the model, streams the transcript and releases everything on stop", async () => {
const { tracks, stream } = makeMediaStream();
const getUserMedia = vi.fn().mockResolvedValue(stream);
stubWasmSupport(getUserMedia);
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const transcripts: string[] = [];
const progress: [number, number | undefined][] = [];
const sessionPromise = startDictation(
{
onTranscript: (text) => transcripts.push(text),
onProgress: (loaded, total) => progress.push([loaded, total]),
},
true,
);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
const worker = FakeWorker.instances[0];
expect(worker.messages[0]).toMatchObject({ type: "load" });
worker.respond({ type: "progress", loaded: 1000, total: 2000 });
worker.respond({ type: "loaded" });
const session: DictationSession = await sessionPromise;
worker.respond({ type: "transcript", text: "hello" });
worker.respond({ type: "transcript", text: "hello world" });
expect(transcripts).toEqual(["hello", "hello world"]);
expect(progress).toEqual([[1000, 2000]]);
await session.stop();
expect(tracks.every((track) => track.stopped)).toBe(true);
expect(worker.terminated).toBe(true);
expect(worker.messages).toContainEqual({ type: "stop" });
});
it("reports a permission error when the microphone is denied", async () => {
const getUserMedia = vi
.fn()
.mockRejectedValue(
new DOMException("Permission denied", "NotAllowedError"),
);
stubWasmSupport(getUserMedia);
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
FakeWorker.instances[0].respond({ type: "loaded" });
await expect(sessionPromise).rejects.toMatchObject({ kind: "permission" });
expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
});
/** Minimal `SpeechRecognition` stand-in; reports whether it was started. */
function installLocalRecognitionFake() {
let started = false;
class FakeRecognition {
continuous = false;
interimResults = false;
lang = "";
onresult: unknown = null;
onerror: unknown = null;
onend: unknown = null;
start() {
started = true;
}
stop() {}
}
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
FakeRecognition;
return () => started;
}
it("falls back to the browser recognizer when the local engine fails", async () => {
const { stream } = makeMediaStream();
stubWasmSupport(vi.fn().mockResolvedValue(stream));
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const started = installLocalRecognitionFake();
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
// A 502 from `/dictation-models/` is "cannot run here", not "no engine".
FakeWorker.instances[0].respond({
type: "error",
message: "model download failed",
});
await sessionPromise;
expect(started()).toBe(true);
});
it("reports a denied microphone instead of falling back", async () => {
stubWasmSupport(
vi
.fn()
.mockRejectedValue(
new DOMException("Permission denied", "NotAllowedError"),
),
);
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const started = installLocalRecognitionFake();
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
FakeWorker.instances[0].respond({ type: "loaded" });
await expect(sessionPromise).rejects.toMatchObject({ kind: "permission" });
// Falling back here would ask again and, in Chrome, send the audio away.
expect(started()).toBe(false);
});
it("fails the load when the engine dies while permission is pending", async () => {
const { tracks, stream } = makeMediaStream();
let grantMicrophone: ((stream: MediaStream) => void) | undefined;
stubWasmSupport(
vi.fn().mockReturnValue(
new Promise((resolve) => {
grantMicrophone = resolve as (stream: MediaStream) => void;
}),
),
);
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const started = installLocalRecognitionFake();
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
const worker = FakeWorker.instances[0];
worker.respond({ type: "loaded" });
// The prompt can sit open for minutes, and the transcriber is already
// running by now. Rejecting the settled load promise would be a no-op.
worker.respond({ type: "error", message: "the transcriber died" });
grantMicrophone?.(stream);
await sessionPromise;
expect(tracks.every((track) => track.stopped)).toBe(true);
expect(worker.terminated).toBe(true);
// Treated as any other engine failure, so the browser recognizer takes it.
expect(started()).toBe(true);
});
it("fails the load when the worker dies uncaught while permission is pending", async () => {
const { tracks, stream } = makeMediaStream();
let grantMicrophone: ((stream: MediaStream) => void) | undefined;
stubWasmSupport(
vi.fn().mockReturnValue(
new Promise((resolve) => {
grantMicrophone = resolve as (stream: MediaStream) => void;
}),
),
);
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const started = installLocalRecognitionFake();
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
const worker = FakeWorker.instances[0];
worker.respond({ type: "loaded" });
// An uncaught error rather than a posted one: the worker's own try/catch
// converts most failures, so this is the channel it cannot cover.
worker.onerror?.(new ErrorEvent("error"));
grantMicrophone?.(stream);
await sessionPromise;
expect(tracks.every((track) => track.stopped)).toBe(true);
expect(worker.terminated).toBe(true);
expect(started()).toBe(true);
});
it("ignores a transcript that arrives after stop", async () => {
const { stream } = makeMediaStream();
stubWasmSupport(vi.fn().mockResolvedValue(stream));
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const onTranscript = vi.fn();
const sessionPromise = startDictation({ onTranscript }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
const worker = FakeWorker.instances[0];
worker.respond({ type: "loaded" });
const session = await sessionPromise;
worker.respond({ type: "transcript", text: "hello world" });
expect(onTranscript).toHaveBeenCalledWith("hello world");
const stopping = session.stop();
// Both engines emit one last result after being told to stop. The caller
// has already forgotten what it appended, so this would be appended twice.
worker.respond({ type: "transcript", text: "hello world" });
worker.respond({ type: "stopped" });
await stopping;
expect(onTranscript).toHaveBeenCalledTimes(1);
});
it("waits for the worker to drain before terminating it", async () => {
const { stream } = makeMediaStream();
stubWasmSupport(vi.fn().mockResolvedValue(stream));
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
const worker = FakeWorker.instances[0];
worker.respond({ type: "loaded" });
const session = await sessionPromise;
const stopping = session.stop();
await Promise.resolve();
// The transcriber runs synchronously inside the worker's `onmessage`, so a
// backlog can leave it seconds behind; terminating now drops the tail.
expect(worker.terminated).toBe(false);
worker.respond({ type: "stopped" });
await stopping;
expect(worker.terminated).toBe(true);
});
it("reports a worker failure that happens after the engine loaded", async () => {
const { stream } = makeMediaStream();
stubWasmSupport(vi.fn().mockResolvedValue(stream));
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const onError = vi.fn();
const sessionPromise = startDictation(
{
onTranscript: vi.fn(),
onError,
},
true,
);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
const worker = FakeWorker.instances[0];
worker.respond({ type: "loaded" });
await sessionPromise;
// The load promise has settled, so rejecting it again is a no-op: without
// a live channel the UI listens forever with the microphone open.
worker.respond({ type: "error", message: "the transcriber died" });
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0]).toMatchObject({ kind: "engine" });
});
it("never opens the microphone when the model fails to load", async () => {
const { tracks, stream } = makeMediaStream();
const getUserMedia = vi.fn().mockResolvedValue(stream);
stubWasmSupport(getUserMedia);
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
const sessionPromise = startDictation({ onTranscript: vi.fn() }, true);
await vi.waitFor(() => expect(FakeWorker.instances.length).toBe(1));
const worker = FakeWorker.instances[0];
worker.respond({ type: "error", message: "model download failed" });
await expect(sessionPromise).rejects.toBeInstanceOf(DictationError);
await expect(
sessionPromise.catch((error: DictationError) => error.kind),
).resolves.toBe("engine");
// The load runs first, so a failed download never lights the recording
// indicator at all.
expect(getUserMedia).not.toHaveBeenCalled();
expect(tracks.every((track) => track.stopped)).toBe(false);
expect(worker.terminated).toBe(true);
});
});
describe("startDictation with the local model preference off", () => {
let recognitionStarted = false;
function installRecognitionFake(
startImpl: () => void = () => {
recognitionStarted = true;
},
) {
class FakeRecognition {
continuous = false;
interimResults = false;
lang = "";
onresult: unknown = null;
onerror: unknown = null;
onend: unknown = null;
start = startImpl;
stop = () => {};
}
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
FakeRecognition;
}
beforeEach(() => {
recognitionStarted = false;
});
it("never constructs the model worker on a fully wasm-capable browser", async () => {
// The whole point of the setting: with it off, the ~51 MB model must
// not be downloaded even where it could run.
const { stream } = makeMediaStream();
stubWasmSupport(vi.fn().mockResolvedValue(stream));
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
installRecognitionFake();
const session = await startDictation({ onTranscript: vi.fn() }, false);
expect(FakeWorker.instances.length).toBe(0);
expect(recognitionStarted).toBe(true);
await session.stop();
});
it("does not fall back to the wasm engine when the recognizer fails", async () => {
// A reverse fallback would start the ~51 MB download right after the
// user turned that model off; the failure must surface instead.
stubWasmSupport(vi.fn());
setWorkerFactory(() => new FakeWorker() as unknown as Worker);
installRecognitionFake(() => {
throw new Error("recognizer refused to start");
});
await expect(
startDictation({ onTranscript: vi.fn() }, false),
).rejects.toMatchObject({ kind: "unavailable" });
expect(FakeWorker.instances.length).toBe(0);
});
});
describe("startDictation with the web speech fallback", () => {
interface RecognitionFake {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult:
| ((event: {
resultIndex: number;
results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>;
}) => void)
| null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
stopped: boolean;
}
let instance: RecognitionFake | undefined;
function installRecognitionFake(startImpl: () => void = () => {}) {
class FakeRecognition {
continuous = false;
interimResults = false;
lang = "";
onresult: RecognitionFake["onresult"] = null;
onerror: RecognitionFake["onerror"] = null;
onend: (() => void) | null = null;
stopped = false;
start: () => void;
stop = () => {
this.stopped = true;
};
constructor() {
this.start = startImpl;
instance = this as unknown as RecognitionFake;
}
}
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
FakeRecognition;
}
it("transcribes interim results and stops on request", async () => {
installRecognitionFake();
const transcripts: string[] = [];
const session = await startDictation(
{
onTranscript: (text) => transcripts.push(text),
},
true,
);
expect(instance?.continuous).toBe(true);
expect(instance?.interimResults).toBe(true);
instance?.onresult?.({
resultIndex: 0,
results: [{ isFinal: false, 0: { transcript: "search for" } }],
});
instance?.onresult?.({
resultIndex: 0,
results: [
{ isFinal: true, 0: { transcript: "search for" } },
{ isFinal: false, 0: { transcript: " moonshine" } },
],
});
expect(transcripts).toEqual(["search for", "search for moonshine"]);
await session.stop();
expect(instance?.stopped).toBe(true);
});
it("reports a permission error raised before the session resolves", async () => {
installRecognitionFake(() => {
instance?.onerror?.({ error: "not-allowed" });
});
await expect(
startDictation({ onTranscript: vi.fn() }, true),
).rejects.toMatchObject({ kind: "permission" });
});
it("reports a denial that arrives after the session resolved", async () => {
// What a real browser does: `start()` returns, and the denial arrives on a
// later task. Rejecting the already-resolved promise would be a no-op, so
// without the `onError` route the user is told nothing at all.
installRecognitionFake();
const onError = vi.fn();
const session = await startDictation(
{
onTranscript: vi.fn(),
onError,
},
true,
);
instance?.onerror?.({ error: "not-allowed" });
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0]).toMatchObject({ kind: "permission" });
await session.stop();
});
it("ends the session quietly when the recognizer stops on its own", async () => {
installRecognitionFake();
const onError = vi.fn();
const onEnd = vi.fn();
const session = await startDictation(
{
onTranscript: vi.fn(),
onEnd,
onError,
},
true,
);
// Chrome ends after silence even with `continuous`. The session is over,
// but there is nothing the user needs told about.
instance?.onend?.();
expect(onEnd).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
await session.stop();
});
it("says nothing when the recognizer ends because it was stopped", async () => {
installRecognitionFake();
const onError = vi.fn();
const onEnd = vi.fn();
const session = await startDictation(
{
onTranscript: vi.fn(),
onEnd,
onError,
},
true,
);
await session.stop();
instance?.onend?.();
expect(onError).not.toHaveBeenCalled();
expect(onEnd).not.toHaveBeenCalled();
});
});
describe("startDictation without any engine", () => {
it("rejects with an unavailable error", async () => {
await expect(
startDictation({ onTranscript: vi.fn() }, true),
).rejects.toMatchObject({ kind: "unavailable" });
});
});