fix(#1195): 克隆音色增加试听播放功能,预留后端试听接口对接位 #1203
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user