feat: Implement character voice preview playback and regeneration, and add a turbo mode status indicator for audiobook projects.

This commit is contained in:
2026-03-11 15:36:43 +08:00
parent 5dded459fc
commit d3c6297a09
8 changed files with 401 additions and 76 deletions

View File

@@ -139,6 +139,14 @@ export const audiobookApi = {
return `/audiobook/projects/${projectId}/segments/${segmentId}/audio`
},
getCharacterAudioUrl: (projectId: number, charId: number): string => {
return `/audiobook/projects/${projectId}/characters/${charId}/audio`
},
regenerateCharacterPreview: async (projectId: number, charId: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${projectId}/characters/${charId}/regenerate-preview`)
},
parseAllChapters: async (projectId: number, onlyErrors?: boolean): Promise<void> => {
const params = onlyErrors ? '?only_errors=true' : ''
await apiClient.post(`/audiobook/projects/${projectId}/parse-all${params}`)

View File

@@ -15,7 +15,8 @@
"processing": "Processing",
"generating": "Generating",
"done": "Done",
"error": "Error"
"error": "Error",
"turboActive": "⚡ Turbo"
},
"stepHints": {

View File

@@ -15,7 +15,8 @@
"processing": "处理中",
"generating": "生成中",
"done": "已完成",
"error": "出错"
"error": "出错",
"turboActive": "⚡ 极速并发"
},
"stepHints": {
@@ -83,7 +84,10 @@
"descPlaceholder": "角色描述",
"voiceDesign": "音色 #{{id}}",
"noVoice": "未分配",
"savedSuccess": "角色已保存"
"savedSuccess": "角色已保存",
"regeneratingPreview": "重新生成试听中...",
"regeneratePreview": "重生试听",
"previewNotReady": "试听收集中..."
},
"confirm": {

View File

@@ -343,6 +343,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
const [charsCollapsed, setCharsCollapsed] = useState(false)
const [chaptersCollapsed, setChaptersCollapsed] = useState(false)
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
const [voiceKeys, setVoiceKeys] = useState<Record<number, number>>({})
const [regeneratingVoices, setRegeneratingVoices] = useState<Set<number>>(new Set())
const prevStatusRef = useRef(project.status)
const autoExpandedRef = useRef(new Set<string>())
@@ -433,7 +435,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
setLoadingAction(true)
setIsPolling(true)
try {
await audiobookApi.analyze(project.id, {})
await audiobookApi.analyze(project.id, { turbo: true })
toast.success(t('projectCard.analyzeStarted'))
onRefresh()
} catch (e: any) {
@@ -525,6 +527,24 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
}
}
const handleRegeneratePreview = async (charId: number) => {
if (!project) return
setRegeneratingVoices(prev => new Set(prev).add(charId))
try {
await audiobookApi.regenerateCharacterPreview(project.id, charId)
toast.success(t('projectCard.characters.savedSuccess')) // or add a new toast key
setVoiceKeys(prev => ({ ...prev, [charId]: (prev[charId] || 0) + 1 }))
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setRegeneratingVoices(prev => {
const next = new Set(prev)
next.delete(charId)
return next
})
}
}
const handleGenerateAll = async () => {
if (!detail) return
setLoadingAction(true)
@@ -667,6 +687,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
return status
})()
const isTurboMode = ['analyzing', 'parsing', 'processing'].includes(displayStatus)
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
@@ -675,6 +697,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<span className="font-medium break-words">{project.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{isTurboMode && (
<Badge variant="secondary" className="bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 shadow-sm border-amber-500/20">
{t('status.turboActive')}
</Badge>
)}
<Badge variant={(STATUS_COLORS[displayStatus] || 'secondary') as any}>
{t(`status.${displayStatus}`, { defaultValue: displayStatus })}
</Badge>
@@ -863,6 +890,34 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</div>
</div>
)}
{!editingCharId && char.voice_design_id && (
<div className="mt-2 pl-1 pr-2 flex items-center justify-between gap-3 bg-muted/30 rounded-md p-1.5 border border-muted/50">
<div className="flex-1 max-w-[200px] sm:max-w-[300px]">
<LazyAudioPlayer
key={`audio-${char.id}-${voiceKeys[char.id] || 0}`}
audioUrl={`${audiobookApi.getCharacterAudioUrl(project.id, char.id)}?t=${voiceKeys[char.id] || 0}`}
jobId={char.id}
/>
</div>
{status === 'characters_ready' && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground shrink-0"
onClick={() => handleRegeneratePreview(char.id)}
disabled={regeneratingVoices.has(char.id)}
>
{regeneratingVoices.has(char.id) ? (
<><Loader2 className="h-3 w-3 mr-1 animate-spin" />{t('projectCard.characters.regeneratingPreview')}</>
) : (
<><RefreshCw className="h-3 w-3 mr-1" />{t('projectCard.characters.regeneratePreview')}</>
)}
</Button>
)}
</div>
)}
</div>
))}
</div>}