fix(frontend): #1897 声音克隆页增加情绪语速设置 #1906
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user