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..d9131798f --- /dev/null +++ b/apps/web/src/components/voice/CloneModal/index.tsx @@ -0,0 +1,440 @@ +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 } from "./constants" +import { validateFile } from "./utils" +import { useAudioRecorder } from "./hooks/useAudioRecorder" +import type { CloneModalProps, ModalPhase } from "./types" +import "./clone-modal.css" + +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) + + /* ── 录音 Hook ──────────────────────────────────── */ + const { + isRecording, + recordTime, + 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) + setErrorMessage("") + resetRecorder() + }, [getNextDefaultName, resetRecorder]) + + /** 关闭弹窗 */ + const handleClose = useCallback(() => { + resetState() + onClose() + }, [resetState, onClose]) + + /** 弹窗打开时重置状态 */ + useEffect(() => { + if (open) { + resetState() + } + }, [open, resetState]) + + /** 组件卸载时清理定时器 */ + useEffect(() => { + return () => { + 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 { + 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 { + setErrorMessage("") + setSelectedFile(file) + resetRecorder() + } + } + } + + /* ── 表单验证 ──────────────────────────────────── */ + + const hasAudio = selectedFile !== null || recordedBlob !== null + + const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio + + const isProcessing = phase === "uploading" || phase === "cloning" + + /** 当前进度索引 */ + const progressIndex = (() => { + switch (phase) { + case "uploading": + return 0 + case "cloning": + return 1 + case "done": + return 2 + default: + return -1 + } + })() + + /* ── 提交克隆 ──────────────────────────────────── */ + + const handleSubmit = async () => { + const name = voiceName.trim() + if (!name || name.length < 2 || name.length > 20) { + setErrorMessage("音色名称需在 2-20 个字符之间") + return + } + if (!hasAudio) { + setErrorMessage("请上传音频文件或录制一段声音") + return + } + + setErrorMessage("") + + try { + // 阶段 1:上传音频 + setPhase("uploading") + + let fileToUpload: File + if (selectedFile) { + fileToUpload = selectedFile + } else { + fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, { + type: "audio/webm", + }) + } + + const formData = new FormData() + formData.append("file", fileToUpload) + const uploadResult = await uploadAsset(formData) + + // 阶段 2:克隆 + setPhase("cloning") + const result = await createVoiceClone({ + name, + description: voiceDescription.trim() || undefined, + audio_url: uploadResult.url, + }) + + // 阶段 3:完成 + setPhase("done") + + // 2秒后自动关闭 + timerRef.current = setTimeout(() => { + onSuccess?.(toVoiceClone(result)) + handleClose() + }, 2000) + } catch (err) { + 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 && ( +
+ + + + + +
+ )} +
+ +
+
+ + {/* 音色描述 */} +
+ +