diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx deleted file mode 100644 index 0a70f17ce..000000000 --- a/apps/web/src/components/voice/CloneModal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * CloneModal — 音色克隆弹窗(共享组件) - * - * 功能: - * - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆 - * - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder) - * - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone - * - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化) - * - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示 - * - * V21 Design System — 零 antd 直接导入 - */ -import React from "react" -import { Modal } from "@/components/ui" -import type { CloneModalProps } from "./types/cloneModal" -import useCloneModal from "./hooks/useCloneModal" -import InputView from "./clone-modal/InputView" -import ProgressView from "./clone-modal/ProgressView" -import "./clone-modal.css" - -const CloneModal: React.FC = ({ open, onClose, onSuccess }) => { - const { - phase, - voiceName, - voiceDescription, - selectedFile, - dragActive, - errorMessage, - isRecording, - recordTime, - recordedBlob, - canSubmit, - isProcessing, - setVoiceName, - setVoiceDescription, - setDragActive, - handleFileSelect, - handleRecordToggle, - handleClose, - handleSubmit, - } = useCloneModal({ open, onClose, onSuccess }) - - return ( - - {/* 输入阶段 */} - {phase === "input" && ( - - )} - - {/* 进度 / 完成阶段 */} - {phase !== "input" && } - - ) -} - -export default CloneModal diff --git a/apps/web/src/components/voice/clone-modal.css b/apps/web/src/components/voice/CloneModal/clone-modal.css similarity index 99% rename from apps/web/src/components/voice/clone-modal.css rename to apps/web/src/components/voice/CloneModal/clone-modal.css index 35952976d..540853183 100644 --- a/apps/web/src/components/voice/clone-modal.css +++ b/apps/web/src/components/voice/CloneModal/clone-modal.css @@ -642,3 +642,4 @@ font-size: 36px; } } + diff --git a/apps/web/src/components/voice/CloneModal/constants.ts b/apps/web/src/components/voice/CloneModal/constants.ts new file mode 100644 index 000000000..b1f6fd75f --- /dev/null +++ b/apps/web/src/components/voice/CloneModal/constants.ts @@ -0,0 +1,20 @@ +import type { ProgressStep } from "./types" + +/** 进度阶段配置 */ +export const PROGRESS_STEPS: ProgressStep[] = [ + { key: "uploading", label: "上传中", icon: "📤" }, + { key: "cloning", label: "克隆中", icon: "🧬" }, + { key: "done", label: "完成", icon: "✅" }, +] + +/** 支持的音频扩展名 */ +export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"] + +/** 文件选择器 accept 属性 */ +export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm" + +/** 最大文件大小:10MB */ +export const MAX_FILE_SIZE = 10 * 1024 * 1024 + +/** 最长录制时长:5 分钟(秒) */ +export const MAX_RECORD_SECONDS = 5 * 60 diff --git a/apps/web/src/components/voice/CloneModal/hooks/useAudioRecorder.ts b/apps/web/src/components/voice/CloneModal/hooks/useAudioRecorder.ts new file mode 100644 index 000000000..ca4c8e047 --- /dev/null +++ b/apps/web/src/components/voice/CloneModal/hooks/useAudioRecorder.ts @@ -0,0 +1,146 @@ +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 isStartingRef = useRef(false) + + /** 重置录音状态 */ + const reset = useCallback(() => { + if (!isMountedRef.current) return + setIsRecording(false) + setRecordTime(0) + setRecordedBlob(null) + audioChunksRef.current = [] + isStartingRef.current = false + 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 + } + + // 防止快速双击导致重复启动 + if (isStartingRef.current) return + isStartingRef.current = true + + // 开始录制 + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }) + if (!isMountedRef.current) { + stream.getTracks().forEach((track) => track.stop()) + isStartingRef.current = false + return + } + 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 (recordTimerRef.current) { + clearInterval(recordTimerRef.current) + recordTimerRef.current = null + } + 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()) + isStartingRef.current = false + } + + 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 { + isStartingRef.current = false + onError?.("无法访问麦克风,请检查浏览器权限设置") + } + }, [isRecording, onError]) + + // 组件卸载时清理 + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + isStartingRef.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, + } +} diff --git a/apps/web/src/components/voice/CloneModal/index.tsx b/apps/web/src/components/voice/CloneModal/index.tsx new file mode 100644 index 000000000..5aab11555 --- /dev/null +++ b/apps/web/src/components/voice/CloneModal/index.tsx @@ -0,0 +1,460 @@ +import React, { useState, useCallback, useRef, useEffect } from "react" +import { Modal, Button } from "@/components/ui" +import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" +import { uploadAsset } from "@/api/assets" +import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants" +import { validateFile } from "./utils" +import { useAudioRecorder } from "./hooks/useAudioRecorder" +import type { CloneModalProps, ModalPhase } from "./types" +import "./clone-modal.css" + +/** 根据 MIME 类型推断文件扩展名 */ +const getExtensionFromMime = (mime: string): string => { + if (mime.includes("webm")) return "webm" + if (mime.includes("mp4") || mime.includes("m4a")) return "m4a" + if (mime.includes("ogg")) return "ogg" + if (mime.includes("wav")) return "wav" + return "webm" +} + +const CloneModal: React.FC = ({ open, onClose, onSuccess }) => { + const [phase, setPhase] = useState("input") + const [voiceName, setVoiceName] = useState("") + const [voiceDescription, setVoiceDescription] = useState("") + const [selectedFile, setSelectedFile] = useState(null) + const [dragActive, setDragActive] = useState(false) + const [errorMessage, setErrorMessage] = useState("") + + const fileInputRef = useRef(null) + const timerRef = useRef | null>(null) + /** 默认音色名称计数器(组件级 ref,避免多实例串号) */ + const cloneCounterRef = useRef(1) + /** 组件挂载状态标记,防止卸载后更新 state */ + const isMountedRef = useRef(true) + const isSubmittingRef = useRef(false) + + /* ── 录音 Hook ──────────────────────────────────── */ + const { + isRecording, + recordedBlob, + formattedTime, + toggleRecord, + reset: resetRecorder, + } = useAudioRecorder(setErrorMessage) + + /** 生成下一个默认音色名称 */ + const getNextDefaultName = useCallback((): string => { + const name = `我的声音 ${cloneCounterRef.current}` + cloneCounterRef.current += 1 + return name + }, []) + + /** 重置弹窗状态 */ + const resetState = useCallback(() => { + setPhase("input") + setVoiceName(getNextDefaultName()) + setVoiceDescription("") + setSelectedFile(null) + setDragActive(false) + if (isSubmittingRef.current) return + isSubmittingRef.current = true + setErrorMessage("") + resetRecorder() + }, [getNextDefaultName, resetRecorder]) + + /** 关闭弹窗 */ + const handleClose = useCallback(() => { + resetState() + onClose() + }, [resetState, onClose]) + + /** 弹窗打开时重置状态 */ + useEffect(() => { + if (open) { + resetState() + } + }, [open, resetState]) + + /** 组件挂载/卸载标记 + 清理定时器 */ + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + /* ── 文件上传 ──────────────────────────────────── */ + + const handleUploadClick = () => { + fileInputRef.current?.click() + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + const error = validateFile(file) + if (error) { + setErrorMessage(error) + setSelectedFile(null) + } else { + if (isSubmittingRef.current) return + isSubmittingRef.current = true + setErrorMessage("") + setSelectedFile(file) + resetRecorder() + } + } + e.target.value = "" + } + + /* ── 拖拽 ──────────────────────────────────────── */ + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true) + } else if (e.type === "dragleave") { + setDragActive(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragActive(false) + const file = e.dataTransfer.files?.[0] + if (file) { + const error = validateFile(file) + if (error) { + setErrorMessage(error) + setSelectedFile(null) + } else { + if (isSubmittingRef.current) return + isSubmittingRef.current = true + setErrorMessage("") + setSelectedFile(file) + resetRecorder() + } + } + } + + /* ── 计算属性 ──────────────────────────────────── */ + + const hasAudio = selectedFile !== null || recordedBlob !== null + const isProcessing = phase === "uploading" || phase === "cloning" + const canSubmit = hasAudio && !isProcessing + + const progressIndex = PROGRESS_STEPS.findIndex((s) => s.key === phase) + + /* ── 提交 ──────────────────────────────────────── */ + + const handleSubmit = async () => { + const name = voiceName.trim() + if (!name || name.length < 2 || name.length > 20) { + setErrorMessage("音色名称需在 2-20 个字符之间") + return + } + if (!hasAudio) { + setErrorMessage("请上传音频文件或录制一段声音") + return + } + + if (isSubmittingRef.current) return + isSubmittingRef.current = true + setErrorMessage("") + + try { + // 阶段 1:上传音频 + setPhase("uploading") + + let fileToUpload: File + if (selectedFile) { + fileToUpload = selectedFile + } else { + // 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配 + const mimeType = recordedBlob?.type || "audio/webm" + const ext = getExtensionFromMime(mimeType) + fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, { + type: mimeType, + }) + } + + const formData = new FormData() + formData.append("file", fileToUpload) + const uploadResult = await uploadAsset(formData) + + // 组件已卸载则中止后续操作 + if (!isMountedRef.current) return + + // 阶段 2:克隆 + setPhase("cloning") + const result = await createVoiceClone({ + name, + description: voiceDescription.trim() || undefined, + audio_url: uploadResult.url, + }) + + // 组件已卸载则中止后续操作 + if (!isMountedRef.current) return + + isSubmittingRef.current = false + // 阶段 3:完成 + setPhase("done") + + // 2秒后自动关闭 + timerRef.current = setTimeout(() => { + if (isMountedRef.current) { + onSuccess?.(toVoiceClone(result)) + handleClose() + } + }, 2000) + } catch (err) { + isSubmittingRef.current = false + // 组件已卸载则不更新 state + if (!isMountedRef.current) return + setPhase("input") + setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试") + } + } + + return ( + + {/* ── 输入阶段 ────────────────────────────────── */} + {phase === "input" && ( +
+ {/* 步骤引导 */} +
+
+
1
+ 上传/录制音频 +
+
+
+
2
+ 填写信息 +
+
+
+
3
+ 提交克隆 +
+
+ + {/* 音色名称 */} +
+ + setVoiceName(e.target.value)} + placeholder="输入音色名称(2-20字符)" + maxLength={20} + /> +
{voiceName.length}/20
+
+ + {/* 上传区域 */} +
+ +
+
{selectedFile ? "📄" : "🎵"}
+

+ {selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"} +

+

支持 MP3、WAV、M4A 格式,最大 10MB

+ +
+
+ + {/* 或分隔 */} +
+
+ +
+
+ + {/* 录制区域 */} +
+ +
+
+

+ {isRecording + ? `录制中 ${formattedTime}` + : recordedBlob + ? `已录制 ${formattedTime}` + : "点击按钮开始录制(最长 5 分钟)"} +

+ {isRecording && ( +
+ + + + + +
+ )} +
+ +
+
+ + {/* 音色描述 */} +
+ +