fix(#1195): 克隆音色增加试听播放功能,预留后端试听接口对接位 #1203

Merged
auto-approve-bot merged 9 commits from fix/clone-voice-playback into develop 2026-07-30 16:11:12 +08:00
5 changed files with 233 additions and 2 deletions
@@ -126,6 +126,8 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (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}
/>
@@ -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<string, { label: string; color: string }>
formatDuration: (seconds: number) => string
}
@@ -25,6 +37,8 @@ const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
selectedClonedVoice,
onSelect,
onOpenCloneModal,
playingVoiceId,
onPlaySample,
CLONE_STATUS_CONFIG,
formatDuration,
}) => {
@@ -60,6 +74,7 @@ const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
const isReady = cv.status === "ready"
const selected = selectedClonedVoice === cv.id
const isPlaying = playingVoiceId === cv.id
return (
<div
key={cv.id}
@@ -93,7 +108,26 @@ const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
)}
</div>
</div>
{selected && isReady && (
{/* 试听播放按钮 */}
{isReady && (
<button
className="xx-clone-voice-play-btn"
onClick={(e) => {
e.stopPropagation()
onPlaySample(cv)
}}
title={isPlaying ? "暂停试听" : "试听克隆音色"}
>
{isPlaying ? (
<PauseCircleOutlined style={{ fontSize: 18 }} />
) : (
<PlayCircleOutlined style={{ fontSize: 18 }} />
)}
</button>
)}
{selected && isReady && !isPlaying && (
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
)}
</div>
+27
View File
@@ -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;
}
@@ -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<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) {
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,
@@ -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")
})
})