diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index 6256826b4..89fbe68e1 100755 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,30 +2,30 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ -import { useState, useRef, useCallback } from "react" -import { message } from "antd" -import type { VoiceClone } from "@/api/voice-clone" -import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" -import { formatDuration } from "../utils/formatDuration" -import { useVoiceAudio } from "./step5-voice/useVoiceAudio" -import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend" -import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis" -import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary" -import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection" +import { useState, useRef, useCallback, useEffect } from "react"; +import { message } from "antd"; +import type { VoiceClone } from "@/api/voice-clone"; +import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"; +import { formatDuration } from "../utils/formatDuration"; +import { useVoiceAudio } from "./step5-voice/useVoiceAudio"; +import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"; +import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"; +import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"; +import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection"; interface UseStep5VoiceProps { - selectedVoice: string - onSelectedVoiceChange: (voiceId: string) => void - voiceMode: "preset" | "custom" | "clone" - onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void - selectedClonedVoice: string - onSelectedClonedVoiceChange: (voiceId: string) => void - clonedVoices: VoiceClone[] - addClone: (voice: VoiceClone) => void - hasProcessing: boolean - cloneModalOpen: boolean - onCloneModalOpenChange: (open: boolean) => void - titleText: string + selectedVoice: string; + onSelectedVoiceChange: (voiceId: string) => void; + voiceMode: "preset" | "custom" | "clone"; + onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void; + selectedClonedVoice: string; + onSelectedClonedVoiceChange: (voiceId: string) => void; + clonedVoices: VoiceClone[]; + addClone: (voice: VoiceClone) => void; + hasProcessing: boolean; + cloneModalOpen: boolean; + onCloneModalOpenChange: (open: boolean) => void; + titleText: string; } export function useStep5Voice({ @@ -43,7 +43,7 @@ export function useStep5Voice({ titleText, }: UseStep5VoiceProps) { /* ── 子模块 ── */ - const { playingVoice, toggleVoicePlay } = useVoiceAudio() + const { playingVoice, toggleVoicePlay } = useVoiceAudio(); const { presetVoices, @@ -52,7 +52,7 @@ export function useStep5Voice({ voiceRecommendations, hasVoiceRecommend, handleVoiceRecommend, - } = useVoiceRecommend(titleText) + } = useVoiceRecommend(titleText); const { customVoiceText, @@ -64,7 +64,7 @@ export function useStep5Voice({ synthesizeMutation, handleSynthesizeVoice, resetTtsState, - } = useTtsSynthesis(selectedVoice) + } = useTtsSynthesis(selectedVoice); const { saveModalOpen, @@ -80,7 +80,7 @@ export function useStep5Voice({ handleOpenSaveModal, handleConfirmSave, handleAddTagInModal, - } = useSaveToLibrary(completedTtsJobId, resetTtsState) + } = useSaveToLibrary(completedTtsJobId, resetTtsState); const { handleSelectRecommendedVoice, @@ -99,46 +99,78 @@ export function useStep5Voice({ addClone, cloneModalOpen, onCloneModalOpenChange, - }) + }); /* ── 克隆音色试听播放 ── */ - const [playingCloneVoice, setPlayingCloneVoice] = useState(null) - const cloneAudioRef = useRef(null) + const [playingCloneVoice, setPlayingCloneVoice] = useState( + null, + ); + const cloneAudioRef = useRef(null); + const cloneMountedRef = useRef(true); + + // 组件卸载时清理克隆音色试听的音频资源 + useEffect(() => { + cloneMountedRef.current = true; + return () => { + cloneMountedRef.current = false; + if (cloneAudioRef.current) { + cloneAudioRef.current.onended = null; + cloneAudioRef.current.pause(); + cloneAudioRef.current = null; + } + }; + }, []); const handlePlayCloneSample = useCallback( (voice: VoiceClone) => { if (playingCloneVoice === voice.id) { - cloneAudioRef.current?.pause() - cloneAudioRef.current = null - setPlayingCloneVoice(null) - return + if (cloneAudioRef.current) { + cloneAudioRef.current.onended = null; + cloneAudioRef.current.pause(); + } + cloneAudioRef.current = null; + setPlayingCloneVoice(null); + return; + } + // 暂停上一个音频并清除事件监听,避免竞态 + if (cloneAudioRef.current) { + cloneAudioRef.current.onended = null; + cloneAudioRef.current.pause(); } - cloneAudioRef.current?.pause() // TODO: 后端克隆音色试听接口出来后,改为调用接口获取试听音频 // 目前使用 sample_url 字段,如果没有则提示 - const audioUrl = voice.sample_url + const audioUrl = voice.sample_url; if (!audioUrl) { - message.warning("该克隆音色暂无试听音频") - return + message.warning("该克隆音色暂无试听音频"); + return; } - const audio = new Audio(audioUrl) - cloneAudioRef.current = audio - audio.play().catch(() => { - message.error("播放失败,请检查网络") - }) + const audio = new Audio(audioUrl); + cloneAudioRef.current = audio; + audio + .play() + .then(() => { + if (cloneMountedRef.current) setPlayingCloneVoice(voice.id); + }) + .catch(() => { + if (cloneMountedRef.current) { + message.error("播放失败,请检查网络"); + cloneAudioRef.current = null; + } + }); audio.onended = () => { - setPlayingCloneVoice(null) - cloneAudioRef.current = null - } - setPlayingCloneVoice(voice.id) + if (cloneMountedRef.current) { + setPlayingCloneVoice(null); + cloneAudioRef.current = null; + } + }; }, [playingCloneVoice], - ) + ); const handleCloneSuccess = (voice: VoiceClone) => { - rawCloneSuccess(voice) - message.success("音色克隆成功!") - } + rawCloneSuccess(voice); + message.success("音色克隆成功!"); + }; return { // 数据 @@ -197,8 +229,7 @@ export function useStep5Voice({ VOICE_GENDER_ICON, CLONE_STATUS_CONFIG, formatDuration, - } + }; } -export default useStep5Voice - +export default useStep5Voice;