fix(frontend): #1897 声音克隆页增加情绪语速设置 #1906

Merged
xiaoxia merged 4 commits from fix/1897-voice-clone-preview-emotion-speed into develop 2026-09-14 19:11:00 +08:00
4 changed files with 230 additions and 0 deletions
+3
View File
@@ -96,9 +96,12 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
export const getVoiceClonePreview = async (
cloneId: string,
text?: string,
options?: { speed?: number; emotion?: string },
): Promise<VoiceClonePreviewResponse> => {
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<VoiceClonePreviewResponse>(
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
@@ -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 && <VoiceClonePreviewPanel voices={voices} />}
{/* 加载状态 — 骨架屏 */}
{isLoading && <VoiceCloneSkeleton />}
@@ -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<VoiceClonePreviewPanelProps> = ({ voices }) => {
const readyVoices = voices.filter((v) => v.status === "ready")
const [selectedId, setSelectedId] = useState<string>(readyVoices[0]?.id ?? "")
const [previewText, setPreviewText] = useState(DEFAULT_PREVIEW_TEXT)
const [speed, setSpeed] = useState<number>(TTS_CONFIG.DEFAULT_SPEED)
const [emotion, setEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
const [previewing, setPreviewing] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(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 (
<div className="vc-preview-panel">
<div className="vc-preview-header">
<SoundOutlined style={{ color: "var(--primary-color)" }} />
<span className="vc-preview-title"></span>
</div>
<div className="vc-preview-body">
<div className="vc-preview-field">
<label className="vc-preview-label"></label>
<select
className="vc-preview-select"
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
>
{readyVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div className="vc-preview-field">
<label className="vc-preview-label"></label>
<textarea
className="vc-preview-textarea"
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
rows={2}
maxLength={200}
placeholder="输入试听文本(最多200字)"
/>
</div>
<div className="vc-preview-grid">
<div className="vc-preview-field">
<SpeedControl speed={speed} onChange={setSpeed} />
</div>
<div className="vc-preview-field">
<EmotionControl emotion={emotion} onChange={setEmotion} />
</div>
</div>
<button
type="button"
className="vc-preview-btn"
onClick={handlePreview}
disabled={previewing || !selectedId || !previewText.trim()}
>
{previewing ? (
<>
<LoadingOutlined /> ...
</>
) : (
<> </>
)}
</button>
{audioUrl && !previewing && (
<audio controls src={audioUrl} style={{ width: "100%", marginTop: 4 }} />
)}
</div>
</div>
)
}
export default VoiceClonePreviewPanel
@@ -464,3 +464,93 @@
padding: var(--space-sm);
}
}
/* ── 试听面板 ─────────────────────────────────────────── */
.vc-preview-panel {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.vc-preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.vc-preview-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.vc-preview-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.vc-preview-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.vc-preview-label {
font-size: 13px;
color: var(--text-secondary);
}
.vc-preview-select,
.vc-preview-textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.vc-preview-select:focus,
.vc-preview-textarea:focus {
border-color: var(--primary-color);
}
.vc-preview-textarea {
resize: vertical;
min-height: 52px;
}
.vc-preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vc-preview-btn {
width: 100%;
padding: 10px 0;
border-radius: 8px;
border: none;
background: var(--primary-color);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.15s;
}
.vc-preview-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}