mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
fix: tighten kokoro profile handling
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { FormControl } from '@/components/ui/form';
|
||||
import {
|
||||
@@ -41,12 +42,9 @@ const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
|
||||
/** Engines that support cloned (reference audio) profiles. */
|
||||
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
|
||||
|
||||
/**
|
||||
* All engine options are always available. The profile grid already
|
||||
* filters by engine, so the dropdown doesn't need to restrict options.
|
||||
*/
|
||||
function getAvailableOptions(_selectedProfile?: VoiceProfileResponse | null) {
|
||||
return ENGINE_OPTIONS;
|
||||
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) {
|
||||
if (!selectedProfile) return ENGINE_OPTIONS;
|
||||
return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine));
|
||||
}
|
||||
|
||||
function getSelectValue(engine: string, modelSize?: string): string {
|
||||
@@ -108,12 +106,13 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
|
||||
const selectValue = getSelectValue(engine, modelSize);
|
||||
const availableOptions = getAvailableOptions(selectedProfile);
|
||||
|
||||
// If current engine isn't in available options, auto-switch to first available
|
||||
const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
|
||||
if (!currentEngineAvailable && availableOptions.length > 0) {
|
||||
// Defer to avoid setting state during render
|
||||
setTimeout(() => handleEngineChange(form, availableOptions[0].value), 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentEngineAvailable && availableOptions.length > 0) {
|
||||
handleEngineChange(form, availableOptions[0].value);
|
||||
}
|
||||
}, [availableOptions, currentEngineAvailable, form]);
|
||||
|
||||
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
|
||||
const triggerClass = compact
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
useAddSample,
|
||||
useCreateProfile,
|
||||
useDeleteAvatar,
|
||||
useDeleteProfile,
|
||||
useProfile,
|
||||
useUpdateProfile,
|
||||
useUploadAvatar,
|
||||
@@ -59,6 +60,15 @@ import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
import { SampleList } from './SampleList';
|
||||
|
||||
const MAX_AUDIO_DURATION_SECONDS = 30;
|
||||
const PRESET_ONLY_ENGINES = new Set(['kokoro']);
|
||||
const DEFAULT_ENGINE_OPTIONS = [
|
||||
{ value: 'qwen', label: 'Qwen3-TTS' },
|
||||
{ value: 'luxtts', label: 'LuxTTS' },
|
||||
{ value: 'chatterbox', label: 'Chatterbox' },
|
||||
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo' },
|
||||
{ value: 'tada', label: 'TADA' },
|
||||
{ value: 'kokoro', label: 'Kokoro 82M' },
|
||||
] as const;
|
||||
|
||||
const baseProfileSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
@@ -119,6 +129,7 @@ export function ProfileForm() {
|
||||
const createProfile = useCreateProfile();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const addSample = useAddSample();
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const deleteAvatar = useDeleteAvatar();
|
||||
const transcribe = useTranscription();
|
||||
@@ -259,6 +270,12 @@ export function ProfileForm() {
|
||||
(!isCreating && editingProfile?.voice_type === 'preset')),
|
||||
});
|
||||
const presetVoices = presetVoicesData?.voices ?? [];
|
||||
const isSampleBasedProfile = isCreating
|
||||
? voiceSource === 'clone'
|
||||
: editingProfile?.voice_type !== 'preset';
|
||||
const availableDefaultEngines = DEFAULT_ENGINE_OPTIONS.filter(
|
||||
(option) => !isSampleBasedProfile || !PRESET_ONLY_ENGINES.has(option.value),
|
||||
);
|
||||
|
||||
// Show recording errors
|
||||
useEffect(() => {
|
||||
@@ -348,6 +365,15 @@ export function ProfileForm() {
|
||||
}
|
||||
}, [editingProfile, profileFormDraft, open, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
defaultEngine &&
|
||||
!availableDefaultEngines.some((option) => option.value === defaultEngine)
|
||||
) {
|
||||
setDefaultEngine('');
|
||||
}
|
||||
}, [availableDefaultEngines, defaultEngine]);
|
||||
|
||||
async function handleTranscribe() {
|
||||
const file = form.getValues('sampleFile');
|
||||
if (!file) {
|
||||
@@ -638,12 +664,32 @@ export function ProfileForm() {
|
||||
description: `"${data.name}" has been created with a sample.`,
|
||||
});
|
||||
} catch (sampleError) {
|
||||
// Profile was created but sample failed - still show error
|
||||
let rollbackSucceeded = false;
|
||||
try {
|
||||
await deleteProfile.mutateAsync(profile.id);
|
||||
rollbackSucceeded = true;
|
||||
} catch (rollbackError) {
|
||||
toast({
|
||||
title: 'Rollback failed',
|
||||
description:
|
||||
rollbackError instanceof Error
|
||||
? rollbackError.message
|
||||
: 'Created profile could not be removed after sample upload failure.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to add sample',
|
||||
description: `Profile "${data.name}" was created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
|
||||
description:
|
||||
sampleError instanceof Error
|
||||
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
|
||||
: rollbackSucceeded
|
||||
? 'Failed to add sample. The profile was rolled back.'
|
||||
: 'Failed to add sample.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1140,12 +1186,11 @@ export function ProfileForm() {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">No preference</SelectItem>
|
||||
<SelectItem value="qwen">Qwen3-TTS</SelectItem>
|
||||
<SelectItem value="luxtts">LuxTTS</SelectItem>
|
||||
<SelectItem value="chatterbox">Chatterbox</SelectItem>
|
||||
<SelectItem value="chatterbox_turbo">Chatterbox Turbo</SelectItem>
|
||||
<SelectItem value="tada">TADA</SelectItem>
|
||||
<SelectItem value="kokoro">Kokoro 82M</SelectItem>
|
||||
{availableDefaultEngines.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ProfileList() {
|
||||
No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The default voice will be used. Create a profile to choose a specific voice.
|
||||
Create a profile to choose a specific voice before generating.
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -191,11 +191,12 @@ def _resolve_relative_paths(engine, tables: set[str]) -> None:
|
||||
Idempotent: absolute paths are left untouched.
|
||||
|
||||
Strategy: paths like "data/generations/abc.wav" are rebased onto the
|
||||
configured data directory. If the path starts with "data/", strip that
|
||||
prefix and prepend get_data_dir(). Otherwise, try resolving relative to
|
||||
CWD as a fallback.
|
||||
configured data directory. If the path starts with "data/", strip that
|
||||
prefix and prepend get_data_dir(). Otherwise, join the relative path
|
||||
directly under get_data_dir().
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import get_data_dir
|
||||
|
||||
data_dir = get_data_dir()
|
||||
@@ -229,11 +230,7 @@ def _resolve_relative_paths(engine, tables: set[str]) -> None:
|
||||
else:
|
||||
rebased = data_dir / p
|
||||
|
||||
if rebased.exists():
|
||||
resolved = rebased
|
||||
else:
|
||||
# Fallback: resolve relative to CWD
|
||||
resolved = p.resolve()
|
||||
resolved = rebased.resolve()
|
||||
|
||||
if resolved.exists():
|
||||
conn.execute(
|
||||
|
||||
@@ -7,13 +7,12 @@ import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
from .. import models
|
||||
from ..app import safe_content_disposition
|
||||
from ..database import VoiceProfile as DBVoiceProfile, get_db
|
||||
from ..services import channels, export_import, profiles
|
||||
@@ -131,13 +130,15 @@ async def seed_preset_profiles_route(
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Skip name collisions
|
||||
if db.query(DBVoiceProfile).filter_by(name=profile_name).first():
|
||||
continue
|
||||
unique_name = profile_name
|
||||
suffix = 2
|
||||
while db.query(DBVoiceProfile).filter_by(name=unique_name).first():
|
||||
unique_name = f"{profile_name} {suffix}"
|
||||
suffix += 1
|
||||
|
||||
profile = DBVoiceProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
name=profile_name,
|
||||
name=unique_name,
|
||||
description=f"Kokoro preset voice — {display_name} ({gender})",
|
||||
language=lang,
|
||||
voice_type="preset",
|
||||
@@ -394,8 +395,6 @@ async def update_profile_effects(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Set or clear the default effects chain for a voice profile."""
|
||||
import json as _json
|
||||
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
+114
-55
@@ -1,36 +1,30 @@
|
||||
"""
|
||||
Voice profile management module.
|
||||
"""
|
||||
"""Voice profile management module."""
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from .. import config
|
||||
from ..database import Generation as DBGeneration, ProfileSample as DBProfileSample, VoiceProfile as DBVoiceProfile
|
||||
from ..models import (
|
||||
EffectConfig,
|
||||
ProfileSampleResponse,
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
)
|
||||
from ..utils.audio import save_audio, validate_and_load_reference_audio
|
||||
from ..utils.cache import _get_cache_dir, clear_profile_cache
|
||||
from ..utils.images import process_avatar, validate_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..models import (
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
ProfileSampleCreate,
|
||||
ProfileSampleResponse,
|
||||
)
|
||||
from ..database import (
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
ProfileSample as DBProfileSample,
|
||||
Generation as DBGeneration,
|
||||
)
|
||||
from ..models import EffectConfig
|
||||
from ..utils.audio import validate_reference_audio, validate_and_load_reference_audio, load_audio, save_audio
|
||||
from ..utils.images import validate_image, process_avatar
|
||||
from ..utils.cache import _get_cache_dir, clear_profile_cache
|
||||
from .tts import get_tts_model
|
||||
from .. import config
|
||||
import json as _json
|
||||
CLONING_ENGINES = {"qwen", "luxtts", "chatterbox", "chatterbox_turbo", "tada"}
|
||||
|
||||
|
||||
def _profile_to_response(
|
||||
@@ -67,6 +61,37 @@ def _profile_to_response(
|
||||
)
|
||||
|
||||
|
||||
def _validate_profile_fields(
|
||||
*,
|
||||
voice_type: str,
|
||||
preset_engine: str | None,
|
||||
preset_voice_id: str | None,
|
||||
design_prompt: str | None,
|
||||
default_engine: str | None,
|
||||
) -> str | None:
|
||||
if voice_type == "preset":
|
||||
if not preset_engine or not preset_voice_id:
|
||||
return "Preset profiles require both preset_engine and preset_voice_id"
|
||||
if default_engine and default_engine != preset_engine:
|
||||
return "Preset profiles must use their preset_engine as default_engine"
|
||||
return None
|
||||
|
||||
if voice_type == "designed":
|
||||
if not design_prompt or not design_prompt.strip():
|
||||
return "Designed profiles require a design_prompt"
|
||||
if preset_engine or preset_voice_id:
|
||||
return "Designed profiles cannot set preset_engine or preset_voice_id"
|
||||
return None
|
||||
|
||||
if preset_engine or preset_voice_id:
|
||||
return "Cloned profiles cannot set preset_engine or preset_voice_id"
|
||||
if design_prompt:
|
||||
return "Cloned profiles cannot set design_prompt"
|
||||
if default_engine and default_engine not in CLONING_ENGINES:
|
||||
return f"Cloned profiles cannot use default engine '{default_engine}'"
|
||||
return None
|
||||
|
||||
|
||||
async def create_profile(
|
||||
data: VoiceProfileCreate,
|
||||
db: Session,
|
||||
@@ -94,6 +119,16 @@ async def create_profile(
|
||||
if voice_type == "preset" and data.preset_engine and not default_engine:
|
||||
default_engine = data.preset_engine
|
||||
|
||||
validation_error = _validate_profile_fields(
|
||||
voice_type=voice_type,
|
||||
preset_engine=data.preset_engine,
|
||||
preset_voice_id=data.preset_voice_id,
|
||||
design_prompt=data.design_prompt,
|
||||
default_engine=default_engine,
|
||||
)
|
||||
if validation_error:
|
||||
raise ValueError(validation_error)
|
||||
|
||||
db_profile = DBVoiceProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
@@ -180,7 +215,7 @@ async def add_profile_sample(
|
||||
async def get_profile(
|
||||
profile_id: str,
|
||||
db: Session,
|
||||
) -> Optional[VoiceProfileResponse]:
|
||||
) -> VoiceProfileResponse | None:
|
||||
"""
|
||||
Get a voice profile by ID.
|
||||
|
||||
@@ -201,7 +236,7 @@ async def get_profile(
|
||||
async def get_profile_samples(
|
||||
profile_id: str,
|
||||
db: Session,
|
||||
) -> List[ProfileSampleResponse]:
|
||||
) -> list[ProfileSampleResponse]:
|
||||
"""
|
||||
Get all samples for a profile.
|
||||
|
||||
@@ -216,7 +251,7 @@ async def get_profile_samples(
|
||||
return [ProfileSampleResponse.model_validate(s) for s in samples]
|
||||
|
||||
|
||||
async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
|
||||
async def list_profiles(db: Session) -> list[VoiceProfileResponse]:
|
||||
"""
|
||||
List all voice profiles with generation and sample counts.
|
||||
|
||||
@@ -257,7 +292,7 @@ async def update_profile(
|
||||
profile_id: str,
|
||||
data: VoiceProfileCreate,
|
||||
db: Session,
|
||||
) -> Optional[VoiceProfileResponse]:
|
||||
) -> VoiceProfileResponse | None:
|
||||
"""
|
||||
Update a voice profile.
|
||||
|
||||
@@ -281,6 +316,22 @@ async def update_profile(
|
||||
if existing_profile:
|
||||
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
|
||||
|
||||
voice_type = getattr(profile, "voice_type", None) or "cloned"
|
||||
preset_engine = getattr(profile, "preset_engine", None)
|
||||
preset_voice_id = getattr(profile, "preset_voice_id", None)
|
||||
design_prompt = getattr(profile, "design_prompt", None)
|
||||
default_engine = data.default_engine if data.default_engine is not None else getattr(profile, "default_engine", None)
|
||||
|
||||
validation_error = _validate_profile_fields(
|
||||
voice_type=voice_type,
|
||||
preset_engine=preset_engine,
|
||||
preset_voice_id=preset_voice_id,
|
||||
design_prompt=design_prompt,
|
||||
default_engine=default_engine,
|
||||
)
|
||||
if validation_error:
|
||||
raise ValueError(validation_error)
|
||||
|
||||
profile.name = data.name
|
||||
profile.description = data.description
|
||||
profile.language = data.language
|
||||
@@ -366,7 +417,7 @@ async def update_profile_sample(
|
||||
sample_id: str,
|
||||
reference_text: str,
|
||||
db: Session,
|
||||
) -> Optional[ProfileSampleResponse]:
|
||||
) -> ProfileSampleResponse | None:
|
||||
"""
|
||||
Update a profile sample's reference text.
|
||||
|
||||
@@ -428,6 +479,12 @@ async def create_voice_prompt_for_profile(
|
||||
|
||||
# ── Preset profiles: return engine-specific voice reference ──
|
||||
if voice_type == "preset":
|
||||
if not profile.preset_engine or not profile.preset_voice_id:
|
||||
raise ValueError(f"Preset profile {profile_id} is missing preset engine metadata")
|
||||
if profile.preset_engine != engine:
|
||||
raise ValueError(
|
||||
f"Preset profile {profile_id} only supports engine '{profile.preset_engine}', not '{engine}'"
|
||||
)
|
||||
return {
|
||||
"voice_type": "preset",
|
||||
"preset_engine": profile.preset_engine,
|
||||
@@ -436,11 +493,16 @@ async def create_voice_prompt_for_profile(
|
||||
|
||||
# ── Designed profiles: return text description (future) ──
|
||||
if voice_type == "designed":
|
||||
if not profile.design_prompt or not profile.design_prompt.strip():
|
||||
raise ValueError(f"Designed profile {profile_id} is missing design_prompt")
|
||||
return {
|
||||
"voice_type": "designed",
|
||||
"design_prompt": profile.design_prompt,
|
||||
}
|
||||
|
||||
if engine not in CLONING_ENGINES:
|
||||
raise ValueError(f"Engine '{engine}' does not support cloned voice profiles")
|
||||
|
||||
# ── Cloned profiles: create from audio samples ──
|
||||
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
|
||||
|
||||
@@ -457,34 +519,34 @@ async def create_voice_prompt_for_profile(
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return voice_prompt
|
||||
else:
|
||||
audio_paths = [s.audio_path for s in samples]
|
||||
reference_texts = [s.reference_text for s in samples]
|
||||
|
||||
combined_audio, combined_text = await tts_model.combine_voice_prompts(
|
||||
audio_paths,
|
||||
reference_texts,
|
||||
)
|
||||
audio_paths = [s.audio_path for s in samples]
|
||||
reference_texts = [s.reference_text for s in samples]
|
||||
|
||||
# Save combined audio to cache directory (persistent)
|
||||
# Create a hash of sample IDs to identify this specific combination
|
||||
import hashlib
|
||||
combined_audio, combined_text = await tts_model.combine_voice_prompts(
|
||||
audio_paths,
|
||||
reference_texts,
|
||||
)
|
||||
|
||||
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
||||
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
|
||||
# Save combined audio to cache directory (persistent)
|
||||
# Create a hash of sample IDs to identify this specific combination
|
||||
import hashlib
|
||||
|
||||
cache_dir = _get_cache_dir()
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
|
||||
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
||||
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
|
||||
|
||||
save_audio(combined_audio, str(combined_path), 24000)
|
||||
cache_dir = _get_cache_dir()
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
|
||||
|
||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||
str(combined_path),
|
||||
combined_text,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return voice_prompt
|
||||
save_audio(combined_audio, str(combined_path), 24000)
|
||||
|
||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||
str(combined_path),
|
||||
combined_text,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return voice_prompt
|
||||
|
||||
|
||||
async def upload_avatar(
|
||||
@@ -571,6 +633,3 @@ async def delete_avatar(
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
# Voicebox API Refactor Plan
|
||||
|
||||
Date: 2026-03-19
|
||||
Status: Proposed
|
||||
Scope: Backend HTTP API structure, schemas, docs, and compatibility strategy
|
||||
|
||||
## Goals
|
||||
|
||||
- Make the API easier to understand and automate against.
|
||||
- Improve endpoint consistency without breaking the desktop app or existing local integrations.
|
||||
- Align generated docs and checked-in OpenAPI artifacts with the actual backend.
|
||||
- Separate app-facing resources from internal or operational actions.
|
||||
- Create a migration path toward a cleaner `v2` resource model while preserving `v1` routes during transition.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Rewriting backend business logic or generation internals.
|
||||
- Introducing authentication for all deployment modes in the first pass.
|
||||
- Changing storage models or database schema unless required for API correctness.
|
||||
- Removing current routes immediately.
|
||||
|
||||
## Current Pain Points
|
||||
|
||||
- Mixed endpoint styles: resource-oriented (`/profiles`) and command-oriented (`/generate`, `/tasks/clear`) coexist.
|
||||
- Related generation resources are split across multiple namespaces: `/generate`, `/history`, `/audio`, `/effects`, and `/generations/.../versions`.
|
||||
- Response payloads vary widely: typed models, raw dicts with `message`, booleans, and `HTTPException(detail=...)` payloads.
|
||||
- Some async flows use exception-shaped `202` responses instead of first-class task contracts.
|
||||
- Checked-in OpenAPI output can drift from actual backend models.
|
||||
- Operational endpoints such as `/shutdown` are exposed in the same surface as user workflows.
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. Prefer additive changes before destructive changes.
|
||||
2. Keep `v1` behavior working until the app and docs fully migrate.
|
||||
3. Add compatibility shims close to the routing layer, not deep in services.
|
||||
4. Treat OpenAPI as a release artifact that must be kept in sync.
|
||||
5. Standardize public contracts before renaming everything.
|
||||
|
||||
## Target API Shape
|
||||
|
||||
This is the intended end state, not the immediate first milestone.
|
||||
|
||||
### Core Resources
|
||||
|
||||
- `/profiles`
|
||||
- `/profiles/{profile_id}/samples`
|
||||
- `/profiles/{profile_id}/avatar`
|
||||
- `/profiles/{profile_id}/effects`
|
||||
- `/generations`
|
||||
- `/generations/{generation_id}`
|
||||
- `/generations/{generation_id}/status`
|
||||
- `/generations/{generation_id}/audio`
|
||||
- `/generations/{generation_id}/versions`
|
||||
- `/generations/{generation_id}/versions/{version_id}`
|
||||
- `/generations/{generation_id}/versions/{version_id}/audio`
|
||||
- `/stories`
|
||||
- `/stories/{story_id}/items`
|
||||
- `/effects/presets`
|
||||
- `/models`
|
||||
- `/models/{model_name}`
|
||||
- `/tasks`
|
||||
|
||||
### Operational or Internal Endpoints
|
||||
|
||||
Move under an explicit namespace and disable where appropriate:
|
||||
|
||||
- `/admin/shutdown`
|
||||
- `/admin/watchdog/disable`
|
||||
- `/admin/cache/clear`
|
||||
- `/admin/tasks/clear`
|
||||
|
||||
### Response Contract Direction
|
||||
|
||||
- Resource reads and writes return typed resource models.
|
||||
- Delete and action endpoints return small typed action result models.
|
||||
- Errors use a consistent structure.
|
||||
- Async actions return explicit task metadata instead of overloading `detail`.
|
||||
|
||||
## Migration Strategy Overview
|
||||
|
||||
The refactor is split into six phases. Phases 1-3 are the highest impact and safest to ship first.
|
||||
|
||||
| Phase | Focus | Est. Duration | Risk | Backward Compatibility |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Documentation and contract correctness | 2-3 days | Low | Full |
|
||||
| 2 | Response and error consistency | 3-5 days | Low-Medium | Full |
|
||||
| 3 | Router structure and internal organization | 3-4 days | Low | Full |
|
||||
| 4 | Additive `v2` resource endpoints | 1-2 weeks | Medium | Full |
|
||||
| 5 | Client migration and deprecation rollout | 1 week | Medium | Full during rollout |
|
||||
| 6 | Cleanup and optional removals | 1-2 releases | Medium-High | Partial after notice |
|
||||
|
||||
## Phase 1: Fix Contract Drift First
|
||||
|
||||
Priority: Highest
|
||||
Outcome: The documented API matches the running backend.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- `docs/openapi.json` can become stale.
|
||||
- Generated API reference pages may describe outdated request bodies.
|
||||
- App metadata still frames the backend too narrowly.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Update FastAPI app metadata in `backend/app.py`.
|
||||
- Replace the old Qwen-specific description with a multi-engine Voicebox API description.
|
||||
- Add tags metadata for major domains if desired.
|
||||
2. Regenerate OpenAPI from the running app using the existing docs script flow.
|
||||
3. Compare `backend/models.py` to the checked-in schema.
|
||||
- Verify `GenerationRequest`, effects endpoints, stories endpoints, and model endpoints.
|
||||
4. Regenerate or refresh API reference pages under `docs/content/docs/api-reference/`.
|
||||
5. Add a CI check that fails if `docs/openapi.json` is out of date.
|
||||
6. Add a short maintainer note describing when schema regeneration is required.
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- No route changes.
|
||||
- No payload changes.
|
||||
- Safe to release immediately.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- `docs/openapi.json` matches the live app.
|
||||
- Generated docs include all currently supported generate parameters.
|
||||
- No frontend code changes required.
|
||||
|
||||
## Phase 2: Standardize Responses and Errors
|
||||
|
||||
Priority: High
|
||||
Outcome: Clients can handle responses predictably.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Delete endpoints return ad hoc message dicts.
|
||||
- Toggle endpoints return special one-off payloads.
|
||||
- `202` async responses are encoded as `HTTPException(detail=...)` in some places.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add shared response models in `backend/models.py`.
|
||||
- `ActionResult`
|
||||
- `DeleteResult`
|
||||
- `ToggleFavoriteResponse`
|
||||
- `AcceptedTaskResponse`
|
||||
- `ApiError`
|
||||
2. Convert routes that currently return raw dicts to explicit `response_model`s.
|
||||
- `DELETE /profiles/{profile_id}`
|
||||
- `DELETE /history/{generation_id}`
|
||||
- `DELETE /stories/{story_id}`
|
||||
- `POST /tasks/clear`
|
||||
- `POST /cache/clear`
|
||||
- similar endpoints across routes
|
||||
3. Replace exception-shaped `202` responses in `transcription.py` with an explicit accepted response body.
|
||||
- Return `JSONResponse(status_code=202, content=...)` or typed FastAPI response model.
|
||||
4. Add a global exception handler for known API errors if helpful.
|
||||
- Normalize `ValueError` to `400` with a consistent error body.
|
||||
- Preserve FastAPI validation errors for now, or wrap them in a consistent top-level shape in a later pass.
|
||||
5. Document the stable error contract in the docs.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- Keep field names inside successful payloads compatible where possible.
|
||||
- For existing dict responses, preserve the current keys while introducing typed models with the same shape.
|
||||
- For `202` flows, support both old and new client handling for one release if needed.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 3-5 engineering days including tests and docs refresh.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- All mutation endpoints declare response models.
|
||||
- Clients can programmatically distinguish success, accepted, and error cases without special casing `detail` payloads.
|
||||
|
||||
## Phase 3: Normalize Router Structure Internally
|
||||
|
||||
Priority: High
|
||||
Outcome: The backend becomes easier to maintain before public path changes begin.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Route files hardcode full paths and are all mounted at root.
|
||||
- There is no consistent use of router prefixes or tags.
|
||||
- Route grouping in code does not cleanly express the public API shape.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add prefixes and tags to routers.
|
||||
- `profiles`: `prefix="/profiles"`
|
||||
- `generations`: `prefix="/generate"` for now or split additive aliases carefully
|
||||
- `history`: `prefix="/history"`
|
||||
- `effects`: `prefix="/effects"`
|
||||
- and so on
|
||||
2. Convert route declarations to relative paths within each router.
|
||||
3. Introduce a small route compatibility layer for routes that are likely to move later.
|
||||
- Example: helper functions that can be mounted under both old and new paths.
|
||||
4. Add explicit route tags so Swagger/OpenAPI groups are coherent.
|
||||
5. Document the intended public ownership of each namespace.
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- No public path changes yet if existing paths are preserved through prefixes and aliases.
|
||||
- Mostly internal refactoring.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 3-4 engineering days.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- All route modules use prefixes and tags.
|
||||
- Route registration in `backend/routes/__init__.py` becomes simpler.
|
||||
- OpenAPI groups read cleanly by domain.
|
||||
|
||||
## Phase 4: Introduce Additive `v2` Resource Endpoints
|
||||
|
||||
Priority: High
|
||||
Outcome: A cleaner API exists without breaking the current one.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Generation-related resources are fragmented.
|
||||
- Sample and audio endpoints are not consistently modeled as resources.
|
||||
- Command-style naming makes the API harder to reason about.
|
||||
|
||||
### New Endpoints to Add
|
||||
|
||||
These should be introduced alongside current endpoints, not as replacements.
|
||||
|
||||
- `POST /generations` -> alias for current `/generate`
|
||||
- `GET /generations` -> alias for current `/history`
|
||||
- `GET /generations/{id}` -> alias for current `/history/{id}`
|
||||
- `POST /generations/{id}/retry` -> alias for current `/generate/{id}/retry`
|
||||
- `POST /generations/{id}/regenerate` -> alias for current `/generate/{id}/regenerate`
|
||||
- `GET /generations/{id}/status` -> alias for current `/generate/{id}/status`
|
||||
- `POST /generations/stream` -> alias for current `/generate/stream`
|
||||
- `GET /generations/{id}/audio` -> alias for current `/audio/{generation_id}`
|
||||
- `GET /generations/{id}/export` -> alias for current `/history/{generation_id}/export`
|
||||
- `GET /generations/{id}/export-audio` -> alias for current `/history/{generation_id}/export-audio`
|
||||
- `GET /profiles/{profile_id}/samples/{sample_id}` or `GET /samples/{sample_id}` as a consciously chosen model
|
||||
- `PUT /profiles/{profile_id}/samples/{sample_id}` -> alias for current sample update route
|
||||
- `DELETE /profiles/{profile_id}/samples/{sample_id}` -> alias for current sample delete route
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Create new handler entry points that call the existing service functions.
|
||||
2. Keep old handlers in place, but mark them deprecated in OpenAPI.
|
||||
3. Add `summary` and `description` text clarifying preferred routes.
|
||||
4. Update frontend and docs examples to use new endpoints first.
|
||||
5. Add tests proving both old and new paths return equivalent responses.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- Old paths remain functional for at least one stable release cycle.
|
||||
- New docs and client examples use `v2-style` resource routes immediately.
|
||||
- Include deprecation headers where feasible, for example:
|
||||
- `Deprecation: true`
|
||||
- `Sunset: <date>`
|
||||
- `Link: <new-doc-url>; rel="successor-version"`
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 1-2 weeks depending on test coverage and frontend updates.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- All major generation workflows are accessible through resource-oriented routes.
|
||||
- Old routes still work unchanged.
|
||||
|
||||
## Phase 5: Migrate First-Party Clients and Publish Deprecations
|
||||
|
||||
Priority: Medium
|
||||
Outcome: Voicebox itself stops depending on legacy paths.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- The desktop app and docs may continue to reinforce old route shapes.
|
||||
- Third-party consumers need a visible migration path.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Update `app/src/lib/api/client.ts` to use the new preferred endpoints.
|
||||
2. Regenerate or refresh any generated API clients.
|
||||
3. Update docs examples, tutorials, and code snippets to use preferred routes only.
|
||||
4. Add a changelog entry describing the migration path.
|
||||
5. Add runtime deprecation logging for legacy route usage in development mode.
|
||||
6. If feasible, expose a small `/health` or `/meta` field showing API version and deprecation window.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- Keep old endpoints available but clearly documented as legacy.
|
||||
- Publish a mapping table from old route to new route.
|
||||
- Do not change request or response payloads during the same phase unless necessary.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- About 1 week including docs and app verification.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- First-party app no longer depends on legacy route names.
|
||||
- Docs do not advertise deprecated paths as the primary interface.
|
||||
|
||||
## Phase 6: Cleanup, Namespace Hardening, and Optional Breaking Changes
|
||||
|
||||
Priority: Medium
|
||||
Outcome: The API surface is cleaner and safer for remote or Docker use.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Internal/admin endpoints are mixed into the public API.
|
||||
- Legacy aliases increase maintenance cost forever if never retired.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Move operational endpoints under `/admin` or `/internal`.
|
||||
- `/shutdown`
|
||||
- `/watchdog/disable`
|
||||
- `/tasks/clear`
|
||||
- `/cache/clear`
|
||||
2. Gate these endpoints behind configuration for non-local deployments.
|
||||
- Example: `VOICEBOX_ENABLE_ADMIN_API=true`
|
||||
3. Decide whether to remove or keep legacy aliases.
|
||||
- If removing, do so only after a published deprecation window.
|
||||
4. Remove deprecated docs pages and old examples.
|
||||
5. Tighten route-level tests to prevent accidental reintroduction of legacy patterns.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- For desktop-only local use, aliases may remain indefinitely if removal cost outweighs benefit.
|
||||
- For published remote API guidance, hide admin endpoints from default docs even if they still exist.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 1-2 releases after the additive migration is complete.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Public docs expose a coherent resource API.
|
||||
- Operational endpoints are clearly separate or disabled in remote contexts.
|
||||
|
||||
## Cross-Cutting Work Items
|
||||
|
||||
These should happen throughout the migration, not only in a single phase.
|
||||
|
||||
### Testing
|
||||
|
||||
- Add route equivalence tests for old and new paths.
|
||||
- Add schema snapshot tests for OpenAPI generation.
|
||||
- Add response-shape tests for common mutations and async workflows.
|
||||
- Add contract tests for `202 Accepted` flows.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Maintain an old-to-new endpoint mapping table.
|
||||
- Add per-endpoint examples for create profile, generate, apply effects, transcribe, and stories operations.
|
||||
- Explicitly document which endpoints are app-facing vs admin-facing.
|
||||
|
||||
### Observability
|
||||
|
||||
- Add warning logs when deprecated endpoints are used.
|
||||
- Track usage counts in development or optional telemetry-free local logs.
|
||||
|
||||
### Release Management
|
||||
|
||||
- Mention API changes in `CHANGELOG.md`.
|
||||
- Ensure docs and app updates ship in the same release as new preferred routes.
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
If engineering time is limited, implement in this exact order:
|
||||
|
||||
1. Fix OpenAPI and docs drift.
|
||||
2. Standardize response models and accepted-task responses.
|
||||
3. Add router prefixes and tags internally.
|
||||
4. Add `/generations` aliases and sample path aliases.
|
||||
5. Migrate the first-party app to preferred routes.
|
||||
6. Deprecate or hide legacy/admin routes.
|
||||
|
||||
## Old-to-New Route Mapping
|
||||
|
||||
| Current Route | Preferred Route |
|
||||
| --- | --- |
|
||||
| `POST /generate` | `POST /generations` |
|
||||
| `POST /generate/stream` | `POST /generations/stream` |
|
||||
| `POST /generate/{id}/retry` | `POST /generations/{id}/retry` |
|
||||
| `POST /generate/{id}/regenerate` | `POST /generations/{id}/regenerate` |
|
||||
| `GET /generate/{id}/status` | `GET /generations/{id}/status` |
|
||||
| `GET /history` | `GET /generations` |
|
||||
| `GET /history/{id}` | `GET /generations/{id}` |
|
||||
| `GET /audio/{id}` | `GET /generations/{id}/audio` |
|
||||
| `GET /history/{id}/export` | `GET /generations/{id}/export` |
|
||||
| `GET /history/{id}/export-audio` | `GET /generations/{id}/export-audio` |
|
||||
| `PUT /profiles/samples/{sample_id}` | `PUT /profiles/{profile_id}/samples/{sample_id}` |
|
||||
| `DELETE /profiles/samples/{sample_id}` | `DELETE /profiles/{profile_id}/samples/{sample_id}` |
|
||||
| `POST /tasks/clear` | `POST /admin/tasks/clear` |
|
||||
| `POST /cache/clear` | `POST /admin/cache/clear` |
|
||||
| `POST /shutdown` | `POST /admin/shutdown` |
|
||||
| `POST /watchdog/disable` | `POST /admin/watchdog/disable` |
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Risk: App regressions during endpoint migration
|
||||
|
||||
- Mitigation: Add new routes before changing client usage.
|
||||
- Mitigation: Keep payloads identical while paths change.
|
||||
|
||||
### Risk: Docs still drift after cleanup
|
||||
|
||||
- Mitigation: Add CI enforcement and a release checklist step.
|
||||
|
||||
### Risk: Third-party local scripts break on removal
|
||||
|
||||
- Mitigation: Prefer indefinite aliases for one-person local workflows unless maintenance becomes painful.
|
||||
|
||||
### Risk: Admin endpoints remain dangerous in remote mode
|
||||
|
||||
- Mitigation: Hide and gate them before promoting remote deployment more broadly.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
The refactor can be considered complete when all of the following are true:
|
||||
|
||||
- OpenAPI, checked-in docs, and backend models match.
|
||||
- The preferred public API is resource-oriented and documented consistently.
|
||||
- The Voicebox app uses preferred routes exclusively.
|
||||
- Legacy routes are either deprecated with a timeline or intentionally retained as compatibility aliases.
|
||||
- Operational endpoints are clearly separated from the public app API.
|
||||
Reference in New Issue
Block a user