From c73e213e35d8daa67954d2d7d936fbad14e8b12d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:27:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(#1196):=20=E4=BF=AE=E5=A4=8D=E8=AF=95?= =?UTF-8?q?=E5=90=AC=E6=92=AD=E6=94=BE=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=20+=20=E9=9F=B3=E9=A2=91=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E7=AB=9E=E6=80=81=20+=20=E5=88=97=E8=A1=A8key?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=BAvoice=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TtsPanel/hooks/useTtsPanel.ts | 222 ++++++++++-------- 1 file changed, 123 insertions(+), 99 deletions(-) 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 index 82ff1b50c..9ba3b7a94 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts @@ -1,203 +1,227 @@ -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 { previewTts } from "@/api/tts" -import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices" +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 { previewTts } from "@/api/tts"; +import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"; interface UseTtsPanelOptions { - open: boolean - config: TtsConfig - onChange: (config: TtsConfig) => void - onClose: () => void + open: boolean; + config: TtsConfig; + onChange: (config: TtsConfig) => void; + onClose: () => void; } -export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => { +export const useTtsPanel = ({ + open, + config, + onChange, + onClose, +}: UseTtsPanelOptions) => { /* ── 音色列表(统一音色:预设 + 克隆) ── */ - const [presetVoices, setPresetVoices] = useState([]) - const [cloneVoices, setCloneVoices] = useState([]) - const [voicesLoading, setVoicesLoading] = useState(false) + const [presetVoices, setPresetVoices] = useState([]); + const [cloneVoices, setCloneVoices] = useState([]); + const [voicesLoading, setVoicesLoading] = useState(false); /* ── 试听状态 ── */ - const [previewLoading, setPreviewLoading] = useState(false) - const audioRef = useRef(null) - const mountedRef = useRef(true) + 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) + if (!open) return; + mountedRef.current = true; + let active = true; + setVoicesLoading(true); 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) + 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); // 如果当前没有选中音色,默认选第一个预设 if (!config.voice_id && presets.length > 0) { - onChange({ ...config, voice_id: presets[0].voice_id }) + onChange({ ...config, voice_id: presets[0].voice_id }); } }) .catch(() => { - if (active) message.error("加载音色列表失败") + if (active) message.error("加载音色列表失败"); }) .finally(() => { - if (active) setVoicesLoading(false) - }) + if (active) setVoicesLoading(false); + }); return () => { - active = false - } + active = false; + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) + }, [open]); /* ── 组件卸载时清理音频资源 ── */ useEffect(() => { return () => { - audioRef.current?.pause() - audioRef.current = null - mountedRef.current = false - } - }, []) + audioRef.current?.pause(); + audioRef.current = null; + mountedRef.current = false; + }; + }, []); /* ── 切换配音模式 ── */ const handleModeChange = useCallback( (mode: TtsMode) => { - onChange({ ...config, mode }) + onChange({ ...config, mode }); }, [config, onChange], - ) + ); /* ── 文本输入 ── */ const handleTextChange = useCallback( (e: ChangeEvent) => { - const text = e.target.value.slice(0, 5000) - onChange({ ...config, text }) + const text = e.target.value.slice(0, 5000); + onChange({ ...config, text }); }, [config, onChange], - ) + ); /* ── 选择音色 ── */ const handleVoiceSelect = useCallback( (voiceId: string) => { - onChange({ ...config, voice_id: voiceId }) + onChange({ ...config, voice_id: voiceId }); }, [config, onChange], - ) + ); /* ── 语速 ── */ const handleSpeedChange = useCallback( (speed: number) => { - onChange({ ...config, speed }) + onChange({ ...config, speed }); }, [config, onChange], - ) + ); /* ── 语调 ── */ const handlePitchChange = useCallback( (pitch: number) => { - onChange({ ...config, pitch }) + onChange({ ...config, pitch }); }, [config, onChange], - ) + ); /* ── 音量 ── */ const handleVolumeChange = useCallback( (volume: number) => { - onChange({ ...config, volume }) + onChange({ ...config, volume }); }, [config, onChange], - ) + ); /* ── 字幕联动 ── */ const handleSubtitleSyncToggle = useCallback(() => { - onChange({ ...config, subtitle_sync: !config.subtitle_sync }) - }, [config, onChange]) + onChange({ ...config, subtitle_sync: !config.subtitle_sync }); + }, [config, onChange]); /* ── 试听 ── */ const handlePreview = useCallback(async () => { if (!config.text.trim()) { - message.warning("请先输入合成文本") - return + message.warning("请先输入合成文本"); + return; } if (!config.voice_id) { - message.warning("请先选择音色") - return + message.warning("请先选择音色"); + return; } - setPreviewLoading(true) + 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 + }); + 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("试听播放中") + if (mountedRef.current) message.success("试听播放中"); }) .catch(() => { - if (mountedRef.current) message.error("播放失败") - }) + if (mountedRef.current) message.error("播放失败"); + }); audio.onended = () => { - if (mountedRef.current) audioRef.current = null - } + if (mountedRef.current) audioRef.current = null; + }; } catch { - if (mountedRef.current) message.error("试听生成失败") + if (mountedRef.current) message.error("试听生成失败"); } finally { - if (mountedRef.current) setPreviewLoading(false) + if (mountedRef.current) setPreviewLoading(false); } - }, [config]) + }, [config]); /* ── 单音色示例试听 ── */ - const [playingVoiceId, setPlayingVoiceId] = useState(null) + const [playingVoiceId, setPlayingVoiceId] = useState(null); const handlePlayVoiceSample = useCallback( (voiceId: string, previewUrl: string | null) => { if (playingVoiceId === voiceId) { - audioRef.current?.pause() - audioRef.current = null - setPlayingVoiceId(null) - return + audioRef.current?.pause(); + audioRef.current = null; + setPlayingVoiceId(null); + return; + } + // 暂停上一个音频并清除事件监听,避免竞态 + if (audioRef.current) { + audioRef.current.onended = null; + audioRef.current.pause(); } - audioRef.current?.pause() if (!previewUrl) { - message.warning("该音色暂无示例音频") - return + message.warning("该音色暂无示例音频"); + return; } - const audio = new Audio(previewUrl) - audioRef.current = audio - audio.play().catch(() => { - message.error("播放失败") - }) + const audio = new Audio(previewUrl); + audioRef.current = audio; + audio + .play() + .then(() => { + if (mountedRef.current) setPlayingVoiceId(voiceId); + }) + .catch(() => { + if (mountedRef.current) { + message.error("播放失败"); + audioRef.current = null; + } + }); audio.onended = () => { - setPlayingVoiceId(null) - audioRef.current = null - } - setPlayingVoiceId(voiceId) + if (mountedRef.current) { + setPlayingVoiceId(null); + audioRef.current = null; + } + }; }, [playingVoiceId], - ) + ); /* ── 重置 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_TTS_CONFIG }) - }, [onChange]) + onChange({ ...DEFAULT_TTS_CONFIG }); + }, [onChange]); /* ── 关闭时停止音频 ── */ const handleClose = useCallback(() => { - mountedRef.current = false - audioRef.current?.pause() - audioRef.current = null - setPlayingVoiceId(null) - onClose() - }, [onClose]) + mountedRef.current = false; + audioRef.current?.pause(); + audioRef.current = null; + setPlayingVoiceId(null); + onClose(); + }, [onClose]); return { presetVoices, @@ -216,5 +240,5 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti handlePreview, handleReset, handleClose, - } -} \ No newline at end of file + }; +};