codexmobile-relay / client /src /components /useVoiceInputRecorder.js
Codex
deploy: CodexMobile Relay
90f0300
Raw
History Blame Contribute Delete
5.89 kB
import { useEffect, useRef, useState } from 'react';
import { apiFetch } from '../api.js';
import {
VOICE_MAX_RECORDING_MS,
VOICE_MAX_UPLOAD_BYTES,
VOICE_MIME_CANDIDATES
} from '../app-core-utils.js';
export function useVoiceInputRecorder({ onVoiceSubmit, onRateLimit, onBeforeStart }) {
const mediaRecorderRef = useRef(null);
const voiceChunksRef = useRef([]);
const voiceStreamRef = useRef(null);
const voiceTimerRef = useRef(null);
const voiceErrorTimerRef = useRef(null);
const [voiceState, setVoiceState] = useState('idle');
const [voiceError, setVoiceError] = useState('');
const voiceRecording = voiceState === 'recording';
const voiceTranscribing = voiceState === 'transcribing';
const voiceSending = voiceState === 'sending';
useEffect(() => () => {
clearVoiceTimer();
clearVoiceErrorTimer();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.onstop = null;
mediaRecorderRef.current.stop();
}
stopVoiceStream();
}, []);
function setVoiceErrorBriefly(message) {
clearVoiceErrorTimer();
setVoiceError(message);
voiceErrorTimerRef.current = window.setTimeout(() => {
setVoiceError('');
voiceErrorTimerRef.current = null;
}, 2600);
}
function clearVoiceErrorTimer() {
if (voiceErrorTimerRef.current) {
window.clearTimeout(voiceErrorTimerRef.current);
voiceErrorTimerRef.current = null;
}
}
function clearVoiceTimer() {
if (voiceTimerRef.current) {
window.clearTimeout(voiceTimerRef.current);
voiceTimerRef.current = null;
}
}
function stopVoiceStream() {
voiceStreamRef.current?.getTracks?.().forEach((track) => track.stop());
voiceStreamRef.current = null;
}
function voiceMimeType() {
if (!window.MediaRecorder?.isTypeSupported) {
return '';
}
return VOICE_MIME_CANDIDATES.find((type) => window.MediaRecorder.isTypeSupported(type)) || '';
}
async function transcribeVoiceBlob(blob) {
if (!blob?.size) {
setVoiceErrorBriefly('没有录到声音');
return '';
}
if (blob.size > VOICE_MAX_UPLOAD_BYTES) {
setVoiceErrorBriefly('录音超过 10MB');
return '';
}
const formData = new FormData();
const extension = blob.type.includes('mp4') ? 'm4a' : 'webm';
formData.append('audio', blob, `voice.${extension}`);
try {
const result = await apiFetch('/api/voice/transcribe', {
method: 'POST',
body: formData
});
if (!result.text?.trim()) {
setVoiceErrorBriefly('没有识别到文字');
return '';
}
return result.text.trim();
} catch (error) {
onRateLimit?.(error, 'voice');
setVoiceErrorBriefly(error.message || '语音转写失败');
return '';
}
}
async function startVoiceRecording() {
onBeforeStart?.();
clearVoiceErrorTimer();
setVoiceError('');
if (window.location.protocol !== 'https:') {
setVoiceErrorBriefly('请使用 HTTPS 地址或 iOS 键盘听写');
return;
}
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
setVoiceErrorBriefly('当前浏览器不支持录音');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = voiceMimeType();
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
voiceStreamRef.current = stream;
voiceChunksRef.current = [];
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event) => {
if (event.data?.size) {
voiceChunksRef.current.push(event.data);
}
};
recorder.onerror = () => {
clearVoiceTimer();
stopVoiceStream();
setVoiceState('idle');
setVoiceErrorBriefly('录音失败');
};
recorder.onstop = async () => {
clearVoiceTimer();
stopVoiceStream();
const recordedType = recorder.mimeType || mimeType || 'audio/webm';
const blob = new Blob(voiceChunksRef.current, { type: recordedType });
voiceChunksRef.current = [];
mediaRecorderRef.current = null;
try {
setVoiceState('transcribing');
const transcript = await transcribeVoiceBlob(blob);
if (transcript) {
setVoiceState('sending');
await onVoiceSubmit(transcript);
}
} catch (error) {
setVoiceErrorBriefly(error.message || '语音发送失败');
} finally {
setVoiceState('idle');
}
};
recorder.start();
setVoiceState('recording');
voiceTimerRef.current = window.setTimeout(() => {
if (mediaRecorderRef.current?.state === 'recording') {
setVoiceState('transcribing');
mediaRecorderRef.current.stop();
}
}, VOICE_MAX_RECORDING_MS);
} catch (error) {
clearVoiceTimer();
stopVoiceStream();
mediaRecorderRef.current = null;
setVoiceState('idle');
const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
setVoiceErrorBriefly(denied ? '麦克风权限被拒绝' : '录音启动失败');
}
}
function stopVoiceRecording() {
if (mediaRecorderRef.current?.state === 'recording') {
clearVoiceErrorTimer();
setVoiceError('');
setVoiceState('transcribing');
mediaRecorderRef.current.stop();
return;
}
clearVoiceTimer();
stopVoiceStream();
setVoiceState('idle');
}
function toggleVoiceInput() {
if (voiceRecording) {
stopVoiceRecording();
} else if (!voiceTranscribing && !voiceSending) {
startVoiceRecording();
}
}
return {
voiceState,
voiceError,
voiceRecording,
voiceTranscribing,
voiceSending,
toggleVoiceInput
};
}