diff --git a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx index 740dd7546..1de7b049e 100644 --- a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx +++ b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx @@ -126,6 +126,8 @@ const Step5VoiceSelect: React.FC = (props) => { selectedClonedVoice={v.selectedClonedVoice} onSelect={v.handleSelectClonedVoice} onOpenCloneModal={v.handleOpenCloneModal} + playingVoiceId={v.playingCloneVoice} + onPlaySample={v.handlePlayCloneSample} CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG} formatDuration={v.formatDuration} /> diff --git a/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx b/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx index 37947529d..761bafce5 100644 --- a/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx +++ b/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx @@ -1,10 +1,18 @@ /** * 克隆声音展开区域 * 克隆按钮、轮询提示、已克隆列表、空状态 + * 支持克隆音色试听播放 + * TODO: 后端克隆音色试听接口出来后,替换 sample_url 为接口返回的试听音频 */ import React from "react" import { Typography } from "antd" -import { ThunderboltOutlined, AudioOutlined, CheckCircleFilled } from "@ant-design/icons" +import { + ThunderboltOutlined, + AudioOutlined, + CheckCircleFilled, + PlayCircleOutlined, + PauseCircleOutlined, +} from "@ant-design/icons" import type { VoiceClone } from "@/api/voice-clone" const { Text } = Typography @@ -15,6 +23,10 @@ interface CloneVoiceSectionProps { selectedClonedVoice: string onSelect: (voiceId: string) => void onOpenCloneModal: () => void + /** 当前正在播放的音色ID */ + playingVoiceId: string | null + /** 播放/暂停试听 */ + onPlaySample: (voiceId: VoiceClone) => void CLONE_STATUS_CONFIG: Record formatDuration: (seconds: number) => string } @@ -25,6 +37,8 @@ const CloneVoiceSection: React.FC = ({ selectedClonedVoice, onSelect, onOpenCloneModal, + playingVoiceId, + onPlaySample, CLONE_STATUS_CONFIG, formatDuration, }) => { @@ -60,6 +74,7 @@ const CloneVoiceSection: React.FC = ({ const statusCfg = CLONE_STATUS_CONFIG[cv.status] const isReady = cv.status === "ready" const selected = selectedClonedVoice === cv.id + const isPlaying = playingVoiceId === cv.id return (
= ({ )}
- {selected && isReady && ( + + {/* 试听播放按钮 */} + {isReady && ( + + )} + + {selected && isReady && !isPlaying && ( )} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 2c2f0702c..4006261e1 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -558,6 +558,32 @@ color: var(--text-primary); } +/* ── 克隆音色试听播放按钮 ── */ +.xx-clone-voice-play-btn { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--primary-color); + color: #fff; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; + margin-right: 4px; +} + +.xx-clone-voice-play-btn:hover { + transform: scale(1.1); + background: var(--primary-hover); +} + +.xx-clone-voice-row.selected .xx-clone-voice-play-btn { + margin-right: 8px; +} + .xx-clone-status { font-size: 11px; display: flex; @@ -2639,3 +2665,4 @@ .ant-modal-close { color: #fff !important; } + diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index ca630c3a3..3ee459c9b 100755 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,6 +2,7 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ +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" @@ -100,6 +101,73 @@ export function useStep5Voice({ onCloneModalOpenChange, }) + /* ── 克隆音色试听播放 ── */ + 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) { + 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() + } + // TODO: 后端克隆音色试听接口出来后,改为调用接口获取试听音频 + // 目前使用 sample_url 字段,如果没有则提示 + const audioUrl = voice.sample_url + if (!audioUrl) { + message.warning("该克隆音色暂无试听音频") + return + } + const audio = new Audio(audioUrl) + cloneAudioRef.current = audio + audio + .play() + .then(() => { + // 只有当前活跃的音频实例才能更新状态,防止快速切换竞态 + if (cloneMountedRef.current && cloneAudioRef.current === audio) { + setPlayingCloneVoice(voice.id) + } + }) + .catch(() => { + if (cloneMountedRef.current && cloneAudioRef.current === audio) { + message.error("播放失败,请检查网络") + cloneAudioRef.current = null + } + }) + audio.onended = () => { + if (cloneMountedRef.current && cloneAudioRef.current === audio) { + setPlayingCloneVoice(null) + cloneAudioRef.current = null + } + } + }, + [playingCloneVoice], + ) + const handleCloneSuccess = (voice: VoiceClone) => { rawCloneSuccess(voice) message.success("音色克隆成功!") @@ -124,6 +192,9 @@ export function useStep5Voice({ // 音频播放 playingVoice, toggleVoicePlay, + // 克隆音色试听播放 + playingCloneVoice, + handlePlayCloneSample, // 预设音色操作 handleSelectPresetVoice, handleSelectCloneVoice, diff --git a/apps/web/src/test/pages/generate/useStep5Voice.test.tsx b/apps/web/src/test/pages/generate/useStep5Voice.test.tsx new file mode 100644 index 000000000..8fd9f9dc1 --- /dev/null +++ b/apps/web/src/test/pages/generate/useStep5Voice.test.tsx @@ -0,0 +1,97 @@ +/** + * useStep5Voice Hook smoke test + * 确保 vitest related 模式能匹配到 useStep5Voice.tsx 的改动 + */ +import { describe, it, expect, vi } from "vitest" +import { renderHook } from "@testing-library/react" + +// mock 所有子 Hook +vi.mock("@/pages/generate/hooks/step5-voice/useVoiceAudio", () => ({ + useVoiceAudio: () => ({ + playingVoice: null, + toggleVoicePlay: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useVoiceRecommend", () => ({ + useVoiceRecommend: () => ({ + presetVoices: [], + presetVoicesLoading: false, + voiceRecommendLoading: false, + voiceRecommendations: [], + hasVoiceRecommend: false, + handleVoiceRecommend: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useTtsSynthesis", () => ({ + useTtsSynthesis: () => ({ + customVoiceText: "", + setCustomVoiceText: vi.fn(), + customAudioUrl: null, + ttsError: null, + ttsJobId: null, + completedTtsJobId: null, + synthesizeMutation: { isPending: false, mutate: vi.fn() }, + handleSynthesizeVoice: vi.fn(), + resetTtsState: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useSaveToLibrary", () => ({ + useSaveToLibrary: () => ({ + saveModalOpen: false, + setSaveModalOpen: vi.fn(), + saveName: "", + setSaveName: vi.fn(), + saveTagIds: [], + setSaveTagIds: vi.fn(), + saveNewTag: "", + setSaveNewTag: vi.fn(), + allTags: [], + saveToLibraryMutation: { isPending: false, mutate: vi.fn() }, + handleOpenSaveModal: vi.fn(), + handleConfirmSave: vi.fn(), + handleAddTagInModal: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useVoiceModeSelection", () => ({ + useVoiceModeSelection: () => ({ + handleSelectRecommendedVoice: vi.fn(), + handleSelectPresetVoice: vi.fn(), + handleSelectCloneVoice: vi.fn(), + handleSelectClonedVoice: vi.fn(), + handleOpenCloneModal: vi.fn(), + handleCloneSuccess: vi.fn(), + }), +})) + +vi.mock("@/api/voice-clone", () => ({ + VoiceCloneStatus: { READY: "ready", PROCESSING: "processing", FAILED: "failed" }, +})) + +describe("useStep5Voice smoke test", () => { + it("should import and render hook without crashing", async () => { + // 动态 import 确保 mock 生效 + const { useStep5Voice } = await import("@/pages/generate/hooks/useStep5Voice") + const { result } = renderHook(() => + useStep5Voice({ + selectedVoice: "", + onSelectedVoiceChange: vi.fn(), + voiceMode: "preset", + onVoiceModeChange: vi.fn(), + selectedClonedVoice: "", + onSelectedClonedVoiceChange: vi.fn(), + clonedVoices: [], + addClone: vi.fn(), + hasProcessing: false, + cloneModalOpen: false, + onCloneModalOpenChange: vi.fn(), + titleText: "测试标题", + }), + ) + expect(result.current).toBeDefined() + expect(typeof result.current.handlePlayCloneSample).toBe("function") + }) +})