From 9b6691bffe1ef353f9ccd2f4017bc9a9461e53a9 Mon Sep 17 00:00:00 2001 From: bdim404 Date: Mon, 9 Mar 2026 11:48:47 +0800 Subject: [PATCH] feat(audiobook): add endpoint to retrieve audio for a specific segment --- qwen3-tts-backend/api/audiobook.py | 26 +++- qwen3-tts-frontend/src/lib/api/audiobook.ts | 7 +- qwen3-tts-frontend/src/pages/Audiobook.tsx | 140 ++++++++++++++++---- 3 files changed, 143 insertions(+), 30 deletions(-) diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index c15ae61..14eed8c 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from api.auth import get_current_user from core.database import get_db from db import crud -from db.models import User +from db.models import User, AudiobookSegment from schemas.audiobook import ( AudiobookProjectCreate, AudiobookProjectResponse, @@ -261,6 +261,30 @@ async def get_segments( return result +@router.get("/projects/{project_id}/segments/{segment_id}/audio") +async def get_segment_audio( + project_id: int, + segment_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + project = crud.get_audiobook_project(db, project_id, current_user.id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + seg = db.query(AudiobookSegment).filter( + AudiobookSegment.id == segment_id, + AudiobookSegment.project_id == project_id, + ).first() + if not seg: + raise HTTPException(status_code=404, detail="Segment not found") + + if not seg.audio_path or not Path(seg.audio_path).exists(): + raise HTTPException(status_code=404, detail="Audio not available") + + return FileResponse(seg.audio_path, media_type="audio/wav") + + @router.get("/projects/{project_id}/download") async def download_project( project_id: int, diff --git a/qwen3-tts-frontend/src/lib/api/audiobook.ts b/qwen3-tts-frontend/src/lib/api/audiobook.ts index 49072f0..cb55732 100644 --- a/qwen3-tts-frontend/src/lib/api/audiobook.ts +++ b/qwen3-tts-frontend/src/lib/api/audiobook.ts @@ -101,9 +101,12 @@ export const audiobookApi = { }, getDownloadUrl: (id: number, chapter?: number): string => { - const base = import.meta.env.VITE_API_URL || '' const chapterParam = chapter !== undefined ? `?chapter=${chapter}` : '' - return `${base}/audiobook/projects/${id}/download${chapterParam}` + return `/audiobook/projects/${id}/download${chapterParam}` + }, + + getSegmentAudioUrl: (projectId: number, segmentId: number): string => { + return `/audiobook/projects/${projectId}/segments/${segmentId}/audio` }, deleteProject: async (id: number): Promise => { diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index dcf6473..092d665 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -1,14 +1,15 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { toast } from 'sonner' -import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp } from 'lucide-react' +import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { Navbar } from '@/components/Navbar' +import { AudioPlayer } from '@/components/AudioPlayer' import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookSegment } from '@/lib/api/audiobook' -import { formatApiError } from '@/lib/api' +import apiClient, { formatApiError } from '@/lib/api' const STATUS_LABELS: Record = { pending: '待分析', @@ -24,10 +25,24 @@ const STATUS_COLORS: Record = { analyzing: 'default', ready: 'default', generating: 'default', - done: 'default', + done: 'outline', error: 'destructive', } +const STEP_HINTS: Record = { + pending: '第 1 步:点击「分析」,LLM 将自动提取角色并分配音色', + analyzing: '第 1 步:LLM 正在分析文本,提取角色列表,请稍候...', + ready: '第 2 步:已提取角色列表,确认角色音色后点击「生成音频」开始合成', + generating: '第 3 步:正在逐段合成音频,请耐心等待...', +} + +const SEGMENT_STATUS_LABELS: Record = { + pending: '待生成', + generating: '生成中', + done: '完成', + error: '出错', +} + function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) { const [baseUrl, setBaseUrl] = useState('') const [apiKey, setApiKey] = useState('') @@ -127,6 +142,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr const [segments, setSegments] = useState([]) const [expanded, setExpanded] = useState(false) const [loadingAction, setLoadingAction] = useState(false) + const [playingSegmentId, setPlayingSegmentId] = useState(null) + const autoExpandedRef = useRef(false) const fetchDetail = useCallback(async () => { try { @@ -149,6 +166,13 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } }, [expanded, fetchDetail, fetchSegments]) + useEffect(() => { + if (project.status === 'ready' && !autoExpandedRef.current) { + setExpanded(true) + autoExpandedRef.current = true + } + }, [project.status]) + useEffect(() => { if (['analyzing', 'generating'].includes(project.status)) { const interval = setInterval(() => { @@ -185,6 +209,26 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } } + const handleDownload = async () => { + setLoadingAction(true) + try { + const response = await apiClient.get( + `/audiobook/projects/${project.id}/download`, + { responseType: 'blob' } + ) + const url = URL.createObjectURL(response.data) + const a = document.createElement('a') + a.href = url + a.download = `${project.title}.mp3` + a.click() + URL.revokeObjectURL(url) + } catch (e: any) { + toast.error(formatApiError(e)) + } finally { + setLoadingAction(false) + } + } + const handleDelete = async () => { if (!confirm(`确认删除项目「${project.title}」及所有音频?`)) return try { @@ -212,19 +256,21 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
{project.status === 'pending' && ( - - )} - {project.status === 'ready' && ( - - )} - {project.status === 'done' && ( - )} - + )} + {project.status === 'done' && ( + + )} +
+ {STEP_HINTS[project.status] && ( +
+ {STEP_HINTS[project.status]} +
+ )} + {project.error_message && (
{project.error_message}
)} @@ -251,15 +303,22 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
角色列表
{detail.characters.map(char => ( -
- {char.name} - {char.instruct} - {char.voice_design_id && ( - 音色 #{char.voice_design_id} +
+ {char.name} + {char.instruct} + {char.voice_design_id ? ( + 音色 #{char.voice_design_id} + ) : ( + 未分配 )}
))}
+ {project.status === 'ready' && ( + + )}
)} @@ -268,14 +327,41 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
片段列表 ({segments.length} 条)
-
+
{segments.slice(0, 50).map(seg => ( -
- {seg.character_name || '?'} - {seg.text} - - {seg.status} - +
+
+ {seg.character_name || '?'} + {seg.text} + {seg.status === 'done' ? ( + + ) : ( + + {SEGMENT_STATUS_LABELS[seg.status] || seg.status} + + )} +
+ {playingSegmentId === seg.id && ( +
+ +
+ )}
))} {segments.length > 50 && (