feat(web): 实时对话 - 第一阶段输入行内联语音录制与浏览器 STT 转写 (#165)

Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-07 15:06:56 +08:00
committed by GitHub
parent abec135d26
commit 8652ee74c6
13 changed files with 1006 additions and 1 deletions

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from "react";
type SpeechRecognitionAlternative = { transcript: string; confidence: number };
type SpeechRecognitionResult = { isFinal: boolean; length: number; [index: number]: SpeechRecognitionAlternative };
type SpeechRecognitionResultList = { length: number; item(index: number): SpeechRecognitionResult; [index: number]: SpeechRecognitionResult };
type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
onstart: (() => void) | null;
onresult: ((event: { results: SpeechRecognitionResultList }) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
abort: () => void;
};
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
if (typeof window === "undefined") return null;
const globalWithSpeech = window as unknown as {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
};
return globalWithSpeech.SpeechRecognition || globalWithSpeech.webkitSpeechRecognition || null;
}
export type SpeechRecognitionError = {
code: string;
message: string;
};
type UseSpeechRecognitionOptions = {
/** 语言代码,默认 zh-CN */
lang?: string;
};
export type UseSpeechRecognitionReturn = {
/** 浏览器是否支持 Web Speech API */
supported: boolean;
/** 识别错误(权限、网络、不支持等) */
error: SpeechRecognitionError | null;
/** 开始识别,挂载后自动调用 */
start: () => void;
/** 停止识别并返回已累积的最终文本 */
stop: () => Promise<string>;
/** 取消识别并清空累积文本 */
cancel: () => void;
};
/**
* 浏览器内置语音识别 Hook(Web Speech API)
* 第一阶段 MVP 使用:不依赖后端、模型渠道或 API Key,零配置可用
*/
export function useSpeechRecognition(options: UseSpeechRecognitionOptions = {}): UseSpeechRecognitionReturn {
const { lang = "zh-CN" } = options;
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const finalTextRef = useRef("");
const stopResolveRef = useRef<((text: string) => void) | null>(null);
const stopTimerRef = useRef<number | null>(null);
const [supported] = useState(() => Boolean(getSpeechRecognition()));
const [error, setError] = useState<SpeechRecognitionError | null>(null);
const start = useCallback(() => {
const Constructor = getSpeechRecognition();
if (!Constructor) {
setError({ code: "unsupported", message: "当前浏览器不支持语音识别,请使用 Chrome 或 Edge" });
return;
}
finalTextRef.current = "";
const recognition = new Constructor();
recognition.lang = lang;
recognition.continuous = true;
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onstart = () => setError(null);
recognition.onresult = (event) => {
let text = finalTextRef.current;
for (let i = 0; i < event.results.length; i += 1) {
const result = event.results[i];
if (result.isFinal && result[0]?.transcript) {
text = [text, result[0].transcript.trim()].filter(Boolean).join(" ");
}
}
finalTextRef.current = text;
};
recognition.onerror = (event) => {
if (event.error === "not-allowed" || event.error === "service-not-allowed") {
setError({ code: event.error, message: "麦克风权限被拒绝,请在浏览器设置中允许访问麦克风" });
} else if (event.error === "no-speech") {
setError({ code: event.error, message: "未检测到语音,请重试" });
} else if (event.error === "network") {
setError({ code: event.error, message: "语音识别服务不可用,请检查网络后重试" });
} else {
setError({ code: event.error, message: `语音识别失败(${event.error})` });
}
};
recognition.onend = () => {
if (stopTimerRef.current !== null) {
window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
}
const resolve = stopResolveRef.current;
stopResolveRef.current = null;
resolve?.(finalTextRef.current);
};
recognitionRef.current = recognition;
recognition.start();
}, [lang]);
const stop = useCallback((): Promise<string> => {
return new Promise((resolve) => {
const recognition = recognitionRef.current;
if (!recognition) {
resolve(finalTextRef.current);
return;
}
stopResolveRef.current = resolve;
// 兜底:部分浏览器 stop() 后 onend 可能不触发,超时后返回已识别文本
stopTimerRef.current = window.setTimeout(() => {
if (stopResolveRef.current) {
stopResolveRef.current = null;
resolve(finalTextRef.current);
}
}, 1500);
try {
recognition.stop();
} catch {
if (stopTimerRef.current !== null) {
window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
}
stopResolveRef.current = null;
resolve(finalTextRef.current);
}
});
}, []);
const cancel = useCallback(() => {
if (stopTimerRef.current !== null) {
window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
}
stopResolveRef.current = null;
const recognition = recognitionRef.current;
recognitionRef.current = null;
if (recognition) {
recognition.onend = null;
try {
recognition.abort();
} catch { /* ignore */ }
}
finalTextRef.current = "";
}, []);
useEffect(() => cancel, [cancel]);
return { supported, error, start, stop, cancel };
}

