fix(#1195): 克隆音色播放失败状态修复 + 音频切换竞态防护 + 卸载清理
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m42s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m38s
AI Code Review / AI Code Review (pull_request) Failing after 3m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m57s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped

This commit is contained in:
2026-07-30 14:30:25 +08:00
parent 3e3ebf71ce
commit a19be2e304
@@ -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<string | null>(null)
const cloneAudioRef = useRef<HTMLAudioElement | null>(null)
const [playingCloneVoice, setPlayingCloneVoice] = useState<string | null>(
null,
);
const cloneAudioRef = useRef<HTMLAudioElement | null>(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;