/** * 语音输入:Web Speech API + MediaRecorder * * 原始 Dart: `speech_to_text` 包(`lib/ui/chat/widgets/voice_input_button.dart`)。 * * 浏览器方案: * - 首选 Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`):实时转写。 * - 不支持时回退到 MediaRecorder(仅录音,不转写)。 * * 注:Web Speech API 在 Chrome / Edge 完整支持;Safari / Firefox 仅录音。 */ import { Result } from "@/utils"; export interface SpeechRecognitionResultEvent { transcript: string; isFinal: boolean; } export interface SpeechRecognitionInstance { start(): void; stop(): void; abort(): void; lang: string; continuous: boolean; interimResults: boolean; onresult: ((event: { results: SpeechRecognitionResultEvent[][] }) => void) | null; onerror: ((event: { error: string }) => void) | null; onend: (() => void) | null; onstart?: (() => void) | null; } interface SpeechRecognitionConstructor { new (): SpeechRecognitionInstance; } declare global { interface Window { SpeechRecognition?: SpeechRecognitionConstructor; webkitSpeechRecognition?: SpeechRecognitionConstructor; } } /** 是否支持实时语音识别 */ export function isSpeechRecognitionSupported(): boolean { if (typeof window === "undefined") return false; return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition); } /** 启动语音识别,返回 `SpeechRecognitionInstance` 由调用方管理生命周期。 */ export function createSpeechRecognition(): Result { if (typeof window === "undefined") { return Result.err(new Error("Speech recognition not available on server")); } const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition; if (!Ctor) { return Result.err(new Error("Speech recognition not supported")); } const recognition = new Ctor(); recognition.interimResults = true; recognition.continuous = false; recognition.lang = navigator?.language ?? "en-US"; return Result.ok(recognition); } /* ============================================================ * MediaRecorder 录音(语音消息 / 降级方案) * ============================================================ */ export interface MediaRecording { blob: Blob; url: string; durationMs: number; } export class MediaRecorderWrapper { private recorder: MediaRecorder | null = null; private chunks: BlobPart[] = []; private startedAt = 0; static isSupported(): boolean { return ( typeof window !== "undefined" && typeof window.MediaRecorder !== "undefined" && !!navigator.mediaDevices?.getUserMedia ); } async start(): Promise> { if (!MediaRecorderWrapper.isSupported()) { return Result.err(new Error("MediaRecorder not supported")); } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mimeType = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : ""; this.recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); this.chunks = []; this.startedAt = Date.now(); this.recorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); }; this.recorder.start(); return Result.ok(true); } catch (e) { return Result.err(e); } } stop(): Promise> { return new Promise((resolve) => { if (!this.recorder) { resolve(Result.err(new Error("Recorder not started"))); return; } this.recorder.onstop = () => { const durationMs = Date.now() - this.startedAt; const blob = new Blob(this.chunks, { type: this.recorder?.mimeType ?? "audio/webm" }); const url = URL.createObjectURL(blob); this.cleanup(); resolve(Result.ok({ blob, url, durationMs })); }; this.recorder.stop(); }); } abort(): void { this.recorder?.stop(); this.cleanup(); } private cleanup(): void { this.recorder?.stream.getTracks().forEach((t) => t.stop()); this.recorder = null; this.chunks = []; } }