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;
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);
});
+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) => {
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" />
+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
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams();
+37 -11
View File
@@ -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
View File
@@ -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(