refactor(TtsPanel): TTS配音面板目录化拆分(332→173行, -48%) #1193
@@ -0,0 +1,48 @@
|
||||
import React from "react"
|
||||
import { Slider } from "antd"
|
||||
|
||||
export interface TtsSliderProps {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
onChange: (val: number) => void
|
||||
formatter: (val: number) => string
|
||||
marks?: string[]
|
||||
}
|
||||
|
||||
export const TtsSlider: React.FC<TtsSliderProps> = ({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
formatter,
|
||||
marks,
|
||||
}) => {
|
||||
return (
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">{label}</span>
|
||||
<span className="tts-slider-value">{formatter(value)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tooltip={{ formatter: (v) => (v != null ? formatter(v as number) : "") }}
|
||||
/>
|
||||
{marks && (
|
||||
<div className="tts-slider-marks">
|
||||
{marks.map((m, i) => (
|
||||
<span key={i}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import type { TTSVoice } from "@/api/tts"
|
||||
import { VOICE_CATEGORY_MAP } from "../constants"
|
||||
|
||||
export interface VoiceSelectorProps {
|
||||
voices: TTSVoice[]
|
||||
selectedVoiceId: string
|
||||
loading: boolean
|
||||
onSelect: (voiceId: string) => void
|
||||
}
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
voices,
|
||||
selectedVoiceId,
|
||||
loading,
|
||||
onSelect,
|
||||
}) => {
|
||||
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
|
||||
|
||||
return (
|
||||
<div className="tts-voice-section">
|
||||
<div className="tts-voice-label">
|
||||
选择音色
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TtsMode } from "../../types"
|
||||
|
||||
/** 音色卡片分类图标 */
|
||||
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
|
||||
male: { icon: "👨", label: "男声" },
|
||||
female: { icon: "👩", label: "女声" },
|
||||
young: { icon: "🧑", label: "少年" },
|
||||
service: { icon: "🎧", label: "客服" },
|
||||
news: { icon: "📰", label: "新闻" },
|
||||
emotion: { icon: "🎭", label: "情感" },
|
||||
}
|
||||
|
||||
/** 配音模式选项 */
|
||||
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
|
||||
{ mode: "none", icon: "🔇", label: "无配音" },
|
||||
{ mode: "upload", icon: "📁", label: "上传配音" },
|
||||
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
|
||||
]
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"
|
||||
import { message } from "antd"
|
||||
import type { TtsConfig, TtsMode } from "../../../types"
|
||||
import { DEFAULT_TTS_CONFIG } from "../../../types"
|
||||
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
|
||||
|
||||
interface UseTtsPanelOptions {
|
||||
open: boolean
|
||||
config: TtsConfig
|
||||
onChange: (config: TtsConfig) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
|
||||
/* ── 音色列表 ── */
|
||||
const [voices, setVoices] = useState<TTSVoice[]>([])
|
||||
const [voicesLoading, setVoicesLoading] = useState(false)
|
||||
|
||||
/* ── 试听状态 ── */
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
/* ── 加载音色列表 + 重置挂载状态 ── */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
mountedRef.current = true
|
||||
let active = true
|
||||
setVoicesLoading(true)
|
||||
getTtsVoices()
|
||||
.then((v) => {
|
||||
if (active) setVoices(v)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) message.error("加载音色列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setVoicesLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* ── 组件卸载时清理音频资源 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 切换配音模式 ── */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TtsMode) => {
|
||||
onChange({ ...config, mode })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 文本输入 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value.slice(0, 5000)
|
||||
onChange({ ...config, text })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 选择音色 ── */
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
onChange({ ...config, voice_id: voiceId })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 语速 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
onChange({ ...config, speed })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 语调 ── */
|
||||
const handlePitchChange = useCallback(
|
||||
(pitch: number) => {
|
||||
onChange({ ...config, pitch })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 音量 ── */
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number) => {
|
||||
onChange({ ...config, volume })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 字幕联动 ── */
|
||||
const handleSubtitleSyncToggle = useCallback(() => {
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
|
||||
}, [config, onChange])
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!config.text.trim()) {
|
||||
message.warning("请先输入合成文本")
|
||||
return
|
||||
}
|
||||
if (!config.voice_id) {
|
||||
message.warning("请先选择音色")
|
||||
return
|
||||
}
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: config.text.slice(0, 200),
|
||||
voice_id: config.voice_id,
|
||||
speed: config.speed,
|
||||
pitch: config.pitch,
|
||||
})
|
||||
if (!mountedRef.current) return
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(res.audio_url)
|
||||
audioRef.current = audio
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
if (mountedRef.current) message.success("试听播放中")
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) message.error("播放失败")
|
||||
})
|
||||
audio.onended = () => {
|
||||
if (mountedRef.current) audioRef.current = null
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) message.error("试听生成失败")
|
||||
} finally {
|
||||
if (mountedRef.current) setPreviewLoading(false)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TTS_CONFIG })
|
||||
}, [onChange])
|
||||
|
||||
/* ── 关闭时停止音频 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
mountedRef.current = false
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
return {
|
||||
voices,
|
||||
voicesLoading,
|
||||
previewLoading,
|
||||
handleModeChange,
|
||||
handleTextChange,
|
||||
handleVoiceSelect,
|
||||
handleSpeedChange,
|
||||
handlePitchChange,
|
||||
handleVolumeChange,
|
||||
handleSubtitleSyncToggle,
|
||||
handlePreview,
|
||||
handleReset,
|
||||
handleClose,
|
||||
}
|
||||
}
|
||||
Executable → Regular
+16
-18
@@ -4,13 +4,14 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { TtsConfig } from "@/pages/editing-planner/types"
|
||||
import { TTS_MODE_OPTIONS } from "@/pages/editing-planner/constants/tts"
|
||||
import VoiceSelector from "./tts/VoiceSelector"
|
||||
import TtsSlider from "./tts/TtsSlider"
|
||||
import { useTtsPanel } from "@/pages/editing-planner/hooks/useTtsPanel"
|
||||
import type { TtsConfig } from "../../types"
|
||||
|
||||
interface TtsPanelProps {
|
||||
import { useTtsPanel } from "./hooks/useTtsPanel"
|
||||
import { VoiceSelector } from "./components/VoiceSelector"
|
||||
import { TtsSlider } from "./components/TtsSlider"
|
||||
import { TTS_MODE_OPTIONS } from "./constants"
|
||||
|
||||
export interface TtsPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前片段 TTS 配置 */
|
||||
@@ -74,7 +75,7 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
|
||||
className="tts-text-input"
|
||||
placeholder="请输入需要合成的文本内容..."
|
||||
value={config.text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onChange={handleTextChange}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
/>
|
||||
@@ -83,47 +84,44 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
|
||||
{/* 音色选择 */}
|
||||
<VoiceSelector
|
||||
voices={voices}
|
||||
voicesLoading={voicesLoading}
|
||||
selectedVoiceId={config.voice_id}
|
||||
onVoiceSelect={handleVoiceSelect}
|
||||
loading={voicesLoading}
|
||||
onSelect={handleVoiceSelect}
|
||||
/>
|
||||
|
||||
{/* 语速滑块 */}
|
||||
{/* 语速 */}
|
||||
<TtsSlider
|
||||
label="语速"
|
||||
value={config.speed}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.05}
|
||||
unit="x"
|
||||
onChange={handleSpeedChange}
|
||||
formatter={(v) => `${v.toFixed(2)}x`}
|
||||
marks={["0.5x", "1.0x", "2.0x"]}
|
||||
tooltipFormatter={(v) => `${v.toFixed(2)}x`}
|
||||
/>
|
||||
|
||||
{/* 语调滑块 */}
|
||||
{/* 语调 */}
|
||||
<TtsSlider
|
||||
label="语调"
|
||||
value={config.pitch}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
unit="半音"
|
||||
onChange={handlePitchChange}
|
||||
formatter={(v) => `${v > 0 ? "+" : ""}${v} 半音`}
|
||||
marks={["-12", "0", "+12"]}
|
||||
tooltipFormatter={(v) => `${v}半音`}
|
||||
/>
|
||||
|
||||
{/* 音量滑块 */}
|
||||
{/* 音量 */}
|
||||
<TtsSlider
|
||||
label="音量"
|
||||
value={config.volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
unit="%"
|
||||
onChange={handleVolumeChange}
|
||||
tooltipFormatter={(v) => `${v}%`}
|
||||
formatter={(v) => `${v}%`}
|
||||
/>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for TtsSlider.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/TtsPanel/components/TtsSlider"
|
||||
|
||||
describe("TtsSlider.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for VoiceSelector.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/TtsPanel/components/VoiceSelector"
|
||||
|
||||
describe("VoiceSelector.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for constants.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/TtsPanel/constants"
|
||||
|
||||
describe("constants.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for useTtsPanel.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel"
|
||||
|
||||
describe("useTtsPanel.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user