diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/components/TtsSlider.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/components/TtsSlider.tsx new file mode 100644 index 000000000..b7c1096f5 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/components/TtsSlider.tsx @@ -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 = ({ + label, + value, + min, + max, + step, + onChange, + formatter, + marks, +}) => { + return ( +
+
+ {label} + {formatter(value)} +
+ (v != null ? formatter(v as number) : "") }} + /> + {marks && ( +
+ {marks.map((m, i) => ( + {m} + ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx new file mode 100644 index 000000000..651c50d40 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx @@ -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 = ({ + voices, + selectedVoiceId, + loading, + onSelect, +}) => { + const voiceCategories = Object.entries(VOICE_CATEGORY_MAP) + + return ( +
+
+ 选择音色 + {loading && 加载中...} +
+
+ {voiceCategories.map(([cat, info]) => { + const voice = voices.find((v) => v.category === cat) + const isSelected = voice && selectedVoiceId === voice.id + return ( + + ) + })} +
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/constants.ts b/apps/web/src/pages/editing-planner/components/TtsPanel/constants.ts new file mode 100644 index 000000000..b715c39cf --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/constants.ts @@ -0,0 +1,18 @@ +import type { TtsMode } from "../../types" + +/** 音色卡片分类图标 */ +export const VOICE_CATEGORY_MAP: Record = { + 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 合成" }, +] diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts new file mode 100644 index 000000000..2aa6297d1 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts @@ -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([]) + const [voicesLoading, setVoicesLoading] = useState(false) + + /* ── 试听状态 ── */ + const [previewLoading, setPreviewLoading] = useState(false) + const audioRef = useRef(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) => { + 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, + } +} diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx old mode 100755 new mode 100644 similarity index 85% rename from apps/web/src/pages/editing-planner/components/TtsPanel.tsx rename to apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx index 7ae3a9606..212f6cb35 --- a/apps/web/src/pages/editing-planner/components/TtsPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx @@ -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 = ({ 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 = ({ open, onClose, config, onChange }) {/* 音色选择 */} - {/* 语速滑块 */} + {/* 语速 */} `${v.toFixed(2)}x`} marks={["0.5x", "1.0x", "2.0x"]} - tooltipFormatter={(v) => `${v.toFixed(2)}x`} /> - {/* 语调滑块 */} + {/* 语调 */} `${v > 0 ? "+" : ""}${v} 半音`} marks={["-12", "0", "+12"]} - tooltipFormatter={(v) => `${v}半音`} /> - {/* 音量滑块 */} + {/* 音量 */} `${v}%`} + formatter={(v) => `${v}%`} /> {/* 试听按钮 */} diff --git a/apps/web/src/test/pages/editing-planner/components/TtsPanel/components/TtsSlider.test.tsx b/apps/web/src/test/pages/editing-planner/components/TtsPanel/components/TtsSlider.test.tsx new file mode 100644 index 000000000..1e2d1baec --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/TtsPanel/components/TtsSlider.test.tsx @@ -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) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/TtsPanel/components/VoiceSelector.test.tsx b/apps/web/src/test/pages/editing-planner/components/TtsPanel/components/VoiceSelector.test.tsx new file mode 100644 index 000000000..61d8d00ad --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/TtsPanel/components/VoiceSelector.test.tsx @@ -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) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/TtsPanel/constants.test.ts b/apps/web/src/test/pages/editing-planner/components/TtsPanel/constants.test.ts new file mode 100644 index 000000000..b58aa3823 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/TtsPanel/constants.test.ts @@ -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) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.test.ts b/apps/web/src/test/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.test.ts new file mode 100644 index 000000000..3b8b279df --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.test.ts @@ -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) + }) +})