138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
/**
|
|
* 语音输入: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<SpeechRecognitionInstance> {
|
|
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<Result<true>> {
|
|
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<Result<MediaRecording>> {
|
|
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 = [];
|
|
}
|
|
}
|