feat(audiobook): enhance chapter expansion functionality in ProjectCard component

This commit is contained in:
2026-03-10 18:05:31 +08:00
parent bf7c73e57c
commit 1db41b6278
2 changed files with 85 additions and 79 deletions

View File

@@ -1,3 +1,5 @@
import asyncio
import functools
import time import time
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@@ -39,7 +41,11 @@ class LocalTTSBackend(TTSBackend):
await self.model_manager.load_model("custom-voice") await self.model_manager.load_model("custom-voice")
_, tts = await self.model_manager.get_current_model() _, tts = await self.model_manager.get_current_model()
result = tts.generate_custom_voice( loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
functools.partial(
tts.generate_custom_voice,
text=params['text'], text=params['text'],
language=params['language'], language=params['language'],
speaker=params['speaker'], speaker=params['speaker'],
@@ -48,7 +54,8 @@ class LocalTTSBackend(TTSBackend):
temperature=params['temperature'], temperature=params['temperature'],
top_k=params['top_k'], top_k=params['top_k'],
top_p=params['top_p'], top_p=params['top_p'],
repetition_penalty=params['repetition_penalty'] repetition_penalty=params['repetition_penalty'],
)
) )
import numpy as np import numpy as np
@@ -60,7 +67,11 @@ class LocalTTSBackend(TTSBackend):
await self.model_manager.load_model("voice-design") await self.model_manager.load_model("voice-design")
_, tts = await self.model_manager.get_current_model() _, tts = await self.model_manager.get_current_model()
result = tts.generate_voice_design( loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
functools.partial(
tts.generate_voice_design,
text=params['text'], text=params['text'],
language=params['language'], language=params['language'],
instruct=params['instruct'], instruct=params['instruct'],
@@ -68,7 +79,8 @@ class LocalTTSBackend(TTSBackend):
temperature=params['temperature'], temperature=params['temperature'],
top_k=params['top_k'], top_k=params['top_k'],
top_p=params['top_p'], top_p=params['top_p'],
repetition_penalty=params['repetition_penalty'] repetition_penalty=params['repetition_penalty'],
)
) )
import numpy as np import numpy as np
@@ -82,19 +94,28 @@ class LocalTTSBackend(TTSBackend):
await self.model_manager.load_model("base") await self.model_manager.load_model("base")
_, tts = await self.model_manager.get_current_model() _, tts = await self.model_manager.get_current_model()
loop = asyncio.get_event_loop()
if x_vector is None: if x_vector is None:
if ref_audio_bytes is None: if ref_audio_bytes is None:
raise ValueError("Either ref_audio_bytes or x_vector must be provided") raise ValueError("Either ref_audio_bytes or x_vector must be provided")
ref_audio_array, ref_sr = process_ref_audio(ref_audio_bytes) ref_audio_array, ref_sr = process_ref_audio(ref_audio_bytes)
x_vector = tts.create_voice_clone_prompt( x_vector = await loop.run_in_executor(
None,
functools.partial(
tts.create_voice_clone_prompt,
ref_audio=(ref_audio_array, ref_sr), ref_audio=(ref_audio_array, ref_sr),
ref_text=params.get('ref_text', ''), ref_text=params.get('ref_text', ''),
x_vector_only_mode=False x_vector_only_mode=False,
)
) )
wavs, sample_rate = tts.generate_voice_clone( wavs, sample_rate = await loop.run_in_executor(
None,
functools.partial(
tts.generate_voice_clone,
text=params['text'], text=params['text'],
language=params['language'], language=params['language'],
voice_clone_prompt=x_vector, voice_clone_prompt=x_vector,
@@ -102,7 +123,8 @@ class LocalTTSBackend(TTSBackend):
temperature=params['temperature'], temperature=params['temperature'],
top_k=params['top_k'], top_k=params['top_k'],
top_p=params['top_p'], top_p=params['top_p'],
repetition_penalty=params['repetition_penalty'] repetition_penalty=params['repetition_penalty'],
)
) )
import numpy as np import numpy as np

View File

