mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 07:10: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:
+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