diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index e9784aa3..761b0da7 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -226,7 +226,20 @@ function mockVoiceRecorder(blob = new Blob(["voice"], { type: "audio/webm" })) { return { getUserMedia, stopTrack }; } -function mockVoiceAudioInput(sample = 128, state: AudioContextState = "running") { +function mockVoiceAudioInput( + sample = 128, + state: AudioContextState = "running", + decodedChannels?: Float32Array[], +) { + const decodeAudioDataMock = vi.fn(async () => { + if (!decodedChannels) throw new Error("decodeAudioData not mocked"); + return { + numberOfChannels: decodedChannels.length, + sampleRate: 16_000, + getChannelData: (channel: number) => decodedChannels[channel], + } as AudioBuffer; + }); + class FakeAudioContext { state = state; @@ -244,6 +257,7 @@ function mockVoiceAudioInput(sample = 128, state: AudioContextState = "running") } close = vi.fn(async () => undefined); + decodeAudioData = decodeAudioDataMock; resume = vi.fn(async () => undefined); } @@ -254,6 +268,7 @@ function mockVoiceAudioInput(sample = 128, state: AudioContextState = "running") vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => window.clearTimeout(id as unknown as number) ); + return { decodeAudioData: decodeAudioDataMock }; } async function waitForVoiceCapture(): Promise { @@ -262,6 +277,15 @@ async function waitForVoiceCapture(): Promise { }); } +function bytesFromDataUrl(dataUrl: string): Uint8Array { + const [, base64 = ""] = dataUrl.split(","); + return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); +} + +function ascii(bytes: Uint8Array, offset: number, length: number): string { + return String.fromCharCode(...bytes.slice(offset, offset + length)); +} + describe("ThreadComposer", () => { it("renders a readonly hero model composer when provided", () => { render( @@ -337,6 +361,47 @@ describe("ThreadComposer", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => { + mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" })); + const { decodeAudioData } = mockVoiceAudioInput( + 180, + "running", + [new Float32Array([0, 0.5, -0.5])], + ); + const onTranscribeAudio = vi.fn(async () => "mimo voice"); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Voice input" })); + expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); + await waitForVoiceCapture(); + fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); + + await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalledTimes(1)); + const [dataUrl, options] = onTranscribeAudio.mock.calls[0]; + expect(dataUrl).toMatch(/^data:audio\/wav;base64,/); + expect(options).toEqual(expect.objectContaining({ durationMs: expect.any(Number) })); + expect(decodeAudioData).toHaveBeenCalledTimes(1); + + const bytes = bytesFromDataUrl(dataUrl); + const view = new DataView(bytes.buffer); + expect(ascii(bytes, 0, 4)).toBe("RIFF"); + expect(ascii(bytes, 8, 4)).toBe("WAVE"); + expect(ascii(bytes, 12, 4)).toBe("fmt "); + expect(view.getUint16(20, true)).toBe(1); + expect(view.getUint16(22, true)).toBe(1); + expect(view.getUint32(24, true)).toBe(16_000); + expect(view.getUint16(34, true)).toBe(16); + expect(ascii(bytes, 36, 4)).toBe("data"); + await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("mimo voice")); + }); + it("does not start duplicate voice recordings while microphone access is pending", async () => { const { getUserMedia, stopTrack } = mockVoiceRecorder(); let resolveStream: ((stream: MediaStream) => void) | undefined;