mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -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 type { UseFormReturn } from 'react-hook-form';
|
||||||
import { FormControl } from '@/components/ui/form';
|
import { FormControl } from '@/components/ui/form';
|
||||||
import {
|
import {
|
||||||
@@ -41,12 +42,9 @@ const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
|
|||||||
/** Engines that support cloned (reference audio) profiles. */
|
/** Engines that support cloned (reference audio) profiles. */
|
||||||
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
|
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
|
||||||
|
|
||||||
/**
|
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) {
|
||||||
* All engine options are always available. The profile grid already
|
if (!selectedProfile) return ENGINE_OPTIONS;
|
||||||
* filters by engine, so the dropdown doesn't need to restrict options.
|
return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine));
|
||||||
*/
|
|
||||||
function getAvailableOptions(_selectedProfile?: VoiceProfileResponse | null) {
|
|
||||||
return ENGINE_OPTIONS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectValue(engine: string, modelSize?: string): string {
|
function getSelectValue(engine: string, modelSize?: string): string {
|
||||||
@@ -108,12 +106,13 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
|
|||||||
const selectValue = getSelectValue(engine, modelSize);
|
const selectValue = getSelectValue(engine, modelSize);
|
||||||
const availableOptions = getAvailableOptions(selectedProfile);
|
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);
|
const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
|
||||||
if (!currentEngineAvailable && availableOptions.length > 0) {
|
|
||||||
// Defer to avoid setting state during render
|
useEffect(() => {
|
||||||
setTimeout(() => handleEngineChange(form, availableOptions[0].value), 0);
|
if (!currentEngineAvailable && availableOptions.length > 0) {
|
||||||
}
|
handleEngineChange(form, availableOptions[0].value);
|
||||||
|
}
|
||||||
|
}, [availableOptions, currentEngineAvailable, form]);
|
||||||
|
|
||||||
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
|
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
|
||||||
const triggerClass = compact
|
const triggerClass = compact
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
useAddSample,
|
useAddSample,
|
||||||
useCreateProfile,
|
useCreateProfile,
|
||||||
useDeleteAvatar,
|
useDeleteAvatar,
|
||||||
|
useDeleteProfile,
|
||||||
useProfile,
|
useProfile,
|
||||||
useUpdateProfile,
|
useUpdateProfile,
|
||||||
useUploadAvatar,
|
useUploadAvatar,
|
||||||
@@ -59,6 +60,15 @@ import { AudioSampleUpload } from './AudioSampleUpload';
|
|||||||
import { SampleList } from './SampleList';
|
import { SampleList } from './SampleList';
|
||||||
|
|
||||||
const MAX_AUDIO_DURATION_SECONDS = 30;
|
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({
|
const baseProfileSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
@@ -119,6 +129,7 @@ export function ProfileForm() {
|
|||||||
const createProfile = useCreateProfile();
|
const createProfile = useCreateProfile();
|
||||||
const updateProfile = useUpdateProfile();
|
const updateProfile = useUpdateProfile();
|
||||||
const addSample = useAddSample();
|
const addSample = useAddSample();
|
||||||
|
const deleteProfile = useDeleteProfile();
|
||||||
const uploadAvatar = useUploadAvatar();
|
const uploadAvatar = useUploadAvatar();
|
||||||
const deleteAvatar = useDeleteAvatar();
|
const deleteAvatar = useDeleteAvatar();
|
||||||
const transcribe = useTranscription();
|
const transcribe = useTranscription();
|
||||||
@@ -259,6 +270,12 @@ export function ProfileForm() {
|
|||||||
(!isCreating && editingProfile?.voice_type === 'preset')),
|
(!isCreating && editingProfile?.voice_type === 'preset')),
|
||||||
});
|
});
|
||||||
const presetVoices = presetVoicesData?.voices ?? [];
|
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
|
// Show recording errors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -348,6 +365,15 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}, [editingProfile, profileFormDraft, open, form]);
|
}, [editingProfile, profileFormDraft, open, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
defaultEngine &&
|
||||||
|
!availableDefaultEngines.some((option) => option.value === defaultEngine)
|
||||||
|
) {
|
||||||
|
setDefaultEngine('');
|
||||||
|
}
|
||||||
|
}, [availableDefaultEngines, defaultEngine]);
|
||||||
|
|
||||||
async function handleTranscribe() {
|
async function handleTranscribe() {
|
||||||
const file = form.getValues('sampleFile');
|
const file = form.getValues('sampleFile');
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -638,12 +664,32 @@ export function ProfileForm() {
|
|||||||
description: `"${data.name}" has been created with a sample.`,
|
description: `"${data.name}" has been created with a sample.`,
|
||||||
});
|
});
|
||||||
} catch (sampleError) {
|
} 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({
|
toast({
|
||||||
title: 'Failed to add sample',
|
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',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,12 +1186,11 @@ export function ProfileForm() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="_none">No preference</SelectItem>
|
<SelectItem value="_none">No preference</SelectItem>
|
||||||
<SelectItem value="qwen">Qwen3-TTS</SelectItem>
|
{availableDefaultEngines.map((option) => (
|
||||||
<SelectItem value="luxtts">LuxTTS</SelectItem>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<SelectItem value="chatterbox">Chatterbox</SelectItem>
|
{option.label}
|
||||||
<SelectItem value="chatterbox_turbo">Chatterbox Turbo</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tada">TADA</SelectItem>
|
))}
|
||||||
<SelectItem value="kokoro">Kokoro 82M</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function ProfileList() {
|
|||||||
No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet.
|
No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<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>
|
</p>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<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.
|
Idempotent: absolute paths are left untouched.
|
||||||
|
|
||||||
Strategy: paths like "data/generations/abc.wav" are rebased onto the
|
Strategy: paths like "data/generations/abc.wav" are rebased onto the
|
||||||
configured data directory. If the path starts with "data/", strip that
|
configured data directory. If the path starts with "data/", strip that
|
||||||
prefix and prepend get_data_dir(). Otherwise, try resolving relative to
|
prefix and prepend get_data_dir(). Otherwise, join the relative path
|
||||||
CWD as a fallback.
|
directly under get_data_dir().
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..config import get_data_dir
|
from ..config import get_data_dir
|
||||||
|
|
||||||
data_dir = get_data_dir()
|
data_dir = get_data_dir()
|
||||||
@@ -229,11 +230,7 @@ def _resolve_relative_paths(engine, tables: set[str]) -> None:
|
|||||||
else:
|
else:
|
||||||
rebased = data_dir / p
|
rebased = data_dir / p
|
||||||
|
|
||||||
if rebased.exists():
|
resolved = rebased.resolve()
|
||||||
resolved = rebased
|
|
||||||
else:
|
|
||||||
# Fallback: resolve relative to CWD
|
|
||||||
resolved = p.resolve()
|
|
||||||
|
|
||||||
if resolved.exists():
|
if resolved.exists():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import config, models
|
from .. import models
|
||||||
from ..app import safe_content_disposition
|
from ..app import safe_content_disposition
|
||||||
from ..database import VoiceProfile as DBVoiceProfile, get_db
|
from ..database import VoiceProfile as DBVoiceProfile, get_db
|
||||||
from ..services import channels, export_import, profiles
|
from ..services import channels, export_import, profiles
|
||||||
@@ -131,13 +130,15 @@ async def seed_preset_profiles_route(
|
|||||||
if existing:
|
if existing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip name collisions
|
unique_name = profile_name
|
||||||
if db.query(DBVoiceProfile).filter_by(name=profile_name).first():
|
suffix = 2
|
||||||
continue
|
while db.query(DBVoiceProfile).filter_by(name=unique_name).first():
|
||||||
|
unique_name = f"{profile_name} {suffix}"
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
profile = DBVoiceProfile(
|
profile = DBVoiceProfile(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=profile_name,
|
name=unique_name,
|
||||||
description=f"Kokoro preset voice — {display_name} ({gender})",
|
description=f"Kokoro preset voice — {display_name} ({gender})",
|
||||||
language=lang,
|
language=lang,
|
||||||
voice_type="preset",
|
voice_type="preset",
|
||||||
@@ -394,8 +395,6 @@ async def update_profile_effects(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Set or clear the default effects chain for a voice profile."""
|
"""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()
|
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(status_code=404, detail="Profile not found")
|
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
|
import logging
|
||||||
from typing import List, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
import uuid
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from ..models import (
|
CLONING_ENGINES = {"qwen", "luxtts", "chatterbox", "chatterbox_turbo", "tada"}
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_response(
|
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(
|
async def create_profile(
|
||||||
data: VoiceProfileCreate,
|
data: VoiceProfileCreate,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -94,6 +119,16 @@ async def create_profile(
|
|||||||
if voice_type == "preset" and data.preset_engine and not default_engine:
|
if voice_type == "preset" and data.preset_engine and not default_engine:
|
||||||
default_engine = data.preset_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(
|
db_profile = DBVoiceProfile(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -180,7 +215,7 @@ async def add_profile_sample(
|
|||||||
async def get_profile(
|
async def get_profile(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Optional[VoiceProfileResponse]:
|
) -> VoiceProfileResponse | None:
|
||||||
"""
|
"""
|
||||||
Get a voice profile by ID.
|
Get a voice profile by ID.
|
||||||
|
|
||||||
@@ -201,7 +236,7 @@ async def get_profile(
|
|||||||
async def get_profile_samples(
|
async def get_profile_samples(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> List[ProfileSampleResponse]:
|
) -> list[ProfileSampleResponse]:
|
||||||
"""
|
"""
|
||||||
Get all samples for a profile.
|
Get all samples for a profile.
|
||||||
|
|
||||||
@@ -216,7 +251,7 @@ async def get_profile_samples(
|
|||||||
return [ProfileSampleResponse.model_validate(s) for s in 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.
|
List all voice profiles with generation and sample counts.
|
||||||
|
|
||||||
@@ -257,7 +292,7 @@ async def update_profile(
|
|||||||
profile_id: str,
|
profile_id: str,
|
||||||
data: VoiceProfileCreate,
|
data: VoiceProfileCreate,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Optional[VoiceProfileResponse]:
|
) -> VoiceProfileResponse | None:
|
||||||
"""
|
"""
|
||||||
Update a voice profile.
|
Update a voice profile.
|
||||||
|
|
||||||
@@ -281,6 +316,22 @@ async def update_profile(
|
|||||||
if existing_profile:
|
if existing_profile:
|
||||||
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
|
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.name = data.name
|
||||||
profile.description = data.description
|
profile.description = data.description
|
||||||
profile.language = data.language
|
profile.language = data.language
|
||||||
@@ -366,7 +417,7 @@ async def update_profile_sample(
|
|||||||
sample_id: str,
|
sample_id: str,
|
||||||
reference_text: str,
|
reference_text: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Optional[ProfileSampleResponse]:
|
) -> ProfileSampleResponse | None:
|
||||||
"""
|
"""
|
||||||
Update a profile sample's reference text.
|
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 ──
|
# ── Preset profiles: return engine-specific voice reference ──
|
||||||
if voice_type == "preset":
|
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 {
|
return {
|
||||||
"voice_type": "preset",
|
"voice_type": "preset",
|
||||||
"preset_engine": profile.preset_engine,
|
"preset_engine": profile.preset_engine,
|
||||||
@@ -436,11 +493,16 @@ async def create_voice_prompt_for_profile(
|
|||||||
|
|
||||||
# ── Designed profiles: return text description (future) ──
|
# ── Designed profiles: return text description (future) ──
|
||||||
if voice_type == "designed":
|
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 {
|
return {
|
||||||
"voice_type": "designed",
|
"voice_type": "designed",
|
||||||
"design_prompt": profile.design_prompt,
|
"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 ──
|
# ── Cloned profiles: create from audio samples ──
|
||||||
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
|
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,
|
use_cache=use_cache,
|
||||||
)
|
)
|
||||||
return voice_prompt
|
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 = [s.audio_path for s in samples]
|
||||||
audio_paths,
|
reference_texts = [s.reference_text for s in samples]
|
||||||
reference_texts,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save combined audio to cache directory (persistent)
|
combined_audio, combined_text = await tts_model.combine_voice_prompts(
|
||||||
# Create a hash of sample IDs to identify this specific combination
|
audio_paths,
|
||||||
import hashlib
|
reference_texts,
|
||||||
|
)
|
||||||
|
|
||||||
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
# Save combined audio to cache directory (persistent)
|
||||||
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
|
# Create a hash of sample IDs to identify this specific combination
|
||||||
|
import hashlib
|
||||||
|
|
||||||
cache_dir = _get_cache_dir()
|
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
|
||||||
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
|
|
||||||
|
|
||||||
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(
|
save_audio(combined_audio, str(combined_path), 24000)
|
||||||
str(combined_path),
|
|
||||||
combined_text,
|
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||||
use_cache=use_cache,
|
str(combined_path),
|
||||||
)
|
combined_text,
|
||||||
return voice_prompt
|
use_cache=use_cache,
|
||||||
|
)
|
||||||
|
return voice_prompt
|
||||||
|
|
||||||
|
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
@@ -571,6 +633,3 @@ async def delete_avatar(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return True
|
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