From 0a10e88abe19f58145540b2c6ae0d8763dce5ec3 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 09:52:18 +0800 Subject: [PATCH] fix: restore useAudioRecorder with isMounted guard --- .../CloneModal/hooks/useAudioRecorder.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/apps/web/src/components/voice/CloneModal/hooks/useAudioRecorder.ts b/apps/web/src/components/voice/CloneModal/hooks/useAudioRecorder.ts index e69de29bb..37e82f3f4 100644 --- a/apps/web/src/components/voice/CloneModal/hooks/useAudioRecorder.ts +++ b/apps/web/src/components/voice/CloneModal/hooks/useAudioRecorder.ts @@ -0,0 +1,128 @@ +import { useState, useRef, useCallback, useEffect } from "react" +import { MAX_RECORD_SECONDS } from "../constants" +import { formatRecordTime } from "../utils" + +/** + * 录音 Hook + * 封装浏览器 MediaRecorder 录音逻辑,含计时、自动停止、时长上限 + */ +export const useAudioRecorder = (onError?: (msg: string) => void) => { + const [isRecording, setIsRecording] = useState(false) + const [recordTime, setRecordTime] = useState(0) + const [recordedBlob, setRecordedBlob] = useState(null) + + const recordTimerRef = useRef | null>(null) + const mediaRecorderRef = useRef(null) + const audioChunksRef = useRef([]) + const isMountedRef = useRef(true) + + /** 重置录音状态 */ + const reset = useCallback(() => { + if (!isMountedRef.current) return + setIsRecording(false) + setRecordTime(0) + setRecordedBlob(null) + audioChunksRef.current = [] + if (recordTimerRef.current) { + clearInterval(recordTimerRef.current) + recordTimerRef.current = null + } + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { + mediaRecorderRef.current.stop() + } + mediaRecorderRef.current = null + }, []) + + /** 开始/停止录制 */ + const toggleRecord = useCallback(async () => { + if (isRecording) { + // 停止录制 + setIsRecording(false) + if (recordTimerRef.current) { + clearInterval(recordTimerRef.current) + recordTimerRef.current = null + } + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { + mediaRecorderRef.current.stop() + } + return + } + + // 开始录制 + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }) + const mediaRecorder = new MediaRecorder(stream) + mediaRecorderRef.current = mediaRecorder + audioChunksRef.current = [] + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data) + } + } + + mediaRecorder.onstop = () => { + if (!isMountedRef.current) return + // 使用浏览器实际生成的 MIME 类型,避免跨浏览器兼容性问题 + const mimeType = mediaRecorder.mimeType || "audio/webm" + const blob = new Blob(audioChunksRef.current, { type: mimeType }) + setRecordedBlob(blob) + // 停止音轨 + stream.getTracks().forEach((track) => track.stop()) + } + + mediaRecorder.start() + setIsRecording(true) + setRecordTime(0) + setRecordedBlob(null) + + recordTimerRef.current = setInterval(() => { + setRecordTime((prev) => { + const next = prev + 1 + if (next >= MAX_RECORD_SECONDS) { + // 达到时长上限,自动停止 + setTimeout(() => { + if (!isMountedRef.current) return + setIsRecording(false) + if (recordTimerRef.current) { + clearInterval(recordTimerRef.current) + recordTimerRef.current = null + } + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { + mediaRecorderRef.current.stop() + } + onError?.("已达最长录制时长(5分钟),已自动停止") + }, 0) + return MAX_RECORD_SECONDS + } + return next + }) + }, 1000) + } catch { + onError?.("无法访问麦克风,请检查浏览器权限设置") + } + }, [isRecording, onError]) + + // 组件卸载时清理 + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + if (recordTimerRef.current) clearInterval(recordTimerRef.current) + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { + mediaRecorderRef.current.stop() + } + } + }, []) + + return { + isRecording, + recordTime, + recordedBlob, + formattedTime: formatRecordTime(recordTime), + toggleRecord, + reset, + } +}