From 4fd2ab76508367bba254b5dda307fe36f0538264 Mon Sep 17 00:00:00 2001 From: zhifu gao <18321252+LauraGPT@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:36:53 +0000 Subject: [PATCH] feat: add configurable voice transcription Signed-off-by: zhifu gao <18321252+LauraGPT@users.noreply.github.com> --- app/components/chat.tsx | 85 ++++++- app/components/settings.tsx | 22 ++ app/components/transcription-config.tsx | 78 ++++++ app/locales/cn.ts | 19 ++ app/locales/en.ts | 19 ++ app/store/access.ts | 3 + app/store/config.ts | 7 + app/utils/transcription.ts | 186 ++++++++++++++ test/transcription-access.test.ts | 12 + test/transcription-config-component.test.tsx | 74 ++++++ test/transcription-config.test.ts | 22 ++ test/transcription.test.ts | 241 +++++++++++++++++++ 12 files changed, 767 insertions(+), 1 deletion(-) create mode 100644 app/components/transcription-config.tsx create mode 100644 app/utils/transcription.ts create mode 100644 test/transcription-access.test.ts create mode 100644 test/transcription-config-component.test.tsx create mode 100644 test/transcription-config.test.ts create mode 100644 test/transcription.test.ts diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 6691403e65b..a954497556c 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -48,6 +48,8 @@ import PluginIcon from "../icons/plugin.svg"; import ShortcutkeyIcon from "../icons/shortcutkey.svg"; import McpToolIcon from "../icons/tool.svg"; import HeadphoneIcon from "../icons/headphone.svg"; +import VoiceIcon from "../icons/voice.svg"; +import VoiceOffIcon from "../icons/voice-off.svg"; import { BOT_HELLO, ChatMessage, @@ -118,6 +120,12 @@ import { getClientConfig } from "../config/client"; import { useAllModels } from "../utils/hooks"; import { ClientApi, MultimodalContent } from "../client/api"; import { createTTSPlayer } from "../utils/audio"; +import { + ActiveAudioRecording, + mergeTranscriptionInput, + startAudioRecording, + transcribeAudio, +} from "../utils/transcription"; import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts"; import { isEmpty } from "lodash-es"; @@ -501,7 +509,7 @@ export function ChatActions(props: { hitBottom: boolean; uploading: boolean; setShowShortcutKeyModal: React.Dispatch>; - setUserInput: (input: string) => void; + setUserInput: React.Dispatch>; setShowChatSidePanel: React.Dispatch>; }) { const config = useAppConfig(); @@ -509,6 +517,60 @@ export function ChatActions(props: { const chatStore = useChatStore(); const pluginStore = usePluginStore(); const session = chatStore.currentSession(); + const accessStore = useAccessStore(); + const recordingRef = useRef(null); + const [transcriptionStatus, setTranscriptionStatus] = useState< + "idle" | "recording" | "transcribing" + >("idle"); + + useEffect(() => { + return () => { + const recording = recordingRef.current; + recordingRef.current = null; + if (recording) { + void recording.stop().catch(() => undefined); + } + }; + }, []); + + async function toggleTranscription() { + if (transcriptionStatus === "transcribing") return; + + try { + if (recordingRef.current) { + const recording = recordingRef.current; + recordingRef.current = null; + setTranscriptionStatus("transcribing"); + + const audio = await recording.stop(); + const transcription = await transcribeAudio( + audio, + { + ...config.transcriptionConfig, + apiKey: accessStore.transcriptionApiKey, + }, + undefined, + { + direct: !!getClientConfig()?.isApp, + }, + ); + props.setUserInput((current) => + mergeTranscriptionInput(current, transcription), + ); + setTranscriptionStatus("idle"); + return; + } + + recordingRef.current = await startAudioRecording(); + setTranscriptionStatus("recording"); + } catch (error) { + recordingRef.current = null; + setTranscriptionStatus("idle"); + showToast( + error instanceof Error ? error.message : "Voice transcription failed", + ); + } + } // switch themes const theme = config.theme; @@ -835,6 +897,27 @@ export function ChatActions(props: { {!isMobileScreen && }
+ {config.transcriptionConfig.enable && ( + void toggleTranscription()} + text={ + transcriptionStatus === "recording" + ? "Stop recording" + : transcriptionStatus === "transcribing" + ? "Transcribing" + : "Voice input" + } + icon={ + transcriptionStatus === "transcribing" ? ( + + ) : transcriptionStatus === "recording" ? ( + + ) : ( + + ) + } + /> + )} {config.realtimeConfig.enable && ( props.setShowChatSidePanel(true)} diff --git a/app/components/settings.tsx b/app/components/settings.tsx index 881c12caeb3..893bf88b532 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -89,6 +89,7 @@ import { useMaskStore } from "../store/mask"; import { ProviderType } from "../utils/cloud"; import { TTSConfigList } from "./tts-config"; import { RealtimeConfigList } from "./realtime-chat/realtime-config"; +import { TranscriptionConfigList } from "./transcription-config"; function EditPromptModal(props: { id: string; onClose: () => void }) { const promptStore = usePromptStore(); @@ -1952,6 +1953,27 @@ export function Settings() { /> + + { + accessStore.update( + (access) => (access.transcriptionApiKey = apiKey), + ); + }} + updateConfig={(updater) => { + const transcriptionConfig = { + ...config.transcriptionConfig, + }; + updater(transcriptionConfig); + config.update( + (config) => (config.transcriptionConfig = transcriptionConfig), + ); + }} + /> + +
diff --git a/app/components/transcription-config.tsx b/app/components/transcription-config.tsx new file mode 100644 index 00000000000..c5feff303df --- /dev/null +++ b/app/components/transcription-config.tsx @@ -0,0 +1,78 @@ +import type { TranscriptionConfig } from "../store/config"; +import Locale from "../locales"; +import { ListItem } from "./ui-lib"; + +export function TranscriptionConfigList(props: { + transcriptionConfig: TranscriptionConfig; + apiKey: string; + updateConfig: (updater: (config: TranscriptionConfig) => void) => void; + updateApiKey: (apiKey: string) => void; +}) { + const config = props.transcriptionConfig; + + return ( + <> + + + props.updateConfig( + (value) => (value.enable = event.currentTarget.checked), + ) + } + /> + + {config.enable && ( + <> + + + props.updateConfig( + (value) => (value.baseUrl = event.currentTarget.value), + ) + } + /> + + + + props.updateConfig( + (value) => (value.model = event.currentTarget.value), + ) + } + /> + + + + props.updateApiKey(event.currentTarget.value) + } + /> + + + )} + + ); +} diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 2cb7dd1e535..e4b6c44c9da 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -596,6 +596,25 @@ const cn = { SubTitle: "生成语音的速度", }, }, + Transcription: { + Enable: { + Title: "启用语音转写", + SubTitle: "使用 OpenAI 兼容的语音转文字接口", + }, + Endpoint: { + Title: "转写接口", + SubTitle: "/v1/audio/transcriptions", + Placeholder: "http://localhost:8000/v1", + }, + Model: { + Title: "转写模型", + Placeholder: "sensevoice", + }, + ApiKey: { + Title: "转写 API 密钥", + SubTitle: "可选", + }, + }, Realtime: { Enable: { Title: "实时聊天", diff --git a/app/locales/en.ts b/app/locales/en.ts index a6d1919045c..b7fcf78609b 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -604,6 +604,25 @@ const en: LocaleType = { }, Engine: "TTS Engine", }, + Transcription: { + Enable: { + Title: "Enable voice transcription", + SubTitle: "Use an OpenAI-compatible speech-to-text endpoint", + }, + Endpoint: { + Title: "Transcription endpoint", + SubTitle: "/v1/audio/transcriptions", + Placeholder: "http://localhost:8000/v1", + }, + Model: { + Title: "Transcription model", + Placeholder: "sensevoice", + }, + ApiKey: { + Title: "Transcription API key", + SubTitle: "Optional", + }, + }, Realtime: { Enable: { Title: "Realtime Chat", diff --git a/app/store/access.ts b/app/store/access.ts index fd55fbdd3d1..633b0eb527d 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -149,6 +149,9 @@ const DEFAULT_ACCESS_STATE = { defaultModel: "", visionModels: "", + // speech-to-text + transcriptionApiKey: "", + // tts config edgeTTSVoiceName: "zh-CN-YunxiNeural", }; diff --git a/app/store/config.ts b/app/store/config.ts index 45e21b02697..3c34d11911a 100644 --- a/app/store/config.ts +++ b/app/store/config.ts @@ -104,6 +104,12 @@ export const DEFAULT_CONFIG = { temperature: 0.9, voice: "alloy" as Voice, }, + + transcriptionConfig: { + enable: false, + baseUrl: "http://localhost:8000/v1", + model: "sensevoice", + }, }; export type ChatConfig = typeof DEFAULT_CONFIG; @@ -111,6 +117,7 @@ export type ChatConfig = typeof DEFAULT_CONFIG; export type ModelConfig = ChatConfig["modelConfig"]; export type TTSConfig = ChatConfig["ttsConfig"]; export type RealtimeConfig = ChatConfig["realtimeConfig"]; +export type TranscriptionConfig = ChatConfig["transcriptionConfig"]; export function limitNumber( x: number, diff --git a/app/utils/transcription.ts b/app/utils/transcription.ts new file mode 100644 index 00000000000..331910e01db --- /dev/null +++ b/app/utils/transcription.ts @@ -0,0 +1,186 @@ +export interface TranscriptionConfig { + baseUrl: string; + model: string; + apiKey?: string; +} + +interface TranscriptionRequestOptions { + direct?: boolean; +} + +type FetchFn = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +interface RecordingDependencies { + getUserMedia: (constraints: MediaStreamConstraints) => Promise; + createMediaRecorder: (stream: MediaStream) => MediaRecorder; +} + +export interface ActiveAudioRecording { + stop: () => Promise; +} + +const TRANSCRIPTION_PATH = "/audio/transcriptions"; + +export function buildTranscriptionUrl(baseUrl: string) { + const normalized = baseUrl.trim().replace(/\/+$/, ""); + if (!normalized) { + throw new Error("Transcription endpoint is required"); + } + if (normalized.endsWith(TRANSCRIPTION_PATH)) { + return normalized; + } + if (normalized.endsWith("/v1")) { + return normalized + TRANSCRIPTION_PATH; + } + return normalized + "/v1" + TRANSCRIPTION_PATH; +} + +export function mergeTranscriptionInput( + current: string, + transcription: string, +) { + const text = transcription.trim(); + if (!text) return current; + if (!current || /\s$/.test(current)) return current + text; + return `${current} ${text}`; +} + +export async function startAudioRecording( + dependencies?: Partial, +): Promise { + const getUserMedia = + dependencies?.getUserMedia ?? + ((constraints) => navigator.mediaDevices.getUserMedia(constraints)); + const createMediaRecorder = + dependencies?.createMediaRecorder ?? + ((stream) => new MediaRecorder(stream)); + + const stream = await getUserMedia({ audio: true }); + const chunks: Blob[] = []; + let released = false; + let stopping = false; + + const releaseStream = () => { + if (released) return; + released = true; + stream.getTracks().forEach((track) => track.stop()); + }; + + let recorder: MediaRecorder; + try { + recorder = createMediaRecorder(stream); + } catch (error) { + releaseStream(); + throw error; + } + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunks.push(event.data); + } + }; + + try { + recorder.start(); + } catch (error) { + releaseStream(); + throw error; + } + + return { + stop() { + if (stopping || recorder.state === "inactive") { + return Promise.reject(new Error("Audio recording is not active")); + } + stopping = true; + + return new Promise((resolve, reject) => { + recorder.onstop = () => { + releaseStream(); + const type = + recorder.mimeType || chunks.find((chunk) => chunk.type)?.type; + resolve(new Blob(chunks, { type: type || "audio/webm" })); + }; + recorder.onerror = () => { + releaseStream(); + reject(new Error("Audio recording failed")); + }; + try { + recorder.stop(); + } catch (error) { + releaseStream(); + reject(error); + } + }); + }, + }; +} + +function recordingExtension(mimeType: string) { + const mediaType = mimeType.split(";", 1)[0].toLowerCase(); + switch (mediaType) { + case "audio/mp4": + return "m4a"; + case "audio/mpeg": + return "mp3"; + case "audio/ogg": + return "ogg"; + case "audio/wav": + case "audio/x-wav": + return "wav"; + case "audio/webm": + default: + return "webm"; + } +} + +export async function transcribeAudio( + audio: Blob, + config: TranscriptionConfig, + fetchFn: FetchFn = fetch, + options: TranscriptionRequestOptions = {}, +) { + const model = config.model.trim(); + if (!model) { + throw new Error("Transcription model is required"); + } + + const form = new FormData(); + form.append("file", audio, `recording.${recordingExtension(audio.type)}`); + form.append("model", model); + + const apiKey = config.apiKey?.trim(); + const headers: Record = {}; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const transcriptionUrl = buildTranscriptionUrl(config.baseUrl); + const requestUrl = options.direct + ? transcriptionUrl + : `/api/transcription${TRANSCRIPTION_PATH}`; + if (!options.direct) { + headers["x-base-url"] = transcriptionUrl.slice( + 0, + -TRANSCRIPTION_PATH.length, + ); + } + + const response = await fetchFn(requestUrl, { + method: "POST", + headers, + body: form, + }); + if (!response.ok) { + throw new Error(`Transcription request failed (${response.status})`); + } + + const payload = await response.json(); + if (typeof payload?.text !== "string") { + throw new Error("Transcription response did not include text"); + } + return payload.text.trim(); +} diff --git a/test/transcription-access.test.ts b/test/transcription-access.test.ts new file mode 100644 index 00000000000..71fb01d2de0 --- /dev/null +++ b/test/transcription-access.test.ts @@ -0,0 +1,12 @@ +let useAccessStore: typeof import("../app/store/access").useAccessStore; + +beforeAll(async () => { + await import("../app/client/api"); + ({ useAccessStore } = await import("../app/store/access")); +}); + +describe("transcription access config", () => { + test("keeps the optional API key in the access store", () => { + expect(useAccessStore.getState().transcriptionApiKey).toBe(""); + }); +}); diff --git a/test/transcription-config-component.test.tsx b/test/transcription-config-component.test.tsx new file mode 100644 index 00000000000..a99ff71a08e --- /dev/null +++ b/test/transcription-config-component.test.tsx @@ -0,0 +1,74 @@ +import { jest } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react"; + +let TranscriptionConfigList: typeof import("../app/components/transcription-config").TranscriptionConfigList; + +beforeAll(async () => { + await import("../app/client/api"); + ({ TranscriptionConfigList } = await import( + "../app/components/transcription-config" + )); +}); + +describe("TranscriptionConfigList", () => { + test("updates the enable switch", () => { + const config = { + enable: false, + baseUrl: "http://localhost:8000/v1", + model: "sensevoice", + }; + const updateConfig = jest.fn((updater: (value: typeof config) => void) => { + updater(config); + }); + + render( + , + ); + + fireEvent.click(screen.getByLabelText("Enable voice transcription")); + expect(config.enable).toBe(true); + }); + + test("updates endpoint, model, and optional API key", () => { + const config = { + enable: true, + baseUrl: "http://localhost:8000/v1", + model: "sensevoice", + }; + const updateConfig = jest.fn((updater: (value: typeof config) => void) => { + updater(config); + }); + const updateApiKey = jest.fn(); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Transcription endpoint"), { + target: { value: "http://127.0.0.1:9000/v1" }, + }); + fireEvent.change(screen.getByLabelText("Transcription model"), { + target: { value: "fun-asr-nano" }, + }); + fireEvent.change(screen.getByLabelText("Transcription API key"), { + target: { value: "local-secret" }, + }); + + expect(config).toEqual({ + enable: true, + baseUrl: "http://127.0.0.1:9000/v1", + model: "fun-asr-nano", + }); + expect(updateApiKey).toHaveBeenCalledWith("local-secret"); + }); +}); diff --git a/test/transcription-config.test.ts b/test/transcription-config.test.ts new file mode 100644 index 00000000000..87b5ae4085f --- /dev/null +++ b/test/transcription-config.test.ts @@ -0,0 +1,22 @@ +let DEFAULT_CONFIG: { + transcriptionConfig: { + enable: boolean; + baseUrl: string; + model: string; + }; +}; + +beforeAll(async () => { + await import("../app/client/api"); + ({ DEFAULT_CONFIG } = await import("../app/store/config")); +}); + +describe("default transcription config", () => { + test("keeps voice input disabled with local FunASR-compatible defaults", () => { + expect(DEFAULT_CONFIG.transcriptionConfig).toEqual({ + enable: false, + baseUrl: "http://localhost:8000/v1", + model: "sensevoice", + }); + }); +}); diff --git a/test/transcription.test.ts b/test/transcription.test.ts new file mode 100644 index 00000000000..2984142919f --- /dev/null +++ b/test/transcription.test.ts @@ -0,0 +1,241 @@ +import { jest } from "@jest/globals"; +import { + buildTranscriptionUrl, + mergeTranscriptionInput, + startAudioRecording, + transcribeAudio, +} from "../app/utils/transcription"; + +function mockResponse(payload: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + } as Response; +} + +function mockFetch(response: Response) { + return jest.fn().mockResolvedValue(response); +} + +describe("buildTranscriptionUrl", () => { + test.each([ + ["http://localhost:8000", "http://localhost:8000/v1/audio/transcriptions"], + ["http://localhost:8000/", "http://localhost:8000/v1/audio/transcriptions"], + [ + "http://localhost:8000/v1", + "http://localhost:8000/v1/audio/transcriptions", + ], + [ + "http://localhost:8000/v1/", + "http://localhost:8000/v1/audio/transcriptions", + ], + [ + "http://localhost:8000/v1/audio/transcriptions", + "http://localhost:8000/v1/audio/transcriptions", + ], + ])("normalizes %s", (baseUrl, expected) => { + expect(buildTranscriptionUrl(baseUrl)).toBe(expected); + }); + + test("rejects a blank base URL", () => { + expect(() => buildTranscriptionUrl(" ")).toThrow( + "Transcription endpoint is required", + ); + }); +}); + +describe("transcribeAudio", () => { + test("posts through the same-origin proxy in the web app", async () => { + const fetchFn = mockFetch(mockResponse({ text: " hello from audio " })); + const audio = new Blob(["audio"], { type: "audio/webm;codecs=opus" }); + + await expect( + transcribeAudio( + audio, + { + baseUrl: "http://localhost:8000/v1/", + model: "sensevoice", + apiKey: "secret-token", + }, + fetchFn, + ), + ).resolves.toBe("hello from audio"); + + expect(fetchFn).toHaveBeenCalledTimes(1); + const [url, init] = fetchFn.mock.calls[0]; + const request = init!; + expect(url).toBe("/api/transcription/audio/transcriptions"); + expect(request.method).toBe("POST"); + expect(request.headers).toEqual({ + Authorization: "Bearer secret-token", + "x-base-url": "http://localhost:8000/v1", + }); + + const body = request.body as FormData; + expect(body.get("model")).toBe("sensevoice"); + const file = body.get("file") as File; + expect(file.name).toBe("recording.webm"); + expect(file.type).toBe("audio/webm;codecs=opus"); + }); + + test("omits authorization when the API key is blank", async () => { + const fetchFn = mockFetch(mockResponse({ text: "ok" })); + + await transcribeAudio( + new Blob(["audio"], { type: "audio/wav" }), + { baseUrl: "http://localhost:8000", model: "sensevoice", apiKey: " " }, + fetchFn, + ); + + expect(fetchFn.mock.calls[0][1]?.headers).toEqual({ + "x-base-url": "http://localhost:8000/v1", + }); + }); + + test("posts directly from the desktop app", async () => { + const fetchFn = mockFetch(mockResponse({ text: "ok" })); + + await transcribeAudio( + new Blob(["audio"], { type: "audio/wav" }), + { baseUrl: "http://localhost:8000/v1", model: "sensevoice" }, + fetchFn, + { direct: true }, + ); + + expect(fetchFn.mock.calls[0][0]).toBe( + "http://localhost:8000/v1/audio/transcriptions", + ); + expect(fetchFn.mock.calls[0][1]?.headers).toEqual({}); + }); + + test("rejects a blank model before sending audio", async () => { + const fetchFn = mockFetch(mockResponse({ text: "ok" })); + + await expect( + transcribeAudio( + new Blob(["audio"], { type: "audio/wav" }), + { baseUrl: "http://localhost:8000/v1", model: " " }, + fetchFn, + ), + ).rejects.toThrow("Transcription model is required"); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + test("reports HTTP and malformed-response failures", async () => { + const audio = new Blob(["audio"], { type: "audio/wav" }); + const config = { baseUrl: "http://localhost:8000", model: "sensevoice" }; + + await expect( + transcribeAudio(audio, config, mockFetch(mockResponse(null, 503))), + ).rejects.toThrow("Transcription request failed (503)"); + await expect( + transcribeAudio(audio, config, mockFetch(mockResponse({}))), + ).rejects.toThrow("Transcription response did not include text"); + }); +}); + +describe("mergeTranscriptionInput", () => { + test("appends transcription without discarding an existing draft", () => { + expect(mergeTranscriptionInput("", " hello ")).toBe("hello"); + expect(mergeTranscriptionInput("draft ", " hello ")).toBe("draft hello"); + expect(mergeTranscriptionInput(" draft", "hello")).toBe(" draft hello"); + expect(mergeTranscriptionInput("draft", " ")).toBe("draft"); + }); +}); + +describe("startAudioRecording", () => { + test("releases the microphone if recorder creation fails", async () => { + const stopTrack = jest.fn(); + const stream = { + getTracks: () => [{ stop: stopTrack }], + } as unknown as MediaStream; + const getUserMedia = jest + .fn<(constraints: MediaStreamConstraints) => Promise>() + .mockResolvedValue(stream); + const createMediaRecorder = jest + .fn<(stream: MediaStream) => MediaRecorder>() + .mockImplementation(() => { + throw new Error("MediaRecorder is unavailable"); + }); + + await expect( + startAudioRecording({ getUserMedia, createMediaRecorder }), + ).rejects.toThrow("MediaRecorder is unavailable"); + expect(stopTrack).toHaveBeenCalledTimes(1); + }); + + test("releases the microphone if stopping the recorder throws", async () => { + const stopTrack = jest.fn(); + const stream = { + getTracks: () => [{ stop: stopTrack }], + } as unknown as MediaStream; + const recorder = { + state: "inactive", + mimeType: "audio/webm", + ondataavailable: null, + onstop: null, + onerror: null, + start(this: { state: string }) { + this.state = "recording"; + }, + stop() { + throw new Error("Unable to stop recording"); + }, + } as unknown as MediaRecorder; + + const recording = await startAudioRecording({ + getUserMedia: async () => stream, + createMediaRecorder: () => recorder, + }); + await expect(recording.stop()).rejects.toThrow("Unable to stop recording"); + expect(stopTrack).toHaveBeenCalledTimes(1); + }); + + test("collects recorder chunks and releases the microphone on stop", async () => { + const stopTrack = jest.fn(); + const stream = { + getTracks: () => [{ stop: stopTrack }], + } as unknown as MediaStream; + const getUserMedia = jest + .fn<(constraints: MediaStreamConstraints) => Promise>() + .mockResolvedValue(stream); + + const recorder = { + mimeType: "audio/webm;codecs=opus", + state: "inactive", + ondataavailable: null as ((event: { data: Blob }) => void) | null, + onstop: null as (() => void) | null, + onerror: null as ((event: Event) => void) | null, + start: jest.fn(function (this: { state: string }) { + this.state = "recording"; + }), + stop: jest.fn(function (this: { + state: string; + ondataavailable: ((event: { data: Blob }) => void) | null; + onstop: (() => void) | null; + }) { + this.state = "inactive"; + this.ondataavailable?.({ + data: new Blob(["audio"], { type: "audio/webm;codecs=opus" }), + }); + this.onstop?.(); + }), + }; + const createMediaRecorder = jest + .fn<(stream: MediaStream) => MediaRecorder>() + .mockReturnValue(recorder as unknown as MediaRecorder); + + const recording = await startAudioRecording({ + getUserMedia, + createMediaRecorder, + }); + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); + expect(recorder.start).toHaveBeenCalledTimes(1); + + const audio = await recording.stop(); + expect(audio.size).toBe(5); + expect(audio.type).toBe("audio/webm;codecs=opus"); + expect(stopTrack).toHaveBeenCalledTimes(1); + }); +});