import { afterEach, describe, expect, it, vi } from "vitest"; import { Dictation, MAX_TRANSCRIPTION_RESPONSE_BYTES, TRANSCRIPTION_RESPONSE_TIMEOUT_MS, } from "./dictation.js"; afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers(); }); function stubRecorderFallback(fetchMock: ReturnType) { const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); vi.stubGlobal("fetch", fetchMock); vi.stubGlobal( "MediaRecorder", class { state = "inactive"; ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) }); this.onstop?.(); } }, ); } describe("Dictation recorder fallback", () => { it("stops tracks if hold-to-talk is cancelled while the mic prompt is open", async () => { const track = { stop: vi.fn() }; let grant!: (stream: { getTracks: () => Array<{ stop: () => void }> }) => void; const pending = new Promise<{ getTracks: () => Array<{ stop: () => void }> }>((resolve) => { grant = resolve; }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(() => pending) }, language: "en-US", }); const started = vi.fn(); vi.stubGlobal( "MediaRecorder", class { state = "inactive"; start() { started(); this.state = "recording"; } stop() { this.state = "inactive"; } }, ); const dictation = new Dictation(); const listening = dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined, }); dictation.stop("cancel"); grant({ getTracks: () => [track] }); await listening; expect(track.stop).toHaveBeenCalledOnce(); expect(started).not.toHaveBeenCalled(); expect(dictation.state.status).toBe("idle"); }); it("does not deliver a stale transcript to a newer session", async () => { const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); let transcribe!: (body: { text: string }) => void; const fetchMock = vi.fn( (_url: string, init?: { signal?: AbortSignal }) => new Promise((resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(Object.assign(new Error("Aborted"), { name: "AbortError" })), ); transcribe = (body) => resolve(Response.json(body)); }), ); vi.stubGlobal("fetch", fetchMock); class FakeRecorder { state = "inactive"; ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) }); this.onstop?.(); } } vi.stubGlobal("MediaRecorder", FakeRecorder); const first = vi.fn(); const second = vi.fn(); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal: first }); dictation.submitHold(); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); await dictation.listen({ mode: "hold", transcribe: true, onFinal: second }); transcribe({ text: "stale take" }); await Promise.resolve(); await Promise.resolve(); expect(first).not.toHaveBeenCalled(); expect(second).not.toHaveBeenCalled(); expect(dictation.state.status).toBe("listening"); }); it("keeps transcription in the space where recording started", async () => { const store = new Map([["rakazo:space-id", "space-support"]]); const localStorage = { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { store.set(key, value); }, removeItem: (key: string) => { store.delete(key); }, }; vi.stubGlobal("window", { localStorage }); vi.stubGlobal("localStorage", localStorage); const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => Response.json({ text: "hello" }), ); vi.stubGlobal("fetch", fetchMock); class FakeRecorder { state = "inactive"; ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) }); this.onstop?.(); } } vi.stubGlobal("MediaRecorder", FakeRecorder); const onFinal = vi.fn(); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal }); store.set("rakazo:space-id", "space-other"); dictation.submitHold(); await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("hello")); expect(fetchMock).toHaveBeenCalledWith( "/api/voice/transcribe", expect.objectContaining({ credentials: "include" }), ); const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); expect(headers.get("x-rakazo-space-id")).toBe("space-support"); expect(headers.get("content-type")).toBe("application/json"); }); it("rejects oversized transcription responses without waiting for cancellation", async () => { const cancel = vi.fn(() => new Promise(() => undefined)); const fetchMock = vi.fn( async () => new Response(new ReadableStream({ cancel }), { headers: { "content-length": String(MAX_TRANSCRIPTION_RESPONSE_BYTES + 1) }, }), ); stubRecorderFallback(fetchMock); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined }); dictation.submitHold(); await vi.waitFor(() => expect(dictation.state.error).toBe("Transcription response is too large."), ); expect(cancel).toHaveBeenCalledOnce(); }); it("times out while a transcription response body is stalled", async () => { vi.useFakeTimers(); const fetchMock = vi.fn( async () => new Response( new ReadableStream({ pull: () => new Promise(() => undefined), }), ), ); stubRecorderFallback(fetchMock); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined }); dictation.submitHold(); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); await vi.advanceTimersByTimeAsync(TRANSCRIPTION_RESPONSE_TIMEOUT_MS); expect(dictation.state.error).toBe("Transcription request timed out."); }); it("surfaces timeout when fetch rejects with AbortError", async () => { // Browsers reject aborted fetch with DOMException AbortError. Matching token // means the deadline fired (stop() bumps token first), so this must not stay // stuck on "transcribing". const fetchMock = vi.fn(async () => { throw new DOMException("The operation was aborted.", "AbortError"); }); stubRecorderFallback(fetchMock); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined }); dictation.submitHold(); await vi.waitFor(() => expect(dictation.state.error).toBe("Transcription request timed out.")); expect(dictation.state.status).toBe("idle"); }); it("stays silent when stop cancels an in-flight transcription", async () => { const fetchMock = vi.fn( (_url: string, init?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { init?.signal?.addEventListener( "abort", () => { reject(new DOMException("The operation was aborted.", "AbortError")); }, { once: true }, ); }), ); stubRecorderFallback(fetchMock); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined }); dictation.submitHold(); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); expect(dictation.state.status).toBe("transcribing"); dictation.stop("cancel"); await Promise.resolve(); await Promise.resolve(); expect(dictation.state.status).toBe("idle"); expect(dictation.state.error).toBeUndefined(); }); it("ignores leftover audio from a replaced recorder", async () => { const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); const fetchMock = vi.fn(async (_url: string, _init?: { body?: string }) => Response.json({ text: "ok" }), ); vi.stubGlobal("fetch", fetchMock); const recorders: Array<{ ondataavailable: ((event: { data: Blob }) => void) | null; onstop: (() => void) | null; stop: () => void; }> = []; class FakeRecorder { state = "inactive"; ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; constructor() { recorders.push(this); } start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: new Blob(["new"], { type: "audio/webm" }) }); this.onstop?.(); } } vi.stubGlobal("MediaRecorder", FakeRecorder); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined }); await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined }); recorders[0]?.ondataavailable?.({ data: new Blob(["STALE-AUDIO-DATA"], { type: "audio/webm" }), }); dictation.submitHold(); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "")) as { audioBase64: string; }; const decoded = atob(body.audioBase64); expect(decoded).toContain("new"); expect(decoded).not.toContain("STALE"); }); it("fails visibly when endpoint dictation has no silence detector", async () => { const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); const started = vi.fn(); vi.stubGlobal( "MediaRecorder", class { start() { started(); } }, ); const dictation = new Dictation(); await dictation.listen({ mode: "endpoint", transcribe: true, onFinal: () => undefined, }); expect(started).not.toHaveBeenCalled(); expect(track.stop).toHaveBeenCalledOnce(); expect(dictation.state.error).toMatch(/stop talking/i); }); it("stops an endpoint recording after silence", async () => { vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); const level = { current: 0.6 }; class FakeAnalyser { fftSize = 2048; getByteTimeDomainData(data: Uint8Array) { data.fill(Math.round(128 + level.current * 127)); } } class FakeContext { state = "running"; createMediaStreamSource() { return { connect() {} }; } createAnalyser() { return new FakeAnalyser(); } close() { return Promise.resolve(); } resume() { return Promise.resolve(); } } vi.stubGlobal("AudioContext", FakeContext); const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); const fetchMock = vi.fn(async () => Response.json({ text: "hello" })); vi.stubGlobal("fetch", fetchMock); const onFinal = vi.fn(); class FakeRecorder { state = "inactive"; ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) }); this.onstop?.(); } } vi.stubGlobal("MediaRecorder", FakeRecorder); const dictation = new Dictation(); await dictation.listen({ mode: "endpoint", transcribe: true, endpointMs: 240, onFinal, }); expect(dictation.state.status).toBe("listening"); await vi.advanceTimersByTimeAsync(80); level.current = 0; await vi.advanceTimersByTimeAsync(240); await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("hello")); vi.useRealTimers(); }); it("does not let a replaced recorder tear down the new silence detector", async () => { vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); const level = { current: 0.6 }; class FakeAnalyser { fftSize = 2048; getByteTimeDomainData(data: Uint8Array) { data.fill(Math.round(128 + level.current * 127)); } } class FakeContext { state = "running"; createMediaStreamSource() { return { connect() {} }; } createAnalyser() { return new FakeAnalyser(); } close() { return Promise.resolve(); } resume() { return Promise.resolve(); } } vi.stubGlobal("AudioContext", FakeContext); const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); const fetchMock = vi.fn(async () => Response.json({ text: "later" })); vi.stubGlobal("fetch", fetchMock); const recorders: Array<{ onstop: (() => void) | null; stop: () => void; }> = []; class FakeRecorder { state = "inactive"; ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; constructor() { recorders.push(this); } start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) }); this.onstop?.(); } } vi.stubGlobal("MediaRecorder", FakeRecorder); const onFinal = vi.fn(); const dictation = new Dictation(); await dictation.listen({ mode: "endpoint", transcribe: true, endpointMs: 240, onFinal, }); const staleStop = recorders[0]?.onstop; await dictation.listen({ mode: "endpoint", transcribe: true, endpointMs: 240, onFinal, }); staleStop?.(); await vi.advanceTimersByTimeAsync(80); level.current = 0; await vi.advanceTimersByTimeAsync(240); await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("later")); vi.useRealTimers(); }); }); function stubWebSpeechMic(level = { current: 0.6 }) { const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), }, language: "en-US", }); class FakeAnalyser { fftSize = 2048; getByteTimeDomainData(data: Uint8Array) { data.fill(Math.round(128 + level.current * 127)); } } class FakeContext { state = "running"; createMediaStreamSource() { return { connect() {} }; } createAnalyser() { return new FakeAnalyser(); } close() { return Promise.resolve(); } resume() { return Promise.resolve(); } } vi.stubGlobal("AudioContext", FakeContext); return { track }; } describe("Dictation web speech", () => { it("restarts endpoint recognition after a quiet end", async () => { const instances: FakeRecognition[] = []; class FakeRecognition { continuous = false; interimResults = false; lang = ""; onresult: ((event: unknown) => void) | null = null; onerror: ((event: { error?: string }) => void) | null = null; onend: (() => void) | null = null; start = vi.fn(); stop = vi.fn(); abort = vi.fn(); constructor() { instances.push(this); } } vi.stubGlobal("window", { SpeechRecognition: FakeRecognition }); stubWebSpeechMic(); const dictation = new Dictation(); await dictation.listen({ mode: "endpoint", onFinal: () => undefined }); const rec = instances[0]; expect(rec?.start).toHaveBeenCalledOnce(); rec?.onend?.(); expect(rec?.start).toHaveBeenCalledTimes(2); expect(dictation.state.status).toBe("listening"); }); it("requests microphone access before starting web speech", async () => { class FakeRecognition { start = vi.fn(); stop = vi.fn(); abort = vi.fn(); } vi.stubGlobal("window", { SpeechRecognition: FakeRecognition }); const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop: vi.fn() }] })); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia }, language: "en-US", }); vi.stubGlobal( "AudioContext", class { state = "running"; createMediaStreamSource() { return { connect() {} }; } createAnalyser() { return { fftSize: 2048, getByteTimeDomainData: (data: Uint8Array) => data.fill(128) }; } close() { return Promise.resolve(); } resume() { return Promise.resolve(); } }, ); const dictation = new Dictation(); await dictation.listen({ mode: "endpoint", onFinal: () => undefined }); expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); expect(dictation.state.status).toBe("listening"); }); it("drives waveform energy from the mic analyser, not transcript length", async () => { vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); const level = { current: 0.75 }; const instances: FakeRecognition[] = []; class FakeRecognition { continuous = false; interimResults = false; lang = ""; onresult: ((event: unknown) => void) | null = null; onerror: ((event: { error?: string }) => void) | null = null; onend: (() => void) | null = null; start = vi.fn(); stop = vi.fn(); abort = vi.fn(); constructor() { instances.push(this); } } vi.stubGlobal("window", { SpeechRecognition: FakeRecognition }); stubWebSpeechMic(level); const dictation = new Dictation(); await dictation.listen({ mode: "endpoint", onFinal: () => undefined }); await vi.advanceTimersByTimeAsync(80); expect(dictation.state.energy).toBeGreaterThan(0.4); instances[0]?.onresult?.({ resultIndex: 0, results: [{ 0: { transcript: "hi" }, isFinal: false }], }); expect(dictation.state.transcript).toBe("hi"); expect(dictation.state.energy).toBeGreaterThan(0.4); vi.useRealTimers(); }); it("surfaces a clear error when microphone access is denied", async () => { class FakeRecognition { start = vi.fn(); } vi.stubGlobal("window", { SpeechRecognition: FakeRecognition }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn(async () => { throw new DOMException("Permission denied", "NotAllowedError"); }), }, language: "en-US", }); const dictation = new Dictation(); await dictation.listen({ mode: "endpoint", onFinal: () => undefined }); expect(dictation.state.status).toBe("idle"); expect(dictation.state.error).toMatch(/Microphone access denied/i); }); });