fix(#1196): 智能剪辑TTS面板改用统一/voices接口,支持克隆音色选择 #1199

Merged
auto-approve-bot merged 10 commits from fix/tts-panel-voices-api into develop 2026-07-30 17:11:17 +08:00
5 changed files with 304 additions and 57 deletions
@@ -3531,52 +3531,143 @@
color: var(--text-tertiary, #9ca3af);
}
.tts-voice-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
.tts-voice-tabs {
display: flex;
gap: 4px;
background: var(--bg-secondary, #f3f4f6);
border-radius: var(--radius-md, 8px);
padding: 3px;
}
.tts-voice-card {
.tts-voice-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px;
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: var(--radius-md, 8px);
background: var(--bg-surface, #fff);
justify-content: center;
gap: 6px;
padding: 6px 12px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #6b7280);
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
}
.tts-voice-card:hover:not(:disabled) {
border-color: var(--primary-300, #93c5fd);
color: var(--primary-600, #2563eb);
background: var(--primary-50, #eff6ff);
.tts-voice-tab:hover {
color: var(--text-primary, #1f2937);
}
.tts-voice-card.active {
border-color: var(--primary-500, #3b82f6);
.tts-voice-tab.active {
background: var(--bg-surface, #fff);
color: var(--primary-600, #2563eb);
background: var(--primary-50, #eff6ff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.tts-voice-tab-count {
font-size: 10px;
padding: 1px 6px;
background: var(--primary-100, #dbeafe);
color: var(--primary-600, #2563eb);
border-radius: 10px;
font-weight: 600;
}
.tts-voice-card:disabled {
opacity: 0.5;
cursor: not-allowed;
.tts-voice-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.tts-voice-card-icon {
font-size: 22px;
.tts-voice-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: var(--radius-md, 8px);
background: var(--bg-surface, #fff);
cursor: pointer;
transition: all 0.2s ease;
}
.tts-voice-card-name {
.tts-voice-item:hover {
border-color: var(--primary-300, #93c5fd);
background: var(--primary-50, #eff6ff);
}
.tts-voice-item.selected {
border-color: var(--primary-500, #3b82f6);
background: var(--primary-50, #eff6ff);
}
.tts-voice-item-icon {
font-size: 20px;
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary, #f3f4f6);
border-radius: 50%;
}
.tts-voice-item-info {
flex: 1;
min-width: 0;
}
.tts-voice-item-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1f2937);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tts-voice-item-desc {
font-size: 11px;
color: var(--text-tertiary, #9ca3af);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tts-voice-item-play {
flex-shrink: 0;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--primary-500, #3b82f6);
color: #fff;
border-radius: 50%;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.tts-voice-item-play:hover {
background: var(--primary-600, #2563eb);
transform: scale(1.08);
}
.tts-voice-empty {
padding: 20px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
background: var(--bg-secondary, #f3f4f6);
border-radius: var(--radius-md, 8px);
}
/* ── 滑块区域 ── */
@@ -6317,3 +6408,4 @@
padding: 16px;
text-align: center;
}
@@ -1,21 +1,36 @@
import React from "react"
import type { TTSVoice } from "@/api/tts"
import { VOICE_CATEGORY_MAP } from "../constants"
import React, { useState } from "react"
import type { UnifiedVoiceItem } from "@/api/voices"
export interface VoiceSelectorProps {
voices: TTSVoice[]
presetVoices: UnifiedVoiceItem[]
cloneVoices: UnifiedVoiceItem[]
selectedVoiceId: string
loading: boolean
playingVoiceId: string | null
onSelect: (voiceId: string) => void
onPlaySample: (voiceId: string, previewUrl: string | null) => void
}
type VoiceTab = "preset" | "clone"
const GENDER_ICON: Record<string, string> = {
male: "👨",
female: "👩",
young: "🧑",
}
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
voices,
presetVoices,
cloneVoices,
selectedVoiceId,
loading,
playingVoiceId,
onSelect,
onPlaySample,
}) => {
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
const [activeTab, setActiveTab] = useState<VoiceTab>("preset")
const currentList = activeTab === "preset" ? presetVoices : cloneVoices
return (
<div className="tts-voice-section">
@@ -23,23 +38,65 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
{loading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && selectedVoiceId === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && onSelect(voice.id)}
disabled={!voice || loading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
{/* Tab 切换 */}
<div className="tts-voice-tabs">
<button
className={`tts-voice-tab${activeTab === "preset" ? " active" : ""}`}
onClick={() => setActiveTab("preset")}
>
</button>
<button
className={`tts-voice-tab${activeTab === "clone" ? " active" : ""}`}
onClick={() => setActiveTab("clone")}
>
{cloneVoices.length > 0 && (
<span className="tts-voice-tab-count">{cloneVoices.length}</span>
)}
</button>
</div>
{/* 音色列表 */}
{loading ? (
<div className="tts-voice-empty"> ...</div>
) : currentList.length === 0 ? (
<div className="tts-voice-empty">
{activeTab === "preset" ? "暂无预设音色" : "暂无克隆音色,快去克隆一个吧"}
</div>
) : (
<div className="tts-voice-list">
{currentList.map((voice) => {
const isSelected = selectedVoiceId === voice.voice_id
const isPlaying = playingVoiceId === voice.voice_id
const icon = GENDER_ICON[voice.gender] ?? "✨"
return (
<div
key={voice.voice_id}
className={`tts-voice-item${isSelected ? " selected" : ""}`}
onClick={() => onSelect(voice.voice_id)}
>
<div className="tts-voice-item-icon">{icon}</div>
<div className="tts-voice-item-info">
<div className="tts-voice-item-name">{voice.name}</div>
<div className="tts-voice-item-desc">{voice.description || voice.language}</div>
</div>
<button
className="tts-voice-item-play"
onClick={(e) => {
e.stopPropagation()
onPlaySample(voice.voice_id, voice.preview_url ?? voice.audio_url)
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶"}
</button>
</div>
)
})}
</div>
)}
</div>
)
}
@@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "reac
import { message } from "antd"
import type { TtsConfig, TtsMode } from "../../../types"
import { DEFAULT_TTS_CONFIG } from "../../../types"
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
import { previewTts } from "@/api/tts"
import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"
interface UseTtsPanelOptions {
open: boolean
@@ -12,10 +13,17 @@ interface UseTtsPanelOptions {
}
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
/* ── 音色列表(统一音色:预设 + 克隆) ── */
const [presetVoices, setPresetVoices] = useState<UnifiedVoiceItem[]>([])
const [cloneVoices, setCloneVoices] = useState<UnifiedVoiceItem[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
// 用 ref 保存最新 config 和 onChange,避免 useEffect 闭包陷阱
const configRef = useRef(config)
const onChangeRef = useRef(onChange)
configRef.current = config
onChangeRef.current = onChange
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
@@ -27,9 +35,20 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
mountedRef.current = true
let active = true
setVoicesLoading(true)
getTtsVoices()
.then((v) => {
if (active) setVoices(v)
fetchVoices({ status: "ready", limit: 100 })
.then((res) => {
if (!active) return
const presets = res.items.filter((v) => v.type === "preset")
const clones = res.items.filter((v) => v.type === "clone")
setPresetVoices(presets)
setCloneVoices(clones)
// 如果当前没有选中音色,默认选第一个预设(用 ref 读最新值,避免闭包旧值覆盖用户选择)
if (!configRef.current.voice_id && presets.length > 0) {
onChangeRef.current({
...configRef.current,
voice_id: presets[0].voice_id,
})
}
})
.catch(() => {
if (active) message.error("加载音色列表失败")
@@ -40,6 +59,7 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
return () => {
active = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
/* ── 组件卸载时清理音频资源 ── */
@@ -145,6 +165,51 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
}
}, [config])
/* ── 单音色示例试听 ── */
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
const handlePlayVoiceSample = useCallback(
(voiceId: string, previewUrl: string | null) => {
if (playingVoiceId === voiceId) {
audioRef.current?.pause()
audioRef.current = null
setPlayingVoiceId(null)
return
}
// 暂停上一个音频并清除事件监听,避免竞态
if (audioRef.current) {
audioRef.current.onended = null
audioRef.current.pause()
}
if (!previewUrl) {
message.warning("该音色暂无示例音频")
return
}
const audio = new Audio(previewUrl)
audioRef.current = audio
audio
.play()
.then(() => {
// 只有当前活跃的音频实例才能更新状态,防止快速切换竞态
if (mountedRef.current && audioRef.current === audio) {
setPlayingVoiceId(voiceId)
}
})
.catch(() => {
if (mountedRef.current && audioRef.current === audio) {
message.error("播放失败")
audioRef.current = null
}
})
audio.onended = () => {
if (mountedRef.current && audioRef.current === audio) {
setPlayingVoiceId(null)
audioRef.current = null
}
}
},
[playingVoiceId],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG })
@@ -155,16 +220,20 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
mountedRef.current = false
audioRef.current?.pause()
audioRef.current = null
setPlayingVoiceId(null)
onClose()
}, [onClose])
return {
voices,
presetVoices,
cloneVoices,
voicesLoading,
previewLoading,
playingVoiceId,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handlePlayVoiceSample,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
@@ -1,6 +1,6 @@
/**
* TTS Drawer
* + + + // + +
* + + + + // + +
*/
import React from "react"
import { Drawer } from "antd"
@@ -21,12 +21,15 @@ export interface TtsPanelProps {
const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
const {
voices,
presetVoices,
cloneVoices,
voicesLoading,
previewLoading,
playingVoiceId,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handlePlayVoiceSample,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
@@ -83,10 +86,13 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
{/* 音色选择 */}
<VoiceSelector
voices={voices}
presetVoices={presetVoices}
cloneVoices={cloneVoices}
selectedVoiceId={config.voice_id}
loading={voicesLoading}
playingVoiceId={playingVoiceId}
onSelect={handleVoiceSelect}
onPlaySample={handlePlayVoiceSample}
/>
{/* 语速 */}
@@ -0,0 +1,23 @@
/**
* TtsPanel smoke test
* TtsPanel
* vitest related TtsPanel/
*/
import { describe, it, expect } from "vitest"
// TtsPanel 主组件与子组件
import "@/pages/editing-planner/components/TtsPanel"
import "@/pages/editing-planner/components/TtsPanel/components/VoiceSelector"
import "@/pages/editing-planner/components/TtsPanel/components/TtsSlider"
// TtsPanel Hooks
import "@/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel"
// TtsPanel 常量
import "@/pages/editing-planner/components/TtsPanel/constants"
describe("TtsPanel module smoke test", () => {
it("should load all TtsPanel sub-modules", () => {
expect(true).toBe(true)
})
})