feat(audiobook): add endpoint to retrieve audio for a specific segment

This commit is contained in:
2026-03-09 11:48:47 +08:00
parent a3d7d318e0
commit 9b6691bffe
3 changed files with 143 additions and 30 deletions

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from api.auth import get_current_user from api.auth import get_current_user
from core.database import get_db from core.database import get_db
from db import crud from db import crud
from db.models import User from db.models import User, AudiobookSegment
from schemas.audiobook import ( from schemas.audiobook import (
AudiobookProjectCreate, AudiobookProjectCreate,
AudiobookProjectResponse, AudiobookProjectResponse,
@@ -261,6 +261,30 @@ async def get_segments(
return result 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") @router.get("/projects/{project_id}/download")
async def download_project( async def download_project(
project_id: int, project_id: int,

View File

@@ -101,9 +101,12 @@ export const audiobookApi = {
}, },
getDownloadUrl: (id: number, chapter?: number): string => { getDownloadUrl: (id: number, chapter?: number): string => {
const base = import.meta.env.VITE_API_URL || ''
const chapterParam = chapter !== undefined ? `?chapter=${chapter}` : '' 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<void> => { deleteProject: async (id: number): Promise<void> => {

View File

@@ -1,14 +1,15 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { toast } from 'sonner' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Navbar } from '@/components/Navbar' import { Navbar } from '@/components/Navbar'
import { AudioPlayer } from '@/components/AudioPlayer'
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookSegment } from '@/lib/api/audiobook' 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<string, string> = { const STATUS_LABELS: Record<string, string> = {
pending: '待分析', pending: '待分析',
@@ -24,10 +25,24 @@ const STATUS_COLORS: Record<string, string> = {
analyzing: 'default', analyzing: 'default',
ready: 'default', ready: 'default',
generating: 'default', generating: 'default',
done: 'default', done: 'outline',
error: 'destructive', error: 'destructive',
} }
const STEP_HINTS: Record<string, string> = {
pending: '第 1 步点击「分析」LLM 将自动提取角色并分配音色',
analyzing: '第 1 步LLM 正在分析文本,提取角色列表,请稍候...',
ready: '第 2 步:已提取角色列表,确认角色音色后点击「生成音频」开始合成',
generating: '第 3 步:正在逐段合成音频,请耐心等待...',
}
const SEGMENT_STATUS_LABELS: Record<string, string> = {
pending: '待生成',
generating: '生成中',
done: '完成',
error: '出错',
}
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) { function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
const [baseUrl, setBaseUrl] = useState('') const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('') const [apiKey, setApiKey] = useState('')
@@ -127,6 +142,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
const [segments, setSegments] = useState<AudiobookSegment[]>([]) const [segments, setSegments] = useState<AudiobookSegment[]>([])
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [loadingAction, setLoadingAction] = useState(false) const [loadingAction, setLoadingAction] = useState(false)
const [playingSegmentId, setPlayingSegmentId] = useState<number | null>(null)
const autoExpandedRef = useRef(false)
const fetchDetail = useCallback(async () => { const fetchDetail = useCallback(async () => {
try { try {
@@ -149,6 +166,13 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
} }
}, [expanded, fetchDetail, fetchSegments]) }, [expanded, fetchDetail, fetchSegments])
useEffect(() => {
if (project.status === 'ready' && !autoExpandedRef.current) {
setExpanded(true)
autoExpandedRef.current = true
}
}, [project.status])
useEffect(() => { useEffect(() => {
if (['analyzing', 'generating'].includes(project.status)) { if (['analyzing', 'generating'].includes(project.status)) {
const interval = setInterval(() => { 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 () => { const handleDelete = async () => {
if (!confirm(`确认删除项目「${project.title}」及所有音频?`)) return if (!confirm(`确认删除项目「${project.title}」及所有音频?`)) return
try { try {
@@ -212,19 +256,21 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</div> </div>
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
{project.status === 'pending' && ( {project.status === 'pending' && (
<Button size="sm" variant="outline" onClick={handleAnalyze} disabled={loadingAction}></Button> <Button size="sm" onClick={handleAnalyze} disabled={loadingAction}>
)} {loadingAction ? '...' : '分析'}
{project.status === 'ready' && (
<Button size="sm" onClick={handleGenerate} disabled={loadingAction}></Button>
)}
{project.status === 'done' && (
<Button size="sm" variant="outline" asChild>
<a href={audiobookApi.getDownloadUrl(project.id)} download>
<Download className="h-3 w-3 mr-1" />
</a>
</Button> </Button>
)} )}
<Button size="icon" variant="ghost" onClick={() => { setExpanded(!expanded) }}> {project.status === 'ready' && (
<Button size="sm" onClick={handleGenerate} disabled={loadingAction}>
{loadingAction ? '...' : '生成音频'}
</Button>
)}
{project.status === 'done' && (
<Button size="sm" variant="outline" onClick={handleDownload} disabled={loadingAction}>
<Download className="h-3 w-3 mr-1" />
</Button>
)}
<Button size="icon" variant="ghost" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button> </Button>
<Button size="icon" variant="ghost" onClick={handleDelete}> <Button size="icon" variant="ghost" onClick={handleDelete}>
@@ -233,6 +279,12 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</div> </div>
</div> </div>
{STEP_HINTS[project.status] && (
<div className="text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2 border-l-2 border-primary/40">
{STEP_HINTS[project.status]}
</div>
)}
{project.error_message && ( {project.error_message && (
<div className="text-xs text-destructive bg-destructive/10 rounded p-2">{project.error_message}</div> <div className="text-xs text-destructive bg-destructive/10 rounded p-2">{project.error_message}</div>
)} )}
@@ -251,15 +303,22 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<div className="text-xs font-medium text-muted-foreground mb-2"></div> <div className="text-xs font-medium text-muted-foreground mb-2"></div>
<div className="space-y-1"> <div className="space-y-1">
{detail.characters.map(char => ( {detail.characters.map(char => (
<div key={char.id} className="flex items-center justify-between text-sm border rounded px-2 py-1"> <div key={char.id} className="flex items-center justify-between text-sm border rounded px-2 py-1.5">
<span className="font-medium">{char.name}</span> <span className="font-medium shrink-0">{char.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{char.instruct}</span> <span className="text-xs text-muted-foreground truncate mx-2 flex-1">{char.instruct}</span>
{char.voice_design_id && ( {char.voice_design_id ? (
<Badge variant="outline" className="text-xs"> #{char.voice_design_id}</Badge> <Badge variant="outline" className="text-xs shrink-0"> #{char.voice_design_id}</Badge>
) : (
<Badge variant="secondary" className="text-xs shrink-0"></Badge>
)} )}
</div> </div>
))} ))}
</div> </div>
{project.status === 'ready' && (
<Button className="w-full mt-3" onClick={handleGenerate} disabled={loadingAction}>
{loadingAction ? '启动中...' : '确认角色,开始生成音频'}
</Button>
)}
</div> </div>
)} )}
@@ -268,14 +327,41 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<div className="text-xs font-medium text-muted-foreground mb-2"> <div className="text-xs font-medium text-muted-foreground mb-2">
({segments.length} ) ({segments.length} )
</div> </div>
<div className="max-h-64 overflow-y-auto space-y-1"> <div className="max-h-96 overflow-y-auto space-y-1 pr-1">
{segments.slice(0, 50).map(seg => ( {segments.slice(0, 50).map(seg => (
<div key={seg.id} className="flex items-start gap-2 text-xs border rounded px-2 py-1"> <div key={seg.id}>
<Badge variant="outline" className="shrink-0 text-xs">{seg.character_name || '?'}</Badge> <div className="flex items-start gap-2 text-xs border rounded px-2 py-1.5">
<span className="text-muted-foreground truncate">{seg.text}</span> <Badge variant="outline" className="shrink-0 text-xs mt-0.5">{seg.character_name || '?'}</Badge>
<Badge variant={seg.status === 'done' ? 'default' : 'secondary'} className="shrink-0 text-xs"> <span className="text-muted-foreground flex-1 min-w-0 break-words leading-relaxed">{seg.text}</span>
{seg.status} {seg.status === 'done' ? (
<Button
size="icon"
variant="ghost"
className="h-5 w-5 shrink-0 mt-0.5"
onClick={() => setPlayingSegmentId(playingSegmentId === seg.id ? null : seg.id)}
>
{playingSegmentId === seg.id
? <Square className="h-2.5 w-2.5 fill-current" />
: <Play className="h-2.5 w-2.5" />
}
</Button>
) : (
<Badge
variant={seg.status === 'error' ? 'destructive' : 'secondary'}
className="shrink-0 text-xs mt-0.5"
>
{SEGMENT_STATUS_LABELS[seg.status] || seg.status}
</Badge> </Badge>
)}
</div>
{playingSegmentId === seg.id && (
<div className="mt-1 ml-1">
<AudioPlayer
audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)}
jobId={seg.id}
/>
</div>
)}
</div> </div>
))} ))}
{segments.length > 50 && ( {segments.length > 50 && (