From 76aa23d61f59300859ad95a5356c145bd0ec8edc Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 29 Aug 2026 21:34:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(voices):=20=E9=85=8D=E9=9F=B3=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=8D=A1=E7=89=87=E7=9C=9F=E5=AE=9E=E6=92=AD=E6=94=BE?= =?UTF-8?q?=20+=20=E9=A2=84=E7=BD=AE/=E5=85=8B=E9=9A=86=E9=9F=B3=E8=89=B2?= =?UTF-8?q?=20TTS=20=E8=AF=95=E5=90=AC=EF=BC=8C=E4=B8=89=20tab=20=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E7=9C=9F=E5=AE=9E=E6=92=AD=E6=94=BE=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 素材 tab 卡片新增播放区(播放按钮+波形+进度条+时间,视觉对齐预置音色卡片), 复用 voice-materials 的 new Audio 真实播放,file_url 为空时按钮置灰 - 废弃 pages/voices/hooks/useAudioPlayer.ts 的 setInterval 假播放,重写为 统一真实播放 hook:素材直链播放 + 预置/克隆音色 POST /tts/preview 合成试听 (示例文案固定,结果按 voiceId 内存缓存),timeupdate 驱动进度、 loadedmetadata 取真实时长、ended 复位、暂停/恢复/seek - 合成中按钮 LoadingOutlined 禁点;互斥播放(切卡片/切 tab/离开页面停止) - 合成失败 toast 由 apiClient 拦截器统一处理(含「克隆尚未完成」文案) - 删除废弃 MaterialVoiceCard.tsx(仅 smoke 测试引用),同步更新测试 --- apps/web/src/pages/voices/VoiceLibrary.tsx | 22 +- .../voices/components/CloneVoiceCard.tsx | 3 + .../voices/components/ClonedVoiceTab.tsx | 15 +- .../voices/components/MaterialVoiceCard.tsx | 37 -- .../voices/components/MaterialVoiceTab.tsx | 95 +++- .../voices/components/PresetVoiceTab.tsx | 17 +- .../src/pages/voices/components/VoiceCard.tsx | 23 +- .../clone-voice-card/CardFooter.tsx | 45 +- .../src/pages/voices/hooks/useAudioPlayer.ts | 226 +++++++--- apps/web/src/pages/voices/voices.css | 29 ++ .../pages/voices/hooks/useAudioPlayer.test.ts | 420 +++++++++--------- apps/web/src/test/pages/voices/smoke.test.tsx | 2 +- 12 files changed, 594 insertions(+), 340 deletions(-) delete mode 100755 apps/web/src/pages/voices/components/MaterialVoiceCard.tsx diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index b091374a4..a062c6df1 100755 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -77,14 +77,16 @@ const VoiceLibrary: React.FC = () => { showToast, }) - // ── 播放控制 ────────────────────────────────────────── + // ── 播放控制(三 tab 共用:真实 Audio 播放 + TTS 试听) ── const { playingId, + loadingId, currentTime, - handlePlay, + duration: playDuration, handlePause, + handleToggleVoice, + handleToggleMaterial, handleSeek, - handleTogglePlay, stopPlayback, } = useAudioPlayer() @@ -210,8 +212,10 @@ const VoiceLibrary: React.FC = () => { loading={presetLoading} voices={filteredPreset} playingId={playingId} + loadingId={loadingId} currentTime={currentTime} - onPlay={handlePlay} + playDuration={playDuration} + onToggle={handleToggleVoice} onPause={handlePause} onSeek={handleSeek} onClearFilters={handleClearFilters} @@ -224,9 +228,12 @@ const VoiceLibrary: React.FC = () => { loading={cloneLoading} voices={clonedVoices} playingId={playingId} + loadingId={loadingId} currentTime={currentTime} - onPlay={handleTogglePlay} + playDuration={playDuration} + onToggle={handleToggleVoice} onPause={handlePause} + onSeek={handleSeek} onUse={handleCloneUse} onDelete={handleCloneDelete} onRetry={handleCloneRetry} @@ -242,6 +249,11 @@ const VoiceLibrary: React.FC = () => { materials={materials as AssetItem[]} onOpenUpload={() => setUploadOpen(true)} onDelete={handleMaterialDelete} + playingId={playingId} + currentTime={currentTime} + playDuration={playDuration} + onToggle={handleToggleMaterial} + onSeek={handleSeek} /> )} diff --git a/apps/web/src/pages/voices/components/CloneVoiceCard.tsx b/apps/web/src/pages/voices/components/CloneVoiceCard.tsx index fbaaf9aa3..0c41a9984 100755 --- a/apps/web/src/pages/voices/components/CloneVoiceCard.tsx +++ b/apps/web/src/pages/voices/components/CloneVoiceCard.tsx @@ -8,9 +8,12 @@ import CardFooter from "./clone-voice-card/CardFooter" export interface CloneVoiceCardProps { voice: ClonedVoiceDisplay isPlaying: boolean + isLoading?: boolean currentTime: number + playDuration?: number onPlay: () => void onPause: () => void + onSeek?: (time: number) => void onUse: () => void onDelete: () => void onRetry: () => void diff --git a/apps/web/src/pages/voices/components/ClonedVoiceTab.tsx b/apps/web/src/pages/voices/components/ClonedVoiceTab.tsx index a3c578091..710032039 100755 --- a/apps/web/src/pages/voices/components/ClonedVoiceTab.tsx +++ b/apps/web/src/pages/voices/components/ClonedVoiceTab.tsx @@ -12,9 +12,12 @@ export interface ClonedVoiceTabProps { loading: boolean voices: ClonedVoiceDisplay[] playingId: string | null + loadingId: string | null currentTime: number - onPlay: (voiceId: string, duration: number) => void + playDuration: number + onToggle: (voiceId: string) => void onPause: () => void + onSeek: (time: number) => void onUse: (voice: ClonedVoiceDisplay) => void onDelete: (voice: ClonedVoiceDisplay) => void onRetry: (voice: ClonedVoiceDisplay) => void @@ -26,9 +29,12 @@ export const ClonedVoiceTab: React.FC = ({ loading, voices, playingId, + loadingId, currentTime, - onPlay, + playDuration, + onToggle, onPause, + onSeek, onUse, onDelete, onRetry, @@ -54,9 +60,12 @@ export const ClonedVoiceTab: React.FC = ({ key={voice.id} voice={voice} isPlaying={playingId === voice.id} + isLoading={loadingId === voice.id} currentTime={playingId === voice.id ? currentTime : 0} - onPlay={() => onPlay(voice.id, voice.duration)} + playDuration={playingId === voice.id ? playDuration : voice.duration} + onPlay={() => onToggle(voice.voiceId)} onPause={onPause} + onSeek={onSeek} onUse={() => onUse(voice)} onDelete={() => onDelete(voice)} onRetry={() => onRetry(voice)} diff --git a/apps/web/src/pages/voices/components/MaterialVoiceCard.tsx b/apps/web/src/pages/voices/components/MaterialVoiceCard.tsx deleted file mode 100755 index 429a9c333..000000000 --- a/apps/web/src/pages/voices/components/MaterialVoiceCard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react" -import { AudioOutlined } from "@ant-design/icons" -import { type AssetItem } from "@/api/assets" -import { formatFileSize } from "@/pages/voices/utils/format" - -export interface MaterialVoiceCardProps { - asset: AssetItem - onClick?: () => void -} - -/** 配音素材卡片 */ -const MaterialVoiceCard: React.FC = ({ asset, onClick }) => { - const duration = (asset.metadata?.duration as number) || 0 - const minutes = Math.floor(duration / 60) - const seconds = Math.floor(duration % 60) - - return ( -
-
- - - {minutes}:{seconds.toString().padStart(2, "0")} - -
-
-
- {asset.name} -
-
- {asset.file_size ? formatFileSize(asset.file_size) : "--"} -
-
-
- ) -} - -export default MaterialVoiceCard diff --git a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx index 187636b52..52d66d8eb 100644 --- a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx +++ b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx @@ -1,16 +1,35 @@ /** * VoiceLibrary 配音素材 Tab 内容 + * + * 播放状态由页面级统一 hook(VoiceLibrary/useAudioPlayer)下发, + * 与预置/克隆音色互斥:同一时刻全库只有一个音频在响。 */ -import React from "react" -import { SoundOutlined, UploadOutlined, AudioOutlined, DeleteOutlined } from "@ant-design/icons" +import React, { useRef } from "react" +import { + SoundOutlined, + UploadOutlined, + AudioOutlined, + DeleteOutlined, + PlayCircleOutlined, + PauseCircleOutlined, +} from "@ant-design/icons" import { Button } from "@/components/ui" import type { AssetItem } from "@/api/assets" +import { mapAssetToMaterial } from "@/pages/voice-materials/types" +import type { VoiceMaterial } from "@/pages/voice-materials/types" +import { formatTime } from "../utils/format" export interface MaterialVoiceTabProps { loading: boolean materials: AssetItem[] onOpenUpload: () => void onDelete: (asset: AssetItem) => void + // 播放控制(页面级统一 hook) + playingId: string | null + currentTime: number + playDuration: number + onToggle: (material: VoiceMaterial) => void + onSeek: (time: number) => void } export const MaterialVoiceTab: React.FC = ({ @@ -18,7 +37,31 @@ export const MaterialVoiceTab: React.FC = ({ materials, onOpenUpload, onDelete, + playingId, + currentTime, + playDuration, + onToggle, + onSeek, }) => { + const progressRefs = useRef>({}) + + /** 播放中点击/拖动进度条 seek;非播放态点击则开始播放 */ + const handleProgressClick = + (asset: AssetItem, material: VoiceMaterial, dur: number) => + (e: React.MouseEvent) => { + e.stopPropagation() + if (!asset.file_url) return + if (playingId !== asset.id) { + onToggle(material) + return + } + const el = progressRefs.current[asset.id] + if (!el || dur <= 0) return + const rect = el.getBoundingClientRect() + const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + onSeek(percent * dur) + } + return (
{/* 骨架屏加载 */} @@ -40,11 +83,15 @@ export const MaterialVoiceTab: React.FC = ({ {!loading && materials.length > 0 && (
{materials.map((asset: AssetItem) => { - const duration = (asset.metadata?.duration as number) || 0 - const minutes = Math.floor(duration / 60) - const seconds = Math.floor(duration % 60) + const material = mapAssetToMaterial(asset) + // duration 优先取顶层(后端从 metadata 提取),兜底 metadata + const cardDuration = asset.duration || material.duration || 0 + const isPlaying = playingId === asset.id + // 播放中以 audio 真实时长为准,未播放显示卡片时长 + const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration + const progress = effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0 return ( -
+
{/* hover 操作区:删除 */}
- - {minutes}:{seconds.toString().padStart(2, "0")} - + {formatTime(cardDuration)}
@@ -76,6 +121,38 @@ export const MaterialVoiceTab: React.FC = ({
+ + {/* 波形装饰条(与预置音色卡片一致) */} +
+ + {/* 播放控制区:真实音频播放 */} +
+ +
{ + progressRefs.current[asset.id] = el + }} + className="xx-voice-progress" + onClick={handleProgressClick(asset, material, effectiveDuration)} + > +
+
+ + {isPlaying ? formatTime(currentTime) : formatTime(cardDuration)} + +
) })} diff --git a/apps/web/src/pages/voices/components/PresetVoiceTab.tsx b/apps/web/src/pages/voices/components/PresetVoiceTab.tsx index 7399601e5..568333395 100644 --- a/apps/web/src/pages/voices/components/PresetVoiceTab.tsx +++ b/apps/web/src/pages/voices/components/PresetVoiceTab.tsx @@ -19,10 +19,12 @@ export interface PresetVoiceTabProps { loading: boolean voices: PresetVoiceDisplay[] playingId: string | null + loadingId: string | null currentTime: number - onPlay: (id: string, duration: number) => void + playDuration: number + onToggle: (voiceId: string) => void onPause: () => void - onSeek: (id: string, time: number, duration: number) => void + onSeek: (time: number) => void onClearFilters: () => void } @@ -36,8 +38,10 @@ export const PresetVoiceTab: React.FC = ({ loading, voices, playingId, + loadingId, currentTime, - onPlay, + playDuration, + onToggle, onPause, onSeek, onClearFilters, @@ -71,15 +75,16 @@ export const PresetVoiceTab: React.FC = ({ name={voice.name} subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`} tags={voice.tags} - duration={voice.duration} + duration={playingId === voice.id ? playDuration : voice.duration} gender={voice.gender} isPlaying={playingId === voice.id} + isLoading={loadingId === voice.id} isSelected={false} currentTime={playingId === voice.id ? currentTime : 0} starred={voice.starred} - onPlay={() => onPlay(voice.id, voice.duration)} + onPlay={() => onToggle(voice.voiceId)} onPause={onPause} - onSeek={(time) => onSeek(voice.id, time, voice.duration)} + onSeek={(time) => onSeek(time)} onToggleStar={() => {}} /> ))} diff --git a/apps/web/src/pages/voices/components/VoiceCard.tsx b/apps/web/src/pages/voices/components/VoiceCard.tsx index 9015056e7..4e9995dc9 100755 --- a/apps/web/src/pages/voices/components/VoiceCard.tsx +++ b/apps/web/src/pages/voices/components/VoiceCard.tsx @@ -4,6 +4,7 @@ import { PlayCircleOutlined, PauseCircleOutlined, HeartOutlined, + LoadingOutlined, } from "@ant-design/icons" import { type VoiceGender } from "@/pages/voices/types" import { genderClass, formatTime } from "@/pages/voices/utils/format" @@ -16,6 +17,7 @@ export interface VoiceCardProps { duration: number gender: VoiceGender isPlaying: boolean + isLoading?: boolean isSelected: boolean currentTime: number starred?: boolean @@ -36,6 +38,7 @@ const VoiceCard: React.FC = ({ duration, gender, isPlaying, + isLoading = false, isSelected, currentTime, starred, @@ -51,8 +54,12 @@ const VoiceCard: React.FC = ({ const handleProgressClick = (e: React.MouseEvent) => { if (!progressRef.current || status !== "ready") return const rect = progressRef.current.getBoundingClientRect() - const percent = (e.clientX - rect.left) / rect.width - onSeek(percent * duration) + const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + if (isPlaying && duration > 0) { + onSeek(percent * duration) + } else if (!isPlaying && !isLoading) { + onPlay() + } } const progress = duration > 0 ? (currentTime / duration) * 100 : 0 @@ -110,19 +117,27 @@ const VoiceCard: React.FC = ({
- {isPlaying ? formatTime(currentTime) : formatTime(duration)} + {isPlaying ? formatTime(currentTime) : duration > 0 ? formatTime(duration) : "试听"}
)} diff --git a/apps/web/src/pages/voices/components/clone-voice-card/CardFooter.tsx b/apps/web/src/pages/voices/components/clone-voice-card/CardFooter.tsx index 26983aab1..97f7e2f6f 100755 --- a/apps/web/src/pages/voices/components/clone-voice-card/CardFooter.tsx +++ b/apps/web/src/pages/voices/components/clone-voice-card/CardFooter.tsx @@ -1,13 +1,21 @@ import React from "react" -import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons" +import { + PlayCircleOutlined, + PauseCircleOutlined, + ReloadOutlined, + LoadingOutlined, +} from "@ant-design/icons" import { type ClonedVoiceDisplay } from "@/pages/voices/types" interface CardFooterProps { voice: ClonedVoiceDisplay isPlaying: boolean + isLoading?: boolean currentTime: number + playDuration?: number onPlay: () => void onPause: () => void + onSeek?: (time: number) => void onUse: () => void onRetry: () => void } @@ -15,14 +23,30 @@ interface CardFooterProps { const CardFooter: React.FC = ({ voice, isPlaying, + isLoading = false, currentTime, + playDuration, onPlay, onPause, + onSeek, onUse, onRetry, }) => { const isFailed = voice.status === "failed" const isProcessing = voice.status === "processing" + // 播放中以 audio 真实时长为准 + const effectiveDuration = isPlaying ? playDuration || voice.duration : voice.duration + + /** 播放中点击进度条 seek;非播放态点击触发播放 */ + const handleProgressClick = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + if (isPlaying && effectiveDuration > 0) { + onSeek?.(percent * effectiveDuration) + } else if (!isLoading) { + onPlay() + } + } return (
@@ -31,21 +55,30 @@ const CardFooter: React.FC = ({ -
+
0 + ? `${Math.min((currentTime / effectiveDuration) * 100, 100)}%` + : "0%", }} />
diff --git a/apps/web/src/pages/voices/hooks/useAudioPlayer.ts b/apps/web/src/pages/voices/hooks/useAudioPlayer.ts index 4dd395495..3cca5bd7a 100644 --- a/apps/web/src/pages/voices/hooks/useAudioPlayer.ts +++ b/apps/web/src/pages/voices/hooks/useAudioPlayer.ts @@ -1,102 +1,200 @@ -import { useState, useRef, useCallback, useEffect } from "react" - /** - * 音频播放控制 Hook - * 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新 - * (适用于预置音色/克隆音色卡片的播放按钮交互) + * 配音库真实音频播放控制 Hook(预置音色 / 克隆音色 / 配音素材三个 tab 共用) + * + * - 配音素材:直接播放 asset.file_url(用户上传的真实音频) + * - 预置/克隆音色:调 POST /tts/preview 合成本示例文案,拿到 audio_url 后真实播放, + * 合成结果按 voiceId 内存缓存,同一音色二次试听不重复合成 + * - 全库同一时刻只有一个 Audio 在响:切卡片 / 切 tab / 离开页面自动停止 + * - timeupdate 驱动进度条,loadedmetadata 取真实时长,ended 自动复位 + * + * 注意:合成失败的错误提示由 apiClient 拦截器统一 toast(含后端 + * 「音色克隆尚未完成,请稍后再试」文案),hook 内不重复提示。 */ +import { useState, useRef, useCallback, useEffect } from "react" +import { previewTts } from "@/api/tts" +import type { VoiceMaterial } from "@/pages/voice-materials/types" + +/** 卡片试听统一示例文案 */ +export const VOICE_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的配音效果,希望你喜欢。" + +interface PreviewCacheEntry { + url: string + duration?: number +} + export function useAudioPlayer() { const [playingId, setPlayingId] = useState(null) const [currentTime, setCurrentTime] = useState(0) - const intervalRef = useRef(null) + const [duration, setDuration] = useState(0) + const [loadingId, setLoadingId] = useState(null) - /** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */ - const handlePlay = useCallback( - (voiceId: string, duration: number, startTime: number = 0) => { - if (playingId === voiceId) return - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - setPlayingId(voiceId) - setCurrentTime(startTime) - intervalRef.current = window.setInterval(() => { - setCurrentTime((prev) => { - if (prev >= duration) { - if (intervalRef.current) { - clearInterval(intervalRef.current) - intervalRef.current = null - } - setPlayingId(null) - return 0 - } - return prev + 0.1 - }) - }, 100) - }, - [playingId], - ) + const audioRef = useRef(null) + const pausedRef = useRef<{ id: string; url: string; duration: number } | null>(null) + const previewCacheRef = useRef>(new Map()) + /** 试听合成请求序号:旧请求返回时丢弃,防止竞态 */ + const reqSeqRef = useRef(0) - /** 暂停播放 */ - const handlePause = useCallback(() => { - if (intervalRef.current) { - clearInterval(intervalRef.current) - intervalRef.current = null + /** 停止当前播放并复位状态 */ + const stopPlayback = useCallback(() => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null } + pausedRef.current = null setPlayingId(null) + setLoadingId(null) + setCurrentTime(0) + setDuration(0) }, []) - /** 跳转到指定时间 */ - const handleSeek = useCallback( - (voiceId: string, time: number, duration: number) => { - if (playingId !== voiceId) { - // 不同音色:从指定时间开始播放 - handlePlay(voiceId, duration, time) - } else { - // 同一音色:直接跳转 - setCurrentTime(time) + /** 用指定 URL 创建 Audio 并播放 */ + const startAudio = useCallback((id: string, url: string, knownDuration?: number) => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + const audio = new Audio(url) + audioRef.current = audio + + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime) + }) + audio.addEventListener("loadedmetadata", () => { + if (Number.isFinite(audio.duration) && audio.duration > 0) { + setDuration(audio.duration) } + }) + audio.addEventListener("ended", () => { + if (audioRef.current === audio) audioRef.current = null + pausedRef.current = null + setPlayingId(null) + setCurrentTime(0) + }) + + if (knownDuration && knownDuration > 0) setDuration(knownDuration) + setCurrentTime(0) + setPlayingId(id) + pausedRef.current = null + + audio.play().catch(() => { + // 自动播放被拦截或 URL 失效:复位按钮,错误提示由拦截器/环境处理 + if (audioRef.current === audio) audioRef.current = null + setPlayingId(null) + setLoadingId((cur) => (cur === id ? null : cur)) + }) + }, []) + + /** 播放配音素材(file_url 直链) */ + const playMaterial = useCallback( + (material: VoiceMaterial) => { + if (!material.fileUrl) return + startAudio(material.id, material.fileUrl, material.duration) }, - [playingId, handlePlay], + [startAudio], ) - /** 切换播放/暂停 */ - const handleTogglePlay = useCallback( - (voiceId: string, duration: number) => { + /** 预置/克隆音色试听:先 TTS 合成(带缓存),再真实播放 */ + const playVoice = useCallback( + async (voiceId: string) => { + // 暂停中恢复 + if (pausedRef.current?.id === voiceId && audioRef.current) { + try { + await audioRef.current.play() + } catch { + return + } + setPlayingId(voiceId) + pausedRef.current = null + return + } + + const cached = previewCacheRef.current.get(voiceId) + if (cached) { + startAudio(voiceId, cached.url, cached.duration) + return + } + + const seq = ++reqSeqRef.current + setLoadingId(voiceId) + try { + const res = await previewTts({ text: VOICE_PREVIEW_TEXT, voice_id: voiceId, speed: 1.0 }) + if (seq !== reqSeqRef.current) return // 已被更新的请求取代 + previewCacheRef.current.set(voiceId, { url: res.audio_url, duration: res.duration }) + setLoadingId(null) + startAudio(voiceId, res.audio_url, res.duration) + } catch { + if (seq !== reqSeqRef.current) return + // 错误文案(含「克隆尚未完成」)由 apiClient 拦截器统一 toast + setLoadingId(null) + } + }, + [startAudio], + ) + + /** 暂停(记录暂停对象,供再次点击恢复) */ + const handlePause = useCallback(() => { + const audio = audioRef.current + if (!audio) return + audio.pause() + pausedRef.current = { id: playingId ?? "", url: audio.src, duration } + setPlayingId(null) + }, [playingId, duration]) + + /** 音色卡片播放/暂停切换 */ + const handleToggleVoice = useCallback( + (voiceId: string) => { if (playingId === voiceId) { handlePause() } else { - handlePlay(voiceId, duration) + stopPlayback() + void playVoice(voiceId) } }, - [playingId, handlePlay, handlePause], + [playingId, handlePause, stopPlayback, playVoice], ) - /** 停止所有播放(切换 Tab 时调用) */ - const stopPlayback = useCallback(() => { - if (intervalRef.current) { - clearInterval(intervalRef.current) - intervalRef.current = null - } - setPlayingId(null) - setCurrentTime(0) + /** 素材卡片播放/暂停切换 */ + const handleToggleMaterial = useCallback( + (material: VoiceMaterial) => { + if (playingId === material.id) { + handlePause() + } else { + stopPlayback() + playMaterial(material) + } + }, + [playingId, handlePause, stopPlayback, playMaterial], + ) + + /** 进度条 seek(仅播放中有效;非播放态点击进度条由卡片改为触发播放) */ + const handleSeek = useCallback((time: number) => { + const audio = audioRef.current + if (!audio) return + audio.currentTime = time + setCurrentTime(time) }, []) - // 组件卸载时清理 + // 组件卸载时清理音频 useEffect(() => { return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current) + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null } } }, []) return { playingId, + loadingId, currentTime, - handlePlay, + duration, + playMaterial, + playVoice, handlePause, + handleToggleVoice, + handleToggleMaterial, handleSeek, - handleTogglePlay, stopPlayback, } } diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index 853b68263..f2de715bd 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -1089,6 +1089,35 @@ color: var(--text-tertiary); } +/* 播放区:复用预置音色卡片 .xx-voice-wave / .xx-voice-controls 视觉 */ +.vmat-card .xx-voice-wave { + margin: 2px 14px 0; + grid-column: auto; +} + +.vmat-card .xx-voice-controls { + grid-column: auto; + padding: 8px 14px 12px; +} + +.vmat-card:hover .xx-voice-wave { + opacity: 0.3; +} + +.vmat-card.playing .xx-voice-wave { + opacity: 0.5; + animation: xx-wave-pulse 0.8s ease-in-out infinite; +} + +/* 无可播放音频时播放按钮置灰 */ +.vmat-card .xx-voice-play-btn:disabled { + background: var(--bg-tertiary); + color: var(--text-tertiary); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + /* 骨架屏 */ .vmat-card--skeleton { pointer-events: none; diff --git a/apps/web/src/test/pages/voices/hooks/useAudioPlayer.test.ts b/apps/web/src/test/pages/voices/hooks/useAudioPlayer.test.ts index da94e45ce..17dcfe8b8 100644 --- a/apps/web/src/test/pages/voices/hooks/useAudioPlayer.test.ts +++ b/apps/web/src/test/pages/voices/hooks/useAudioPlayer.test.ts @@ -1,232 +1,242 @@ /** - * useAudioPlayer hook 测试 — VoiceLibrary 版本 + * useAudioPlayer hook 测试 — VoiceLibrary 版本(真实 Audio + TTS 试听) * - * 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测。 - * 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。 + * mock HTMLAudioElement 与 previewTts,验证三 tab 共用的播放状态逻辑。 */ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" import { renderHook, act } from "@testing-library/react" import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer" +import type { VoiceMaterial } from "@/pages/voice-materials/types" -describe("useAudioPlayer (voices)", () => { - beforeEach(() => { - vi.useFakeTimers() +const mockAudioPlay = vi.fn() +const mockAudioPause = vi.fn() +const listeners: Record void) | ((ev: unknown) => void)> = {} +let mockAudioInstance: { + play: ReturnType + pause: ReturnType + addEventListener: (ev: string, cb: () => void) => void + currentTime: number + duration: number + src: string + volume: number + paused: boolean +} + +const previewTtsMock = vi.fn() + +vi.mock("@/api/tts", () => ({ + previewTts: (...args: unknown[]) => previewTtsMock(...args), +})) + +const mockMaterial: VoiceMaterial = { + id: "asset-1", + name: "测试素材", + description: "", + gender: "neutral", + tagIds: [], + fileName: "test.mp3", + fileSize: 1024, + duration: 30, + mimeType: "audio/mpeg", + createdAt: "2024-01-01T00:00:00Z", + fileUrl: "https://example.com/test.mp3", +} + +beforeEach(() => { + vi.clearAllMocks() + for (const k of Object.keys(listeners)) delete listeners[k] + + mockAudioInstance = { + play: mockAudioPlay.mockResolvedValue(undefined), + pause: mockAudioPause, + addEventListener: (ev: string, cb: () => void) => { + listeners[ev] = cb + }, + currentTime: 0, + duration: 30, + src: "", + volume: 1, + paused: true, + } + + global.Audio = vi.fn().mockImplementation((url: string) => { + mockAudioInstance.src = url + return mockAudioInstance + }) as unknown as typeof Audio +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe("useAudioPlayer (voices, 真实播放)", () => { + it("初始状态为空", () => { + const { result } = renderHook(() => useAudioPlayer()) + expect(result.current.playingId).toBeNull() + expect(result.current.loadingId).toBeNull() + expect(result.current.currentTime).toBe(0) }) - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - it("应该使用初始状态初始化", () => { + it("playMaterial 用 file_url 真实播放素材", () => { const { result } = renderHook(() => useAudioPlayer()) + act(() => { + result.current.playMaterial(mockMaterial) + }) + + expect(result.current.playingId).toBe("asset-1") + expect(mockAudioPlay).toHaveBeenCalledTimes(1) + expect(mockAudioInstance.src).toBe("https://example.com/test.mp3") + }) + + it("file_url 缺失时不播放", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.playMaterial({ ...mockMaterial, fileUrl: undefined }) + }) + + expect(result.current.playingId).toBeNull() + expect(mockAudioPlay).not.toHaveBeenCalled() + }) + + it("handleToggleMaterial 播放中再点为暂停", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.handleToggleMaterial(mockMaterial) + }) + expect(result.current.playingId).toBe("asset-1") + + act(() => { + result.current.handleToggleMaterial(mockMaterial) + }) + expect(result.current.playingId).toBeNull() + expect(mockAudioPause).toHaveBeenCalled() + }) + + it("暂停后再次点击恢复播放", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.handleToggleMaterial(mockMaterial) + }) + act(() => { + result.current.handleToggleMaterial(mockMaterial) // 暂停 + }) + mockAudioPlay.mockClear() + act(() => { + result.current.handleToggleMaterial(mockMaterial) // 恢复 + }) + expect(result.current.playingId).toBe("asset-1") + expect(mockAudioPlay).toHaveBeenCalledTimes(1) + }) + + it("playVoice 合成中显示 loading,成功后播放且二次点用缓存不重复合成", async () => { + previewTtsMock.mockResolvedValue({ audio_url: "https://example.com/tts.mp3", duration: 5 }) + const { result } = renderHook(() => useAudioPlayer()) + + await act(async () => { + await result.current.playVoice("voice-1") + }) + + expect(previewTtsMock).toHaveBeenCalledWith( + expect.objectContaining({ voice_id: "voice-1", speed: 1.0 }), + ) + expect(result.current.playingId).toBe("voice-1") + expect(mockAudioInstance.src).toBe("https://example.com/tts.mp3") + + // 停止后再次试听同一音色 → 走缓存 + act(() => { + result.current.stopPlayback() + }) + mockAudioPlay.mockClear() + previewTtsMock.mockClear() + + await act(async () => { + await result.current.playVoice("voice-1") + }) + expect(previewTtsMock).not.toHaveBeenCalled() + expect(result.current.playingId).toBe("voice-1") + expect(mockAudioPlay).toHaveBeenCalledTimes(1) + }) + + it("合成失败时复位 loading 且不播放", async () => { + previewTtsMock.mockRejectedValue(new Error("克隆尚未完成")) + const { result } = renderHook(() => useAudioPlayer()) + + await act(async () => { + await result.current.playVoice("voice-x") + }) + + expect(result.current.loadingId).toBeNull() + expect(result.current.playingId).toBeNull() + expect(mockAudioPlay).not.toHaveBeenCalled() + }) + + it("互斥:播放新素材时停止上一个", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.playMaterial(mockMaterial) + }) + act(() => { + result.current.playMaterial({ + ...mockMaterial, + id: "asset-2", + fileUrl: "https://e.com/2.mp3", + }) + }) + + expect(result.current.playingId).toBe("asset-2") + expect(mockAudioPause).toHaveBeenCalledTimes(1) + }) + + it("handleSeek 播放中调整 currentTime", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.playMaterial(mockMaterial) + }) + act(() => { + result.current.handleSeek(12) + }) + + expect(mockAudioInstance.currentTime).toBe(12) + }) + + it("timeupdate 更新进度、ended 复位", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.playMaterial(mockMaterial) + }) + + act(() => { + mockAudioInstance.currentTime = 7 + ;(listeners["timeupdate"] as () => void)() + }) + expect(result.current.currentTime).toBe(7) + + act(() => { + ;(listeners["ended"] as () => void)() + }) expect(result.current.playingId).toBeNull() expect(result.current.currentTime).toBe(0) }) - it("handlePlay 应该开始播放指定音色", () => { + it("stopPlayback 复位全部状态", () => { const { result } = renderHook(() => useAudioPlayer()) act(() => { - result.current.handlePlay("voice-1", 10) + result.current.playMaterial(mockMaterial) }) - - expect(result.current.playingId).toBe("voice-1") - expect(result.current.currentTime).toBe(0) - }) - - it("handlePlay 对同一个音色不应重复启动播放", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - const initialTime = result.current.currentTime - - // 推进一些时间让进度走动 - act(() => { - vi.advanceTimersByTime(200) - }) - - const timeAfterAdvance = result.current.currentTime - expect(timeAfterAdvance).toBeGreaterThan(initialTime) - - // 对同一个音色再次调用 handlePlay 不应重置 - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - expect(result.current.playingId).toBe("voice-1") - expect(result.current.currentTime).toBe(timeAfterAdvance) - }) - - it("handlePlay 切换音色时应停止上一个并从头开始", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - act(() => { - vi.advanceTimersByTime(500) - }) - - expect(result.current.playingId).toBe("voice-1") - expect(result.current.currentTime).toBeGreaterThan(0) - - act(() => { - result.current.handlePlay("voice-2", 15) - }) - - expect(result.current.playingId).toBe("voice-2") - expect(result.current.currentTime).toBe(0) - }) - - it("播放进度应该随时间递增", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - // 每 100ms 增加 0.1 - act(() => { - vi.advanceTimersByTime(300) - }) - - expect(result.current.currentTime).toBeCloseTo(0.3, 1) - expect(result.current.playingId).toBe("voice-1") - }) - - it("播放到结尾应自动停止并重置", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频 - }) - - act(() => { - vi.advanceTimersByTime(600) // 超过 0.5 秒 - }) - - expect(result.current.playingId).toBeNull() - expect(result.current.currentTime).toBe(0) - }) - - it("handlePause 应该暂停播放", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - act(() => { - vi.advanceTimersByTime(200) - }) - - const timeBeforePause = result.current.currentTime - - act(() => { - result.current.handlePause() - }) - - expect(result.current.playingId).toBeNull() - - // 暂停后时间不应再变化 - act(() => { - vi.advanceTimersByTime(500) - }) - - expect(result.current.currentTime).toBe(timeBeforePause) - }) - - it("handleSeek 应该跳转到指定时间", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - act(() => { - result.current.handleSeek("voice-1", 5, 10) - }) - - expect(result.current.currentTime).toBe(5) - }) - - it("handleSeek 对不同音色应该开始播放该音色", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - act(() => { - result.current.handleSeek("voice-2", 3, 15) - }) - - expect(result.current.playingId).toBe("voice-2") - expect(result.current.currentTime).toBe(3) - }) - - it("handleTogglePlay 应该在播放和暂停之间切换", () => { - const { result } = renderHook(() => useAudioPlayer()) - - // 初始为暂停,调用应开始播放 - act(() => { - result.current.handleTogglePlay("voice-1", 10) - }) - - expect(result.current.playingId).toBe("voice-1") - - // 再次调用应暂停 - act(() => { - result.current.handleTogglePlay("voice-1", 10) - }) - - expect(result.current.playingId).toBeNull() - }) - - it("stopPlayback 应该重置所有播放状态", () => { - const { result } = renderHook(() => useAudioPlayer()) - - act(() => { - result.current.handlePlay("voice-1", 10) - }) - - act(() => { - vi.advanceTimersByTime(300) - }) - - expect(result.current.playingId).toBe("voice-1") - expect(result.current.currentTime).toBeGreaterThan(0) - act(() => { result.current.stopPlayback() }) expect(result.current.playingId).toBeNull() expect(result.current.currentTime).toBe(0) - - // 停止后定时器不应再触发 - const timeAfterStop = result.current.currentTime - act(() => { - vi.advanceTimersByTime(500) - }) - expect(result.current.currentTime).toBe(timeAfterStop) - }) - - it("返回值应该包含所有必要的方法和状态", () => { - const { result } = renderHook(() => useAudioPlayer()) - - expect(typeof result.current.handlePlay).toBe("function") - expect(typeof result.current.handlePause).toBe("function") - expect(typeof result.current.handleSeek).toBe("function") - expect(typeof result.current.handleTogglePlay).toBe("function") - expect(typeof result.current.stopPlayback).toBe("function") - expect(typeof result.current.playingId).toBe("object") // string | null - expect(typeof result.current.currentTime).toBe("number") + expect(mockAudioPause).toHaveBeenCalled() }) }) diff --git a/apps/web/src/test/pages/voices/smoke.test.tsx b/apps/web/src/test/pages/voices/smoke.test.tsx index f9e0d90a3..2acbb2059 100755 --- a/apps/web/src/test/pages/voices/smoke.test.tsx +++ b/apps/web/src/test/pages/voices/smoke.test.tsx @@ -29,7 +29,7 @@ import "@/pages/voices/components/tts-modal/ErrorAlert" import "@/pages/voices/components/tts-modal/ResultPanel" import "@/pages/voices/components/tts-modal/types" import "@/pages/voices/components/VoiceFilterBar" -import "@/pages/voices/components/MaterialVoiceCard" +import "@/pages/voices/components/MaterialVoiceTab" // 类型与常量 import "@/pages/voices/types" -- 2.54.0