diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 44ebb266..75e1b4e5 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -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); }); diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 72a1fc90..7900cd44 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -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() { > - ) : isPlayable ? ( + ) : ( <> @@ -466,6 +480,7 @@ export function HistoryTable() { size="icon" className="h-8 w-8" aria-label="Actions" + disabled={isGenerating} > @@ -495,6 +510,10 @@ export function HistoryTable() { Apply Effects + handleRegenerate(gen.id)}> + + Regenerate + handleDeleteClick(gen.id, gen.profile_name)} disabled={deleteGeneration.isPending} @@ -519,7 +538,7 @@ export function HistoryTable() { )} > - ) : null} + )} @@ -533,13 +552,13 @@ export function HistoryTable() { transition={{ duration: 0.2, ease: 'easeOut' }} className="overflow-hidden" > - + {gen.versions.map((v) => ( { handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id); if (!v.is_default) { @@ -550,8 +569,8 @@ export function HistoryTable() { {v.label} {v.effects_chain && v.effects_chain.length > 0 && ( - - {v.effects_chain.length} fx + + {v.effects_chain.map((e) => e.type).join(' → ')} )} diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index fd3e1b50..1310c383 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -212,6 +212,12 @@ class ApiClient { }); } + async regenerateGeneration(generationId: string): Promise { + return this.request(`/generate/${generationId}/regenerate`, { + method: 'POST', + }); + } + // History async listHistory(query?: HistoryQuery): Promise { const params = new URLSearchParams(); diff --git a/backend/export_import.py b/backend/export_import.py index 6d705aa4..7af1f9b0 100644 --- a/backend/export_import.py +++ b/backend/export_import.py @@ -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() diff --git a/backend/main.py b/backend/main.py index e269b20d..31ca5ad9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(