diff --git a/apps/web/src/api/voice-clone/clones.ts b/apps/web/src/api/voice-clone/clones.ts index f63e71124..a5e64fdc8 100644 --- a/apps/web/src/api/voice-clone/clones.ts +++ b/apps/web/src/api/voice-clone/clones.ts @@ -96,9 +96,12 @@ export const retryVoiceClone = async (id: string): Promise => export const getVoiceClonePreview = async ( cloneId: string, text?: string, + options?: { speed?: number; emotion?: string }, ): Promise => { const searchParams = new URLSearchParams() if (text) searchParams.set("text", text) + if (options?.speed !== undefined) searchParams.set("speed", String(options.speed)) + if (options?.emotion) searchParams.set("emotion", options.emotion) const qs = searchParams.toString() const response = await apiClient.get( `/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`, diff --git a/apps/web/src/pages/voice-clone/VoiceClone.tsx b/apps/web/src/pages/voice-clone/VoiceClone.tsx index bd0291c66..ce6abd872 100755 --- a/apps/web/src/pages/voice-clone/VoiceClone.tsx +++ b/apps/web/src/pages/voice-clone/VoiceClone.tsx @@ -11,6 +11,7 @@ import CloneModal from "@/components/voice/CloneModal" import { VoiceCloneCard } from "./components/VoiceCloneCard" import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States" import { EditNameDialog } from "./components/EditNameDialog" +import VoiceClonePreviewPanel from "./components/VoiceClonePreviewPanel" import { useVoiceCloneList } from "./hooks/useVoiceCloneList" import "./voice-clone.css" @@ -47,6 +48,9 @@ const VoiceClone: React.FC = () => { } /> + {/* 音色试听面板(自定义文本 + 语速/情绪) */} + {!isLoading && voices.length > 0 && } + {/* 加载状态 — 骨架屏 */} {isLoading && } diff --git a/apps/web/src/pages/voice-clone/components/VoiceClonePreviewPanel.tsx b/apps/web/src/pages/voice-clone/components/VoiceClonePreviewPanel.tsx new file mode 100644 index 000000000..8a7185ce5 --- /dev/null +++ b/apps/web/src/pages/voice-clone/components/VoiceClonePreviewPanel.tsx @@ -0,0 +1,133 @@ +/** + * 克隆音色试听面板 + * - 选择就绪音色、输入试听文本、调节语速/情绪,点试听 + * - 复用配音库 TTS 弹窗的 SpeedControl / EmotionControl 组件 + */ +import React, { useState, useRef, useCallback } from "react" +import { message } from "antd" +import { SoundOutlined, LoadingOutlined } from "@ant-design/icons" +import { getVoiceClonePreview, type VoiceClone } from "@/api/voice-clone" +import SpeedControl from "@/pages/voices/components/tts-modal/SpeedControl" +import EmotionControl from "@/pages/voices/components/tts-modal/EmotionControl" +import { DEFAULT_TTS_EMOTION, type TtsEmotion } from "@/pages/voices/components/tts-modal/constants" +import { TTS_CONFIG } from "@/pages/voices/components/tts-modal/types" + +interface VoiceClonePreviewPanelProps { + voices: VoiceClone[] +} + +const DEFAULT_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的声音效果,希望你喜欢。" + +const VoiceClonePreviewPanel: React.FC = ({ voices }) => { + const readyVoices = voices.filter((v) => v.status === "ready") + + const [selectedId, setSelectedId] = useState(readyVoices[0]?.id ?? "") + const [previewText, setPreviewText] = useState(DEFAULT_PREVIEW_TEXT) + const [speed, setSpeed] = useState(TTS_CONFIG.DEFAULT_SPEED) + const [emotion, setEmotion] = useState(DEFAULT_TTS_EMOTION) + const [previewing, setPreviewing] = useState(false) + const [audioUrl, setAudioUrl] = useState(null) + const audioRef = useRef(null) + + const handlePreview = useCallback(async () => { + if (!selectedId) { + message.warning("请先选择要试听的音色") + return + } + const text = previewText.trim() + if (!text) { + message.warning("请输入试听文本") + return + } + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + setPreviewing(true) + setAudioUrl(null) + try { + const res = await getVoiceClonePreview(selectedId, text, { speed, emotion }) + setAudioUrl(res.audio_url) + const audio = new Audio(res.audio_url) + audioRef.current = audio + audio.play().catch(() => { + message.error("播放失败,请重试") + }) + } catch (err) { + const msg = err instanceof Error ? err.message : "试听失败" + message.error(msg) + } finally { + setPreviewing(false) + } + }, [selectedId, previewText, speed, emotion]) + + if (readyVoices.length === 0) return null + + return ( +
+
+ + 音色试听 +
+ +
+
+ + +
+ +
+ +