import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; const SAMPLE_RATE = 16000; const CHANNELS = 1; const BITS_PER_SAMPLE = 16; export type RecorderStatus = 'idle' | 'recording' | 'processing'; export type OnPcmDataAvailable = (pcmData: Float32Array) => void; export interface UseVoiceRecorderReturn { isRecording: boolean; status: RecorderStatus; duration: number; error: string | null; startRecord: (onPcmData?: OnPcmDataAvailable) => Promise; stopRecord: () => Promise; cancelRecord: () => void; } function createWavHeader( dataLength: number, sampleRate: number, channels: number, bitsPerSample: number, ): Uint8Array { const byteRate = sampleRate * channels * (bitsPerSample / 8); const blockAlign = channels * (bitsPerSample / 8); const dataSize = dataLength * (bitsPerSample / 8); const fileSize = 36 + dataSize; const header = new ArrayBuffer(44); const v = new DataView(header); // RIFF v.setUint8(0, 0x52); v.setUint8(1, 0x49); v.setUint8(2, 0x46); v.setUint8(3, 0x46); v.setUint32(4, fileSize, true); // WAVE v.setUint8(8, 0x57); v.setUint8(9, 0x41); v.setUint8(10, 0x56); v.setUint8(11, 0x45); // fmt v.setUint8(12, 0x66); v.setUint8(13, 0x6d); v.setUint8(14, 0x74); v.setUint8(15, 0x20); v.setUint32(16, 16, true); v.setUint16(20, 1, true); // PCM v.setUint16(22, channels, true); v.setUint32(24, sampleRate, true); v.setUint32(28, byteRate, true); v.setUint16(32, blockAlign, true); v.setUint16(34, bitsPerSample, true); // data v.setUint8(36, 0x64); v.setUint8(37, 0x61); v.setUint8(38, 0x74); v.setUint8(39, 0x61); v.setUint32(40, dataSize, true); return new Uint8Array(header); } /** * Convert raw PCM Float32Array to a WAV ArrayBuffer. */ export function convertPcmToWav( pcmData: Float32Array, sampleRate: number = SAMPLE_RATE, channels: number = CHANNELS, ): ArrayBuffer { const pcm16 = new Int16Array(pcmData.length); for (let i = 0; i < pcmData.length; i++) { const s = Math.max(-1, Math.min(1, pcmData[i])); pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } const wavHeader = createWavHeader(pcm16.length, sampleRate, channels, BITS_PER_SAMPLE); const wavBuffer = new ArrayBuffer(wavHeader.length + pcm16.length * 2); const wavView = new DataView(wavBuffer); for (let i = 0; i < wavHeader.length; i++) { wavView.setUint8(i, wavHeader[i]); } new Int16Array(wavBuffer, wavHeader.length).set(pcm16); return wavBuffer; } /** * Convert WebM/Opus blob to 16kHz mono WAV ArrayBuffer * via AudioContext decode + OfflineAudioContext resample. */ export async function convertWebmToWav(webmBlob: Blob): Promise { const arrayBuffer = await webmBlob.arrayBuffer(); const tempCtx = new AudioContext(); let decoded: AudioBuffer; try { decoded = await tempCtx.decodeAudioData(arrayBuffer); } finally { await tempCtx.close().catch(() => {}); } const offlineCtx = new OfflineAudioContext( CHANNELS, Math.ceil(decoded.duration * SAMPLE_RATE), SAMPLE_RATE, ); const source = offlineCtx.createBufferSource(); source.buffer = decoded; source.connect(offlineCtx.destination); source.start(0); const resampled = await offlineCtx.startRendering(); let pcmFloat: Float32Array; if (resampled.numberOfChannels > 1) { const ch0 = resampled.getChannelData(0); const ch1 = resampled.getChannelData(1); pcmFloat = new Float32Array(ch0.length); for (let i = 0; i < ch0.length; i++) { pcmFloat[i] = (ch0[i] + ch1[i]) / 2; } } else { pcmFloat = resampled.getChannelData(0); } const pcm16 = new Int16Array(pcmFloat.length); for (let i = 0; i < pcmFloat.length; i++) { const s = Math.max(-1, Math.min(1, pcmFloat[i])); pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } const wavHeader = createWavHeader(pcm16.length, SAMPLE_RATE, CHANNELS, BITS_PER_SAMPLE); const wavBuffer = new ArrayBuffer(wavHeader.length + pcm16.length * 2); const wavView = new DataView(wavBuffer); for (let i = 0; i < wavHeader.length; i++) { wavView.setUint8(i, wavHeader[i]); } new Int16Array(wavBuffer, wavHeader.length).set(pcm16); return wavBuffer; } export function useVoiceRecorder(): UseVoiceRecorderReturn { const [isRecording, setIsRecording] = useState(false); const [status, setStatus] = useState('idle'); const [duration, setDuration] = useState(0); const [error, setError] = useState(null); const isRecordingRef = useRef(false); const statusRef = useRef('idle'); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const streamRef = useRef(null); const durationTimerRef = useRef(null); const startTimeRef = useRef(0); const isStoppingRef = useRef(false); const startPromiseRef = useRef | null>(null); const stopPromiseRef = useRef<{ resolve: (v: ArrayBuffer | null) => void; reject: (r?: unknown) => void; } | null>(null); const audioContextRef = useRef(null); const scriptProcessorRef = useRef(null); const onPcmDataRef = useRef(null); useEffect(() => { isRecordingRef.current = isRecording; statusRef.current = status; }, [isRecording, status]); const cleanupPcmPipeline = useCallback(() => { if (scriptProcessorRef.current) { try { scriptProcessorRef.current.disconnect(); } catch { /* ignore */ } scriptProcessorRef.current = null; } if (audioContextRef.current) { audioContextRef.current.close().catch(() => {}); audioContextRef.current = null; } onPcmDataRef.current = null; }, []); const cleanup = useCallback(() => { if (durationTimerRef.current !== null) { clearInterval(durationTimerRef.current); durationTimerRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } mediaRecorderRef.current = null; audioChunksRef.current = []; isStoppingRef.current = false; cleanupPcmPipeline(); }, [cleanupPcmPipeline]); const startRecord = useCallback(async (onPcmData?: OnPcmDataAvailable) => { if (isRecordingRef.current || statusRef.current === 'processing') return; setError(null); const doStart = async () => { if (!navigator.mediaDevices?.getUserMedia) { const isSecure = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1'; if (!isSecure) { throw new Error('语音功能需要在HTTPS环境下使用'); } throw new Error('浏览器不支持语音录制功能,请使用Chrome等现代浏览器'); } let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: SAMPLE_RATE, channelCount: CHANNELS, echoCancellation: true, noiseSuppression: true, }, }); } catch (firstErr: any) { if (firstErr.name === 'NotAllowedError' || firstErr.name === 'PermissionDeniedError') { throw new Error('需要麦克风权限才能使用语音功能'); } if (firstErr.name === 'NotFoundError' || firstErr.name === 'DevicesNotFoundError') { throw new Error('未检测到麦克风设备'); } if (firstErr.name === 'NotReadableError' || firstErr.name === 'TrackStartError') { try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (retryErr: any) { if (retryErr.name === 'NotAllowedError' || retryErr.name === 'PermissionDeniedError') { throw new Error('需要麦克风权限才能使用语音功能'); } throw new Error(`无法访问麦克风: ${retryErr.message || '未知错误'}`); } } else { throw new Error(`无法访问麦克风: ${firstErr.message || '未知错误'}`); } } streamRef.current = stream; if (typeof MediaRecorder === 'undefined') { stream.getTracks().forEach(t => t.stop()); streamRef.current = null; throw new Error('浏览器不支持 MediaRecorder API'); } let recorder: MediaRecorder; try { recorder = MediaRecorder.isTypeSupported('audio/wav') ? new MediaRecorder(stream, { mimeType: 'audio/wav' }) : new MediaRecorder(stream); } catch (err: any) { stream.getTracks().forEach(t => t.stop()); streamRef.current = null; throw new Error(`创建录音器失败: ${err.message || '未知错误'}`); } mediaRecorderRef.current = recorder; audioChunksRef.current = []; onPcmDataRef.current = onPcmData || null; if (onPcmData) { try { const audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE }); audioContextRef.current = audioCtx; const source = audioCtx.createMediaStreamSource(stream); const bufferSize = 4096; const scriptProcessor = audioCtx.createScriptProcessor(bufferSize, CHANNELS, CHANNELS); scriptProcessorRef.current = scriptProcessor; scriptProcessor.onaudioprocess = (event) => { if (!isRecordingRef.current || !onPcmDataRef.current) return; const inputData = event.inputBuffer.getChannelData(0); onPcmDataRef.current(new Float32Array(inputData)); }; source.connect(scriptProcessor); const gainNode = audioCtx.createGain(); gainNode.gain.value = 0; scriptProcessor.connect(gainNode); gainNode.connect(audioCtx.destination); } catch { // PCM pipeline setup failed — streaming won't work but recording continues for fallback } } recorder.ondataavailable = (e: BlobEvent) => { if (e.data.size > 0) { audioChunksRef.current.push(e.data); } }; recorder.onstop = () => { setIsRecording(false); isRecordingRef.current = false; setStatus('processing'); statusRef.current = 'processing'; if (durationTimerRef.current !== null) { clearInterval(durationTimerRef.current); durationTimerRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } cleanupPcmPipeline(); if (!stopPromiseRef.current) { setStatus('idle'); statusRef.current = 'idle'; isStoppingRef.current = false; audioChunksRef.current = []; return; } const { resolve, reject } = stopPromiseRef.current; stopPromiseRef.current = null; const mimeType = recorder.mimeType || 'audio/webm'; const blob = new Blob(audioChunksRef.current, { type: mimeType }); audioChunksRef.current = []; const needsConversion = mimeType.includes('webm') || mimeType.includes('opus') || !mimeType.includes('wav'); const elapsedMs = Date.now() - startTimeRef.current; /** 与 recorder.start(300) 对齐:过短停止时常得到不完整 WebM,decodeAudioData 会抛 EncodingError */ const MIN_WEBM_CONVERT_MS = 320; const finishIdle = () => { setStatus('idle'); statusRef.current = 'idle'; isStoppingRef.current = false; }; if (blob.size === 0) { finishIdle(); resolve(null); return; } if (needsConversion) { if (elapsedMs < MIN_WEBM_CONVERT_MS) { finishIdle(); resolve(null); return; } convertWebmToWav(blob) .then(wav => { finishIdle(); resolve(wav); }) .catch(err => { const msg = err && typeof (err as Error).message === 'string' ? (err as Error).message : ''; const decodeFailed = (err as Error)?.name === 'EncodingError' || msg.includes('decodeAudioData') || msg.includes('Unable to decode audio'); // 极短或极小的片段解码失败时视为无效录音,避免误触点击出现技术性报错 if (decodeFailed && (elapsedMs < 2000 || blob.size < 4096)) { finishIdle(); resolve(null); return; } setError('音频格式转换失败'); finishIdle(); reject(new Error(`音频格式转换失败: ${err}`)); }); } else { blob .arrayBuffer() .then(buf => { setStatus('idle'); statusRef.current = 'idle'; isStoppingRef.current = false; resolve(buf); }) .catch(() => { setError('音频数据转换失败'); setStatus('idle'); statusRef.current = 'idle'; isStoppingRef.current = false; reject(new Error('音频数据转换失败')); }); } }; recorder.onerror = () => { setIsRecording(false); isRecordingRef.current = false; setStatus('idle'); statusRef.current = 'idle'; setError('录音失败'); setDuration(0); cleanupPcmPipeline(); cleanup(); }; recorder.start(300); setIsRecording(true); isRecordingRef.current = true; setStatus('recording'); statusRef.current = 'recording'; startTimeRef.current = Date.now(); setDuration(0); durationTimerRef.current = window.setInterval(() => { const elapsed = (Date.now() - startTimeRef.current) / 1000; setDuration(Math.round(elapsed)); }, 200); }; const p = doStart(); startPromiseRef.current = p; try { await p; } finally { if (startPromiseRef.current === p) { startPromiseRef.current = null; } } }, [cleanup, cleanupPcmPipeline]); const stopRecord = useCallback(async (): Promise => { if (startPromiseRef.current) { try { await startPromiseRef.current; } catch { return null; } } return new Promise((resolve, reject) => { if (isStoppingRef.current) { resolve(null); return; } if (!mediaRecorderRef.current) { resolve(null); return; } if (mediaRecorderRef.current.state === 'inactive') { setIsRecording(false); isRecordingRef.current = false; setStatus('idle'); statusRef.current = 'idle'; resolve(null); return; } isStoppingRef.current = true; stopPromiseRef.current = { resolve, reject }; try { mediaRecorderRef.current.stop(); setIsRecording(false); isRecordingRef.current = false; setStatus('processing'); statusRef.current = 'processing'; } catch (err: any) { stopPromiseRef.current = null; isStoppingRef.current = false; reject(new Error(`停止录音失败: ${err.message || '未知错误'}`)); } setTimeout(() => { if (stopPromiseRef.current) { stopPromiseRef.current = null; setStatus('idle'); statusRef.current = 'idle'; isStoppingRef.current = false; reject(new Error('停止录音超时')); } }, 10000); }); }, []); const cancelRecord = useCallback(() => { startPromiseRef.current = null; stopPromiseRef.current = null; isStoppingRef.current = false; if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { try { mediaRecorderRef.current.stop(); } catch { // ignore } } setIsRecording(false); setStatus('idle'); setDuration(0); setError(null); isRecordingRef.current = false; statusRef.current = 'idle'; cleanupPcmPipeline(); cleanup(); }, [cleanup, cleanupPcmPipeline]); return useMemo( () => ({ isRecording, status, duration, error, startRecord, stopRecord, cancelRecord }), [isRecording, status, duration, error, startRecord, stopRecord, cancelRecord], ); }