feat(audiobook): enhance chapter expansion functionality in ProjectCard component
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user