mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
Regenerate as new version, UI polish, and bugfixes
- Add /generate/{id}/regenerate endpoint that creates a new take version
- Wire regenerate into history dropdown with SSE progress + autoplay
- Add normalize_audio to regenerate path
- Remove duplicate Regenerate menu item
- Show disabled ellipsis menu during generation instead of hiding it
- Fix sf.write format kwarg broken by asyncio.to_thread migration
- Rename clean version label to 'original', effects to 'version-N'
- Show effects chain names in version list instead of 'N fx'
- Include all versions in export package
- Clean up version panel padding
This commit is contained in:
@@ -157,8 +157,21 @@ export function AudioPlayer() {
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer) return;
|
||||
|
||||
// Update store when time changes
|
||||
// Update store when time changes, stop if past duration
|
||||
wavesurfer.on('timeupdate', (time) => {
|
||||
const dur = usePlayerStore.getState().duration;
|
||||
if (dur > 0 && time >= dur) {
|
||||
setCurrentTime(dur);
|
||||
const loop = usePlayerStore.getState().isLooping;
|
||||
if (loop) {
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play();
|
||||
} else {
|
||||
wavesurfer.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCurrentTime(time);
|
||||
});
|
||||
|
||||
|
||||
@@ -224,6 +224,20 @@ export function HistoryTable() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async (generationId: string) => {
|
||||
try {
|
||||
await apiClient.regenerateGeneration(generationId);
|
||||
addPendingGeneration(generationId);
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Regenerate failed',
|
||||
description: error instanceof Error ? error.message : 'Could not regenerate',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyEffects = (generationId: string) => {
|
||||
setEffectsTargetId(generationId);
|
||||
setEffectsChain([]);
|
||||
@@ -457,7 +471,7 @@ export function HistoryTable() {
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
) : isPlayable ? (
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -466,6 +480,7 @@ export function HistoryTable() {
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Actions"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -495,6 +510,10 @@ export function HistoryTable() {
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Apply Effects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Regenerate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
@@ -519,7 +538,7 @@ export function HistoryTable() {
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -533,13 +552,13 @@ export function HistoryTable() {
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-border/50 px-3 pb-2 pt-2">
|
||||
<div className="border-t border-border/50">
|
||||
<div className="divide-y divide-border/40">
|
||||
{gen.versions.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full h-9 px-2 text-left hover:bg-muted/50 transition-colors"
|
||||
className="flex items-center gap-2 w-full h-9 px-3 text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() => {
|
||||
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id);
|
||||
if (!v.is_default) {
|
||||
@@ -550,8 +569,8 @@ export function HistoryTable() {
|
||||
<Play className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium">{v.label}</span>
|
||||
{v.effects_chain && v.effects_chain.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{v.effects_chain.length} fx
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{v.effects_chain.map((e) => e.type).join(' → ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
|
||||
@@ -212,6 +212,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
|
||||
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// History
|
||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
+37
-11
@@ -13,7 +13,7 @@ from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import VoiceProfileResponse
|
||||
from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration
|
||||
from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion
|
||||
from .profiles import create_profile, add_profile_sample
|
||||
from .models import VoiceProfileCreate
|
||||
from . import config
|
||||
@@ -269,16 +269,33 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {generation.profile_id} not found")
|
||||
|
||||
# Get audio file
|
||||
audio_path = Path(generation.audio_path)
|
||||
if not audio_path.exists():
|
||||
raise ValueError(f"Audio file not found: {audio_path}")
|
||||
|
||||
# Get all versions for this generation
|
||||
versions = (
|
||||
db.query(DBGenerationVersion)
|
||||
.filter_by(generation_id=generation_id)
|
||||
.order_by(DBGenerationVersion.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Create ZIP in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Create manifest.json
|
||||
# Build version manifest entries
|
||||
version_entries = []
|
||||
for v in versions:
|
||||
v_path = Path(v.audio_path)
|
||||
effects_chain = None
|
||||
if v.effects_chain:
|
||||
effects_chain = json.loads(v.effects_chain)
|
||||
version_entries.append({
|
||||
"id": v.id,
|
||||
"label": v.label,
|
||||
"is_default": v.is_default,
|
||||
"effects_chain": effects_chain,
|
||||
"filename": v_path.name,
|
||||
})
|
||||
|
||||
manifest = {
|
||||
"version": "1.0",
|
||||
"generation": {
|
||||
@@ -295,13 +312,22 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"language": profile.language,
|
||||
}
|
||||
},
|
||||
"versions": version_entries,
|
||||
}
|
||||
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
|
||||
# Add audio file
|
||||
filename = audio_path.name
|
||||
zip_file.write(audio_path, f"audio/{filename}")
|
||||
# Add all version audio files
|
||||
for v in versions:
|
||||
v_path = Path(v.audio_path)
|
||||
if v_path.exists():
|
||||
zip_file.write(v_path, f"audio/{v_path.name}")
|
||||
|
||||
# Fallback: if no versions exist, include the generation's main audio
|
||||
if not versions:
|
||||
audio_path = Path(generation.audio_path)
|
||||
if audio_path.exists():
|
||||
zip_file.write(audio_path, f"audio/{audio_path.name}")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return zip_buffer.read()
|
||||
|
||||
+115
-3
@@ -828,7 +828,7 @@ async def generate_speech(
|
||||
# Create clean version entry
|
||||
versions_mod.create_version(
|
||||
generation_id=generation_id,
|
||||
label="clean",
|
||||
label="original",
|
||||
audio_path=str(clean_audio_path),
|
||||
db=bg_db,
|
||||
effects_chain=None,
|
||||
@@ -849,7 +849,7 @@ async def generate_speech(
|
||||
final_audio_path = str(processed_path)
|
||||
versions_mod.create_version(
|
||||
generation_id=generation_id,
|
||||
label="processed",
|
||||
label="version-2",
|
||||
audio_path=str(processed_path),
|
||||
db=bg_db,
|
||||
effects_chain=effects_chain_config,
|
||||
@@ -978,6 +978,118 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
|
||||
return models.GenerationResponse.model_validate(gen)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/generate/{generation_id}/regenerate",
|
||||
response_model=models.GenerationResponse,
|
||||
)
|
||||
async def regenerate_generation(generation_id: str, db: Session = Depends(get_db)):
|
||||
"""Re-run TTS with the same parameters and save the result as a new version."""
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not gen:
|
||||
raise HTTPException(status_code=404, detail="Generation not found")
|
||||
if (gen.status or "completed") != "completed":
|
||||
raise HTTPException(status_code=400, detail="Generation must be completed to regenerate")
|
||||
|
||||
from .backends import get_tts_backend_for_engine
|
||||
from . import versions as versions_mod
|
||||
|
||||
regen_engine = gen.engine or "qwen"
|
||||
regen_model_size = gen.model_size or "1.7B"
|
||||
tts_model = get_tts_backend_for_engine(regen_engine)
|
||||
|
||||
# Set to generating so the UI shows the loader and SSE picks it up
|
||||
gen.status = "generating"
|
||||
gen.error = None
|
||||
db.commit()
|
||||
db.refresh(gen)
|
||||
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_generation(
|
||||
task_id=generation_id,
|
||||
profile_id=gen.profile_id,
|
||||
text=gen.text,
|
||||
)
|
||||
|
||||
version_id = str(uuid.uuid4())
|
||||
|
||||
async def _run_regenerate():
|
||||
bg_db = next(get_db())
|
||||
try:
|
||||
if regen_engine == "qwen":
|
||||
await tts_model.load_model_async(regen_model_size)
|
||||
else:
|
||||
await tts_model.load_model()
|
||||
|
||||
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
||||
gen.profile_id,
|
||||
bg_db,
|
||||
use_cache=True,
|
||||
engine=regen_engine,
|
||||
)
|
||||
|
||||
from .utils.chunked_tts import generate_chunked
|
||||
|
||||
trim_fn = None
|
||||
if regen_engine in ("chatterbox", "chatterbox_turbo"):
|
||||
from .utils.audio import trim_tts_output
|
||||
trim_fn = trim_tts_output
|
||||
|
||||
audio, sample_rate = await generate_chunked(
|
||||
tts_model,
|
||||
gen.text,
|
||||
voice_prompt,
|
||||
language=gen.language,
|
||||
seed=None, # New seed for variation
|
||||
instruct=gen.instruct,
|
||||
trim_fn=trim_fn,
|
||||
)
|
||||
|
||||
from .utils.audio import normalize_audio, save_audio
|
||||
audio = normalize_audio(audio)
|
||||
|
||||
duration = len(audio) / sample_rate
|
||||
audio_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
|
||||
|
||||
save_audio(audio, str(audio_path), sample_rate)
|
||||
|
||||
# Count existing versions to auto-label
|
||||
existing = versions_mod.list_versions(generation_id, bg_db)
|
||||
label = f"take-{len(existing) + 1}"
|
||||
|
||||
versions_mod.create_version(
|
||||
generation_id=generation_id,
|
||||
label=label,
|
||||
audio_path=str(audio_path),
|
||||
db=bg_db,
|
||||
effects_chain=None,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
await history.update_generation_status(
|
||||
generation_id=generation_id,
|
||||
status="completed",
|
||||
db=bg_db,
|
||||
audio_path=str(audio_path),
|
||||
duration=duration,
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await history.update_generation_status(
|
||||
generation_id=generation_id,
|
||||
status="failed",
|
||||
db=bg_db,
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
task_manager.complete_generation(generation_id)
|
||||
bg_db.close()
|
||||
|
||||
_enqueue_generation(_run_regenerate())
|
||||
|
||||
return models.GenerationResponse.model_validate(gen)
|
||||
|
||||
|
||||
@app.get("/generate/{generation_id}/status")
|
||||
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
|
||||
"""SSE endpoint that streams generation status updates.
|
||||
@@ -1599,7 +1711,7 @@ async def preview_effects(
|
||||
# Write to in-memory buffer
|
||||
import soundfile as sf
|
||||
buf = io.BytesIO()
|
||||
await asyncio.to_thread(sf.write, buf, processed, sample_rate, "WAV")
|
||||
await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV"))
|
||||
buf.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
Reference in New Issue
Block a user