mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 22:55:15 -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;
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+37
-11
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user