From 9f9e492bf3b06835a877f3de00a15d5da17cb05f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 30 Aug 2026 14:43:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(voices):=20=E9=85=8D=E9=9F=B3=E5=BA=93?= =?UTF-8?q?=E5=9B=9B=E8=BF=9E=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E8=BF=9B=E5=BA=A6=E6=9D=A1=E9=9A=94=E7=A6=BB/TTS?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=85=8B=E9=9A=86=E9=9F=B3=E8=89=B2/?= =?UTF-8?q?=E5=85=8B=E9=9A=86=E5=BC=B9=E7=AA=97=E7=B4=A0=E6=9D=90=E9=80=89?= =?UTF-8?q?=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 素材卡片进度条联动 bug:MaterialVoiceTab 与新配音库页 CardPlayer 共用页面级 currentTime,非播放卡片也显示进度。改为仅播放中的卡片 计算进度(参照 ClonedVoiceTab 隔离写法) 2. AI 配音弹窗音色下拉新增「我的克隆音色」分组:旧配音库页 VoiceSelector + useTtsSynthesize 透传 ready 克隆音色(ClonedVoiceDisplay);新配音库页 TtsModal + hook 用 getVoiceClonesWithTotal 拉取并过滤 ready;选中克隆音色 时 voice_id 直接传 profile UUID 由后端解析 3. 克隆音色弹窗改造:删除「上传音频文件」入口及拖拽/文件选择死代码; 保留录音(录音后仍上传为素材再克隆,用户无感知);新增「从配音素材选择」 下拉(getAssetsByKind voice,显示素材名+时长),选中直接传 asset_id 克隆,跳过上传;素材为空提示「请先在配音库上传素材」+跳转入口 4. API:CreateVoiceCloneRequest/Full 支持 asset_id(与 audio_url 二选一); 删除 components/voice 下无引用的旧架构死代码(clone-modal/hooks/constants/ utils/types 共 12 个文件,-1088 行) 测试:tsc/ESLint/prettier 全过,604 单测通过,CloneModal 4 条交互用例 --- apps/web/src/api/voice-clone/clones.ts | 9 +- apps/web/src/api/voice-clone/types.ts | 13 +- .../voice/CloneModal/clone-modal.css | 52 ++--- .../components/voice/CloneModal/constants.ts | 9 - .../src/components/voice/CloneModal/index.tsx | 213 +++++++++--------- .../src/components/voice/CloneModal/utils.ts | 17 -- .../voice/clone-modal/InputView.tsx | 142 ------------ .../voice/clone-modal/ProgressView.tsx | 92 -------- .../voice/clone-modal/RecordArea.tsx | 49 ---- .../voice/clone-modal/StepIndicator.tsx | 30 --- .../voice/clone-modal/UploadZone.tsx | 79 ------- .../components/voice/constants/cloneModal.ts | 29 --- .../voice/hooks/useAudioRecorder.ts | 119 ---------- .../voice/hooks/useCloneFormState.ts | 134 ----------- .../components/voice/hooks/useCloneModal.ts | 46 ---- .../components/voice/hooks/useCloneSubmit.ts | 108 --------- .../src/components/voice/types/cloneModal.ts | 20 -- .../src/components/voice/utils/cloneModal.ts | 25 -- .../voice-materials/VoiceMaterialLibrary.tsx | 2 + .../voice-materials/components/TtsModal.tsx | 30 ++- .../voice-material-card/CardPlayer.tsx | 3 +- .../voice-materials/hooks/useTtsSynthesize.ts | 13 ++ apps/web/src/pages/voices/VoiceLibrary.tsx | 11 +- .../voices/components/MaterialVoiceTab.tsx | 4 +- .../src/pages/voices/components/TtsModal.tsx | 8 +- .../pages/voices/components/VoiceModals.tsx | 5 + .../components/tts-modal/VoiceSelector.tsx | 39 +++- .../voices/components/tts-modal/types.ts | 3 + .../pages/voices/hooks/useTtsSynthesize.ts | 10 +- .../test/components/voice/CloneModal.test.tsx | 68 ++++-- .../components/voice/CloneModal/utils.test.ts | 11 - 31 files changed, 305 insertions(+), 1088 deletions(-) delete mode 100644 apps/web/src/components/voice/clone-modal/InputView.tsx delete mode 100644 apps/web/src/components/voice/clone-modal/ProgressView.tsx delete mode 100644 apps/web/src/components/voice/clone-modal/RecordArea.tsx delete mode 100644 apps/web/src/components/voice/clone-modal/StepIndicator.tsx delete mode 100644 apps/web/src/components/voice/clone-modal/UploadZone.tsx delete mode 100644 apps/web/src/components/voice/constants/cloneModal.ts delete mode 100644 apps/web/src/components/voice/hooks/useAudioRecorder.ts delete mode 100644 apps/web/src/components/voice/hooks/useCloneFormState.ts delete mode 100755 apps/web/src/components/voice/hooks/useCloneModal.ts delete mode 100755 apps/web/src/components/voice/hooks/useCloneSubmit.ts delete mode 100644 apps/web/src/components/voice/types/cloneModal.ts delete mode 100644 apps/web/src/components/voice/utils/cloneModal.ts delete mode 100644 apps/web/src/test/components/voice/CloneModal/utils.test.ts diff --git a/apps/web/src/api/voice-clone/clones.ts b/apps/web/src/api/voice-clone/clones.ts index d4c4dfc79..f63e71124 100644 --- a/apps/web/src/api/voice-clone/clones.ts +++ b/apps/web/src/api/voice-clone/clones.ts @@ -44,14 +44,19 @@ export const getVoiceCloneDetail = async (id: string): Promise => { const payload: CreateVoiceCloneRequestFull = { name: data.name, description: data.description, - source_audio_url: data.audio_url, + } + // 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆 + if (data.asset_id) { + payload.asset_id = data.asset_id + } else { + payload.source_audio_url = data.audio_url } const response = await apiClient.post("/voice-clones", payload) return response.data diff --git a/apps/web/src/api/voice-clone/types.ts b/apps/web/src/api/voice-clone/types.ts index 7cc9096a3..c0acb8416 100644 --- a/apps/web/src/api/voice-clone/types.ts +++ b/apps/web/src/api/voice-clone/types.ts @@ -22,10 +22,13 @@ export interface VoiceClone { updated_at: string } -/** 创建克隆请求(前端简化版) */ +/** 创建克隆请求(前端简化版:audio_url 与 asset_id 二选一) */ export interface CreateVoiceCloneRequest { name: string - audio_url: string + /** 录音/文件上传后的音频 URL(与 asset_id 二选一) */ + audio_url?: string + /** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */ + asset_id?: string description?: string } @@ -72,11 +75,13 @@ export interface VoiceCloneStatusResponse { retry_count: number } -/** 后端创建克隆请求(完整版) */ +/** 后端创建克隆请求(完整版:source_audio_url 与 asset_id 二选一) */ export interface CreateVoiceCloneRequestFull { name: string description?: string - source_audio_url: string + source_audio_url?: string + /** 从配音素材选择克隆时传素材 ID */ + asset_id?: string voice_model?: string language?: string gender?: string diff --git a/apps/web/src/components/voice/CloneModal/clone-modal.css b/apps/web/src/components/voice/CloneModal/clone-modal.css index 35952976d..73e84f260 100644 --- a/apps/web/src/components/voice/CloneModal/clone-modal.css +++ b/apps/web/src/components/voice/CloneModal/clone-modal.css @@ -149,53 +149,29 @@ /* ── 上传区域 ───────────────────────────────────────────── */ -.xx-clonemodal-upload-zone { - border: 2px dashed var(--xx-color-border, #e5e7eb); +/* ── 素材选择空态 ─────────────────────────────────────────── */ + +.xx-clonemodal-asset-empty { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 1px dashed var(--xx-color-border, #e5e7eb); border-radius: var(--radius-sm); - padding: 28px 20px; - text-align: center; - cursor: pointer; - transition: all 0.2s ease; background: var(--xx-color-bg-secondary, #f9fafb); } -.xx-clonemodal-upload-zone:hover { - border-color: var(--xx-color-primary, #6366f1); - background: rgba(99, 102, 241, 0.03); -} - -.xx-clonemodal-upload-zone--active { - border-color: var(--xx-color-primary, #6366f1); - background: rgba(99, 102, 241, 0.06); -} - -.xx-clonemodal-upload-zone--has-file { - border-style: solid; - border-color: var(--xx-color-primary, #6366f1); - background: rgba(99, 102, 241, 0.04); -} - -.xx-clonemodal-upload-icon { - font-size: 32px; - margin-bottom: 8px; -} - -.xx-clonemodal-upload-title { - font-size: 14px; - font-weight: 500; - color: var(--xx-color-text, #111827); - margin: 0 0 4px; - word-break: break-all; -} - -.xx-clonemodal-upload-hint { - font-size: 12px; - color: var(--xx-color-text-secondary, #6b7280); +.xx-clonemodal-asset-empty-text { margin: 0; + font-size: 13px; + color: var(--xx-color-text-secondary, #6b7280); } /* ── 错误提示 ───────────────────────────────────────────── */ +/* ── 错误提示 ───────────────────────────────────────────── */ + .xx-clonemodal-error { display: flex; align-items: center; diff --git a/apps/web/src/components/voice/CloneModal/constants.ts b/apps/web/src/components/voice/CloneModal/constants.ts index b1f6fd75f..65908b85e 100644 --- a/apps/web/src/components/voice/CloneModal/constants.ts +++ b/apps/web/src/components/voice/CloneModal/constants.ts @@ -7,14 +7,5 @@ export const PROGRESS_STEPS: ProgressStep[] = [ { 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/index.tsx b/apps/web/src/components/voice/CloneModal/index.tsx index be564f067..84044441f 100644 --- a/apps/web/src/components/voice/CloneModal/index.tsx +++ b/apps/web/src/components/voice/CloneModal/index.tsx @@ -1,10 +1,12 @@ import React, { useState, useCallback, useRef, useEffect } from "react" +import { useNavigate } from "react-router-dom" +import { useQuery } from "@tanstack/react-query" import { Modal, Button } from "@/components/ui" import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" -import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets" +import { uploadAssetDirect, ensureDefaultLibrary, getAssetsByKind } from "@/api/assets" import { getOrCreateDefaultProject } from "@/api/projects" -import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants" -import { validateFile } from "./utils" +import { PROGRESS_STEPS } from "./constants" +import { formatRecordTime } from "./utils" import { useAudioRecorder } from "./hooks/useAudioRecorder" import type { CloneModalProps, ModalPhase } from "./types" import "./clone-modal.css" @@ -18,15 +20,21 @@ const getExtensionFromMime = (mime: string): string => { return "webm" } +/** 格式化素材时长(秒 → mm:ss) */ +const formatAssetDuration = (seconds?: number): string => { + if (!seconds || seconds <= 0) return "--:--" + return formatRecordTime(Math.round(seconds)) +} + const CloneModal: React.FC = ({ open, onClose, onSuccess }) => { + const navigate = useNavigate() const [phase, setPhase] = useState("input") const [voiceName, setVoiceName] = useState("") const [voiceDescription, setVoiceDescription] = useState("") - const [selectedFile, setSelectedFile] = useState(null) - const [dragActive, setDragActive] = useState(false) + /** 从配音素材选择的素材 ID */ + const [selectedAssetId, setSelectedAssetId] = useState("") const [errorMessage, setErrorMessage] = useState("") - const fileInputRef = useRef(null) const timerRef = useRef | null>(null) /** 默认音色名称计数器(组件级 ref,避免多实例串号) */ const cloneCounterRef = useRef(1) @@ -34,6 +42,14 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => const isMountedRef = useRef(true) const isSubmittingRef = useRef(false) + /* ── 配音素材列表(「从配音素材选择」;弹窗打开时才发请求) ────── */ + const { data: voiceAssets, isLoading: assetsLoading } = useQuery({ + queryKey: ["assets", "voice", "clone-modal"], + queryFn: () => getAssetsByKind("voice", { limit: 100 }), + enabled: open, + staleTime: 30_000, + }) + /* ── 录音 Hook ──────────────────────────────────── */ const { isRecording, @@ -55,8 +71,7 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => setPhase("input") setVoiceName(getNextDefaultName()) setVoiceDescription("") - setSelectedFile(null) - setDragActive(false) + setSelectedAssetId("") // 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit; // 此前在此上锁且无复位路径,弹窗打开即死锁 setErrorMessage("") @@ -85,65 +100,26 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => } }, []) - /* ── 文件上传 ──────────────────────────────────── */ + /* ── 素材/录音互斥:选择素材时清掉录音,开始录音时清掉素材选择 ── */ - 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 { - // 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作, - // 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁 - 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 handleSelectAsset = (assetId: string) => { + setSelectedAssetId(assetId) + if (assetId) { + resetRecorder() } } - 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 { - // 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作, - // 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁 - setErrorMessage("") - setSelectedFile(file) - resetRecorder() - } + const handleToggleRecord = () => { + // 开始录音会清掉已选素材;停止录音保留录音结果 + if (!isRecording) { + setSelectedAssetId("") } + toggleRecord() } /* ── 计算属性 ──────────────────────────────────── */ - const hasAudio = selectedFile !== null || recordedBlob !== null + const hasAudio = selectedAssetId !== "" || recordedBlob !== null const isProcessing = phase === "uploading" || phase === "cloning" const canSubmit = hasAudio && !isProcessing @@ -158,7 +134,7 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => return } if (!hasAudio) { - setErrorMessage("请上传音频文件或录制一段声音") + setErrorMessage("请从配音素材选择一段音频,或直接录制声音") return } @@ -167,20 +143,37 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => setErrorMessage("") try { - // 阶段 1:上传音频 + // 路径 A:从配音素材选择 → 无需上传,直接克隆 + if (selectedAssetId) { + setPhase("cloning") + const result = await createVoiceClone({ + name, + description: voiceDescription.trim() || undefined, + asset_id: selectedAssetId, + }) + + if (!isMountedRef.current) return + + isSubmittingRef.current = false + setPhase("done") + timerRef.current = setTimeout(() => { + if (isMountedRef.current) { + onSuccess?.(toVoiceClone(result)) + handleClose() + } + }, 2000) + return + } + + // 路径 B:录音 → 先上传为配音素材,再克隆 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, - }) - } + // 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配 + const mimeType = recordedBlob?.type || "audio/webm" + const ext = getExtensionFromMime(mimeType) + const fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, { + type: mimeType, + }) // 获取默认项目和素材库 const project = await getOrCreateDefaultProject() @@ -226,6 +219,8 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => } } + const hasAssets = (voiceAssets?.length ?? 0) > 0 + return ( = ({ open, onClose, onSuccess }) =>
1
- 上传/录制音频 + 选择/录制音频
@@ -274,30 +269,42 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) =>
{voiceName.length}/20
- {/* 上传区域 */} + {/* 从配音素材选择 */}
- -
-
{selectedFile ? "📄" : "🎵"}
-

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

-

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

- -
+ + {hasAssets ? ( + + ) : ( +
+

+ {assetsLoading ? "素材加载中…" : "请先在配音库上传素材"} +

+ {!assetsLoading && ( + + )} +
+ )}
{/* 或分隔 */} @@ -332,7 +339,7 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) =>