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:
Jamie Pine
2026-03-14 08:34:58 -07:00
parent 25134b4ba9
commit 00c5b75ffb
5 changed files with 197 additions and 21 deletions
+14 -1
View File
@@ -157,8 +157,21 @@ export function AudioPlayer() {
const wavesurfer = wavesurferRef.current; const wavesurfer = wavesurferRef.current;
if (!wavesurfer) return; if (!wavesurfer) return;
// Update store when time changes // Update store when time changes, stop if past duration
wavesurfer.on('timeupdate', (time) => { 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); setCurrentTime(time);
}); });
+25 -6
View File
@@ -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) => { const handleApplyEffects = (generationId: string) => {
setEffectsTargetId(generationId); setEffectsTargetId(generationId);
setEffectsChain([]); setEffectsChain([]);
@@ -457,7 +471,7 @@ export function HistoryTable() {
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
</Button> </Button>
) : isPlayable ? ( ) : (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -466,6 +480,7 @@ export function HistoryTable() {
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
aria-label="Actions" aria-label="Actions"
disabled={isGenerating}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
@@ -495,6 +510,10 @@ export function HistoryTable() {
<Wand2 className="mr-2 h-4 w-4" /> <Wand2 className="mr-2 h-4 w-4" />
Apply Effects Apply Effects
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
Regenerate
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)} onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending} disabled={deleteGeneration.isPending}
@@ -519,7 +538,7 @@ export function HistoryTable() {
</Button> </Button>
)} )}
</> </>
) : null} )}
</div> </div>
</div> </div>
@@ -533,13 +552,13 @@ export function HistoryTable() {
transition={{ duration: 0.2, ease: 'easeOut' }} transition={{ duration: 0.2, ease: 'easeOut' }}
className="overflow-hidden" 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"> <div className="divide-y divide-border/40">
{gen.versions.map((v) => ( {gen.versions.map((v) => (
<button <button
key={v.id} key={v.id}
type="button" 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={() => { onClick={() => {
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id); handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id);
if (!v.is_default) { if (!v.is_default) {
@@ -550,8 +569,8 @@ export function HistoryTable() {
<Play className="h-3 w-3 shrink-0 text-muted-foreground" /> <Play className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium">{v.label}</span> <span className="truncate text-xs font-medium">{v.label}</span>
{v.effects_chain && v.effects_chain.length > 0 && ( {v.effects_chain && v.effects_chain.length > 0 && (
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground truncate">
{v.effects_chain.length} fx {v.effects_chain.map((e) => e.type).join(' → ')}
</span> </span>
)} )}
<span className="flex-1" /> <span className="flex-1" />
+6
View File
@@ -212,6 +212,12 @@ class ApiClient {
}); });
} }
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
method: 'POST',
});
}
// History // History
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> { async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
+36 -10
View File
@@ -13,7 +13,7 @@ from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import VoiceProfileResponse 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 .profiles import create_profile, add_profile_sample
from .models import VoiceProfileCreate from .models import VoiceProfileCreate
from . import config from . import config
@@ -269,16 +269,33 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
if not profile: if not profile:
raise ValueError(f"Profile {generation.profile_id} not found") raise ValueError(f"Profile {generation.profile_id} not found")
# Get audio file # Get all versions for this generation
audio_path = Path(generation.audio_path) versions = (
if not audio_path.exists(): db.query(DBGenerationVersion)
raise ValueError(f"Audio file not found: {audio_path}") .filter_by(generation_id=generation_id)
.order_by(DBGenerationVersion.created_at)
.all()
)
# Create ZIP in memory # Create ZIP in memory
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: 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 = { manifest = {
"version": "1.0", "version": "1.0",
"generation": { "generation": {
@@ -295,13 +312,22 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
"name": profile.name, "name": profile.name,
"description": profile.description, "description": profile.description,
"language": profile.language, "language": profile.language,
} },
"versions": version_entries,
} }
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2)) zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
# Add audio file # Add all version audio files
filename = audio_path.name for v in versions:
zip_file.write(audio_path, f"audio/{filename}") 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) zip_buffer.seek(0)
return zip_buffer.read() return zip_buffer.read()
+115 -3
View File
@@ -828,7 +828,7 @@ async def generate_speech(
# Create clean version entry # Create clean version entry
versions_mod.create_version( versions_mod.create_version(
generation_id=generation_id, generation_id=generation_id,
label="clean", label="original",
audio_path=str(clean_audio_path), audio_path=str(clean_audio_path),
db=bg_db, db=bg_db,
effects_chain=None, effects_chain=None,
@@ -849,7 +849,7 @@ async def generate_speech(
final_audio_path = str(processed_path) final_audio_path = str(processed_path)
versions_mod.create_version( versions_mod.create_version(
generation_id=generation_id, generation_id=generation_id,
label="processed", label="version-2",
audio_path=str(processed_path), audio_path=str(processed_path),
db=bg_db, db=bg_db,
effects_chain=effects_chain_config, 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) 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") @app.get("/generate/{generation_id}/status")
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)): async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
"""SSE endpoint that streams generation status updates. """SSE endpoint that streams generation status updates.
@@ -1599,7 +1711,7 @@ async def preview_effects(
# Write to in-memory buffer # Write to in-memory buffer
import soundfile as sf import soundfile as sf
buf = io.BytesIO() 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) buf.seek(0)
return StreamingResponse( return StreamingResponse(