View File

@@ -0,0 +1,193 @@
import { useCallback, useEffect, useRef, useState } from "react";
export type VoiceRecordingState = "idle" | "recording" | "paused";
type UseVoiceRecordingOptions = {
/** 最大录音时长(秒),默认 60 秒 */
maxDuration?: number;
};
export type UseVoiceRecordingReturn = {
state: VoiceRecordingState;
waveform: number[];
duration: number;
error: string | null;
start: () => Promise<void>;
stop: () => Promise<Blob | null>;
cancel: () => void;
reset: () => void;
};
/**
* 语音录制 Hook
* 使用 MediaRecorder API 录制音频,AnalyserNode 获取实时波形数据
*/
export function useVoiceRecording(options: UseVoiceRecordingOptions = {}): UseVoiceRecordingReturn {
const { maxDuration = 60 } = options;
const [state, setState] = useState<VoiceRecordingState>("idle");
const [waveform, setWaveform] = useState<number[]>([]);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const animationFrameRef = useRef<number | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
// 清理资源
const cleanup = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
void audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
mediaRecorderRef.current = null;
}, []);
// 更新波形数据
const updateWaveform = useCallback(() => {
const analyser = analyserRef.current;
if (!analyser) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray);
// 取 32 个频段的平均值,用于波形显示
const step = Math.floor(bufferLength / 32);
const values: number[] = [];
for (let i = 0; i < 32; i++) {
let sum = 0;
for (let j = 0; j < step; j++) {
sum += dataArray[i * step + j];
}
values.push(sum / step / 255);
}
setWaveform(values);
animationFrameRef.current = requestAnimationFrame(updateWaveform);
}, []);
// 开始录制
const start = useCallback(async () => {
setError(null);
setWaveform([]);
setDuration(0);
chunksRef.current = [];
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyserRef.current = analyser;
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.start(100);
startTimeRef.current = Date.now();
setState("recording");
// 启动波形更新
animationFrameRef.current = requestAnimationFrame(updateWaveform);
// 启动计时器
timerRef.current = window.setInterval(() => {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
if (elapsed >= maxDuration) {
// 达到最大时长,自动停止
mediaRecorder.stop();
}
}, 100);
} catch (err) {
const message = err instanceof Error ? err.message : "无法访问麦克风";
setError(message);
setState("idle");
cleanup();
}
}, [cleanup, maxDuration, updateWaveform]);
// 停止录制
const stop = useCallback((): Promise<Blob | null> => {
return new Promise((resolve) => {
const mediaRecorder = mediaRecorderRef.current;
if (!mediaRecorder || mediaRecorder.state === "inactive") {
cleanup();
setState("idle");
resolve(null);
return;
}
mediaRecorder.onstop = () => {
const blob = chunksRef.current.length > 0 ? new Blob(chunksRef.current, { type: "audio/webm" }) : null;
cleanup();
setState("idle");
resolve(blob);
};
mediaRecorder.stop();
});
}, [cleanup]);
// 取消录制
const cancel = useCallback(() => {
chunksRef.current = [];
cleanup();
setState("idle");
setWaveform([]);
setDuration(0);
}, [cleanup]);
// 重置
const reset = useCallback(() => {
cancel();
setError(null);
}, [cancel]);
// 组件卸载时清理
useEffect(() => {
return () => cleanup();
}, [cleanup]);
return {
state,
waveform,
duration,
error,
start,
stop,
cancel,
reset,
};
}