Merge pull request 'fix(#1195): 我的音色页面试听按钮对接/voice-clones/{id}/preview接口' (#1204) from fix/voice-clone-preview-frontend-1195 into develop
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m38s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 53s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 42s
CI/CD Pipeline / Unit Tests (push) Successful in 8m49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m14s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m38s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 53s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 42s
CI/CD Pipeline / Unit Tests (push) Successful in 8m49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m14s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / CI Gate (push) Has been skipped
This commit was merged in pull request #1204.
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
VoiceCloneListParams,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
VoiceClonePreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||
@@ -85,3 +86,17 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取克隆音色试听音频(实时 TTS 合成) */
|
||||
export const getVoiceClonePreview = async (
|
||||
cloneId: string,
|
||||
text?: string,
|
||||
): Promise<VoiceClonePreviewResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (text) searchParams.set("text", text)
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<VoiceClonePreviewResponse>(
|
||||
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
VoiceCloneStatusResponse,
|
||||
CreateVoiceCloneRequestFull,
|
||||
VoiceCloneListParams,
|
||||
VoiceClonePreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
@@ -29,4 +30,5 @@ export {
|
||||
updateVoiceClone,
|
||||
getVoiceCloneStatus,
|
||||
retryVoiceClone,
|
||||
getVoiceClonePreview,
|
||||
} from "./clones"
|
||||
|
||||
@@ -90,3 +90,24 @@ export interface VoiceCloneListParams {
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 克隆音色试听响应 */
|
||||
export interface VoiceClonePreviewResponse {
|
||||
clone_id: string
|
||||
/** 音色克隆档案 ID */
|
||||
|
||||
voice_id: string
|
||||
/** CosyVoice 音色 ID */
|
||||
|
||||
audio_url: string
|
||||
/** 试听音频 URL */
|
||||
|
||||
text: string
|
||||
/** 试听文本 */
|
||||
|
||||
duration: number
|
||||
/** 音频时长(秒) */
|
||||
|
||||
file_size: number
|
||||
/** 文件大小(字节) */
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const MyVoices: React.FC = () => {
|
||||
loading,
|
||||
hasProcessing,
|
||||
playingId,
|
||||
loadingPreviewId,
|
||||
toasts,
|
||||
editModalOpen,
|
||||
editName,
|
||||
@@ -75,6 +76,7 @@ const MyVoices: React.FC = () => {
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
isPlaying={playingId === voice.id}
|
||||
isLoading={loadingPreviewId === voice.id}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EditOutlined,
|
||||
SoundOutlined,
|
||||
ClockCircleOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Tooltip } from "@/components/ui"
|
||||
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
|
||||
@@ -15,6 +16,7 @@ import { formatDate } from "../utils"
|
||||
interface VoiceCardProps {
|
||||
voice: VoiceClone
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
onTogglePlay: (voice: VoiceClone) => void
|
||||
onEdit: (voice: VoiceClone) => void
|
||||
onDelete: (voice: VoiceClone) => void
|
||||
@@ -26,6 +28,7 @@ interface VoiceCardProps {
|
||||
export const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
isLoading = false,
|
||||
onTogglePlay,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -74,8 +77,13 @@ export const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
buttonType={isPlaying ? "secondary" : "ghost"}
|
||||
buttonSize="sm"
|
||||
onClick={() => onTogglePlay(voice)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isPlaying ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingOutlined /> 合成中…
|
||||
</>
|
||||
) : isPlaying ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 暂停
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { deleteVoiceClone, updateVoiceClone } from "@/api/voice-clone"
|
||||
import { deleteVoiceClone, updateVoiceClone, getVoiceClonePreview } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { ToastItem } from "../types"
|
||||
|
||||
@@ -15,6 +15,7 @@ export const useMyVoices = () => {
|
||||
const navigate = useNavigate()
|
||||
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [loadingPreviewId, setLoadingPreviewId] = useState<string | null>(null)
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
|
||||
@@ -31,26 +32,44 @@ export const useMyVoices = () => {
|
||||
|
||||
// 试听播放
|
||||
const handleTogglePlay = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
async (voice: VoiceClone) => {
|
||||
// 正在播放同一个 → 暂停
|
||||
if (playingId === voice.id) {
|
||||
audioRef.current?.pause()
|
||||
setPlayingId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 先停掉当前播放
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
if (!voice.sample_url) {
|
||||
showToast("暂无试听音频", "error")
|
||||
|
||||
// 如果正在加载其他试听,不重复触发
|
||||
if (loadingPreviewId && loadingPreviewId !== voice.id) {
|
||||
return
|
||||
}
|
||||
const audio = new Audio(voice.sample_url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
|
||||
audio.onended = () => setPlayingId(null)
|
||||
setPlayingId(voice.id)
|
||||
|
||||
// 调用试听接口获取音频 URL
|
||||
setLoadingPreviewId(voice.id)
|
||||
try {
|
||||
const preview = await getVoiceClonePreview(voice.id)
|
||||
const audio = new Audio(preview.audio_url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
showToast("播放失败,请检查音频文件", "error")
|
||||
setPlayingId(null)
|
||||
})
|
||||
audio.onended = () => setPlayingId(null)
|
||||
setPlayingId(voice.id)
|
||||
} catch {
|
||||
showToast("试听音频生成失败,请重试", "error")
|
||||
} finally {
|
||||
setLoadingPreviewId(null)
|
||||
}
|
||||
},
|
||||
[playingId, showToast],
|
||||
[playingId, loadingPreviewId, showToast],
|
||||
)
|
||||
|
||||
// 编辑
|
||||
@@ -117,6 +136,7 @@ export const useMyVoices = () => {
|
||||
hasProcessing,
|
||||
// 状态
|
||||
playingId,
|
||||
loadingPreviewId,
|
||||
toasts,
|
||||
editModalOpen,
|
||||
editingVoice,
|
||||
|
||||
Reference in New Issue
Block a user