@@ -323,6 +323,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
const [turbo, setTurbo] = useState(false) const [turbo, setTurbo] = useState(false)
const [charsCollapsed, setCharsCollapsed] = useState(false) const [charsCollapsed, setCharsCollapsed] = useState(false)
const [chaptersCollapsed, setChaptersCollapsed] = useState(false) const [chaptersCollapsed, setChaptersCollapsed] = useState(false)
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
const prevStatusRef = useRef(project.status) const prevStatusRef = useRef(project.status)
const autoExpandedRef = useRef(new Set<string>()) const autoExpandedRef = useRef(new Set<string>())
@@ -691,10 +692,17 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
const chGenerating = chSegs.some(s => s.status === 'generating') const chGenerating = chSegs.some(s => s.status === 'generating')
const chAllDone = chTotal > 0 && chDone === chTotal const chAllDone = chTotal > 0 && chDone === chTotal
const chTitle = ch.title || `${ch.chapter_index + 1}` const chTitle = ch.title || `${ch.chapter_index + 1}`
const chExpanded = expandedChapters.has(ch.id)
const toggleChExpand = () => setExpandedChapters(prev => {
const next = new Set(prev)
if (next.has(ch.id)) next.delete(ch.id)
else next.add(ch.id)
return next
})
return ( return (
<div key={ch.id} className="border rounded px-3 py-2 space-y-2"> <div key={ch.id} className="border rounded px-3 py-2 space-y-2">
<div className="flex items-center justify-between text-sm gap-2"> <div className="flex items-center justify-between text-sm gap-2">
<span className="text-xs font-medium truncate max-w-[55%]">{chTitle}</span> <span className="text-xs font-medium truncate">{chTitle}</span>
<div className="flex gap-1 items-center shrink-0"> <div className="flex gap-1 items-center shrink-0">
{ch.status === 'pending' && ( {ch.status === 'pending' && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => handleParseChapter(ch.id, ch.title)}> <Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => handleParseChapter(ch.id, ch.title)}>
@@ -731,11 +739,33 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</Button> </Button>
)} )}
{chSegs.length > 0 && (
<button onClick={toggleChExpand} className="text-muted-foreground hover:text-foreground">
{chExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
)}
</div> </div>
</div> </div>
{ch.status === 'parsing' && ( {ch.status === 'parsing' && (
<LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} /> <LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} />
)} )}
{chExpanded && chSegs.length > 0 && (
<div className="space-y-1.5 pt-1 border-t">
{chSegs.map(seg => (
<div key={seg.id} className={`space-y-1 ${sequentialPlayingId === seg.id ? 'bg-primary/5 rounded px-1' : ''}`}>
<div className="flex items-start gap-2 text-xs">
<Badge variant="outline" className="shrink-0 text-xs mt-0.5">{seg.character_name || '?'}</Badge>
<span className="text-muted-foreground flex-1 min-w-0 break-words leading-relaxed">{seg.text}</span>
{seg.status === 'generating' && <Loader2 className="h-3 w-3 animate-spin shrink-0 mt-0.5" />}
{seg.status === 'error' && <Badge variant="destructive" className="text-xs shrink-0 mt-0.5"></Badge>}
</div>
{seg.status === 'done' && (
<AudioPlayer audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)} jobId={seg.id} />
)}
</div>
))}
</div>
)}
</div> </div>
) )
})} })}
@@ -748,52 +778,6 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</div> </div>
)} )}
{['generating', 'done'].includes(status) && segments.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-medium text-muted-foreground">
{segments.length}
</div>
<SequentialPlayer segments={segments} projectId={project.id} onPlayingChange={setSequentialPlayingId} />
</div>
<div className="space-y-2">
{segments.slice(0, 50).map(seg => (
<div
key={seg.id}
className={`border rounded px-2 py-2 space-y-2 transition-colors ${sequentialPlayingId === seg.id ? 'border-primary/50 bg-primary/5' : ''}`}
>
<div className="flex items-start gap-2 text-xs">
<Badge variant="outline" className="shrink-0 text-xs mt-0.5">{seg.character_name || '?'}</Badge>
<span className="text-muted-foreground flex-1 min-w-0 break-words leading-relaxed">{seg.text}</span>
{seg.status === 'generating' ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 mt-0.5">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
) : seg.status !== 'done' ? (
<Badge
variant={seg.status === 'error' ? 'destructive' : 'secondary'}
className="shrink-0 text-xs mt-0.5"
>
{seg.status === 'error' ? '出错' : '待生成'}
</Badge>
) : null}
</div>
{seg.status === 'done' && (
<AudioPlayer
audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)}
jobId={seg.id}
/>
)}
</div>
))}
{segments.length > 50 && (
<div className="text-xs text-muted-foreground text-center py-1">
50 {segments.length}
</div>
)}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>