Compare commits

...
Author SHA1 Message Date
James PineandClaude Opus 4.6 a10024fbd8 fix: clean up scroll effect timers and fix disabled+selected card toggle
- Add cleanup for requestAnimationFrame and setTimeout in scroll effect
  to prevent stale DOM writes on unmount or rapid selection changes
- Fix disabled+selected card click: bounce the selection to re-trigger
  the engine auto-switch instead of deselecting

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 21:20:05 -07:00
James PineandClaude Opus 4.6 7ebf57d8f4 feat: gray out unsupported profiles instead of filtering, auto-switch engine on selection
- Show all voice profiles with unsupported ones grayed out (opacity) instead of hidden
- Clicking a grayed-out profile selects it and auto-switches the engine to a compatible one
- Sort supported profiles first, with info tip about compatibility at the bottom
- Scroll to selected profile after engine/sort changes with safe margin
- Fix engine desync on tab navigation by initializing form engine from store

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 20:22:11 -07:00
James Pine b108bb1cb1 fix: store media paths relative to data dir 2026-03-20 15:06:07 -07:00
James Pine e6f419cd70 fix: show all engines in floating generator 2026-03-19 19:52:08 -07:00
James Pine 72c13fd3fc fix: enforce preset profile engine compatibility 2026-03-19 19:51:53 -07:00
James Pine 4e0c731db8 feat: add Qwen CustomVoice preset engine 2026-03-19 19:48:50 -07:00
James Pine d70b878b71 fix: tighten kokoro profile handling 2026-03-19 19:32:49 -07:00
Jamie PineandGitHub a71011741d Merge pull request #325 from jamiepine/feat/kokoro-engine
feat: Kokoro 82M TTS engine + voice profile type system
2026-03-19 19:21:15 -07:00
Jamie PineandGitHub e4def9365f Merge pull request #321 from liorshahverdi/fix/delete-failed-generations-292
fix/allows deletion of failed generations 292
2026-03-19 09:36:55 -07:00
Lior Shahverdi 12ed2d51ce Adds a trash icon button alongside the existing retry button for
failed generations, giving users a way to clean up failed entries
  without having to retry them first.
2026-03-18 15:44:37 -04:00
33 changed files with 1271 additions and 326 deletions
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { FormControl } from '@/components/ui/form';
import {
@@ -18,6 +19,8 @@ import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
const ENGINE_OPTIONS = [
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B', engine: 'qwen' },
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B', engine: 'qwen' },
{ value: 'qwen_custom_voice:1.7B', label: 'Qwen CustomVoice 1.7B', engine: 'qwen_custom_voice' },
{ value: 'qwen_custom_voice:0.6B', label: 'Qwen CustomVoice 0.6B', engine: 'qwen_custom_voice' },
{ value: 'luxtts', label: 'LuxTTS', engine: 'luxtts' },
{ value: 'chatterbox', label: 'Chatterbox', engine: 'chatterbox' },
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo', engine: 'chatterbox_turbo' },
@@ -28,6 +31,7 @@ const ENGINE_OPTIONS = [
const ENGINE_DESCRIPTIONS: Record<string, string> = {
qwen: 'Multi-language, two sizes',
qwen_custom_voice: '9 preset voices, instruct control',
luxtts: 'Fast, English-focused',
chatterbox: '23 languages, incl. Hebrew',
chatterbox_turbo: 'English, [laugh] [cough] tags',
@@ -41,22 +45,29 @@ 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 {
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
if (engine === 'qwen_custom_voice') return `qwen_custom_voice:${modelSize || '1.7B'}`;
if (engine === 'tada') return `tada:${modelSize || '1B'}`;
return engine;
}
function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: string) {
if (value.startsWith('qwen:')) {
export function applyEngineSelection(form: UseFormReturn<GenerationFormValues>, value: string) {
if (value.startsWith('qwen_custom_voice:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen_custom_voice');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('qwen_custom_voice');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
} else if (value.startsWith('qwen:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
@@ -108,12 +119,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) {
applyEngineSelection(form, availableOptions[0].value);
}
}, [availableOptions, currentEngineAvailable, form]);
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
const triggerClass = compact
@@ -121,7 +133,7 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
: undefined;
return (
<Select value={selectValue} onValueChange={(v) => handleEngineChange(form, v)}>
<Select value={selectValue} onValueChange={(v) => applyEngineSelection(form, v)}>
<FormControl>
<SelectTrigger className={triggerClass}>
<SelectValue />
@@ -125,22 +125,22 @@ export function FloatingGenerateBox({
}, [watchedEngine, setSelectedEngine]);
// Sync generation form language, engine, and effects with selected profile
type EngineValue = 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro' | 'qwen_custom_voice';
useEffect(() => {
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
// Auto-switch engine if profile has a default
if (selectedProfile?.default_engine) {
form.setValue(
'engine',
selectedProfile.default_engine as
| 'qwen'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro',
);
// Auto-switch engine to match the profile
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
if (engine) {
form.setValue('engine', engine as EngineValue);
} else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
// Cloned/designed profile with no default — ensure a compatible (non-preset) engine
const currentEngine = form.getValues('engine');
const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
if (presetEngines.has(currentEngine)) {
form.setValue('engine', 'qwen');
}
}
// Pre-fill effects from profile defaults
if (
@@ -408,7 +408,7 @@ export function FloatingGenerateBox({
/>
<FormItem className="flex-1 space-y-0">
<EngineModelSelector form={form} compact selectedProfile={selectedProfile} />
<EngineModelSelector form={form} compact />
</FormItem>
<FormItem className="flex-1 space-y-0">
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Loader2, Mic } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -19,19 +20,41 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import { EngineModelSelector, getEngineDescription } from './EngineModelSelector';
import { EngineModelSelector, applyEngineSelection, getEngineDescription } from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
function getEngineSelectValue(engine: string): string {
if (engine === 'qwen') return 'qwen:1.7B';
if (engine === 'qwen_custom_voice') return 'qwen_custom_voice:1.7B';
if (engine === 'tada') return 'tada:1B';
return engine;
}
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { form, handleSubmit, isPending } = useGenerationForm();
useEffect(() => {
if (!selectedProfile) {
return;
}
if (selectedProfile.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
const preferredEngine = selectedProfile.default_engine || selectedProfile.preset_engine;
if (preferredEngine) {
applyEngineSelection(form, getEngineSelectValue(preferredEngine));
}
}, [form, selectedProfile]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
@@ -91,7 +114,7 @@ export function GenerationForm() {
)}
/>
{form.watch('engine') === 'qwen' && (
{(form.watch('engine') === 'qwen' || form.watch('engine') === 'qwen_custom_voice') && (
<FormField
control={form.control}
name="instruct"
+21 -9
View File
@@ -569,15 +569,27 @@ export function HistoryTable() {
)}
{isFailed ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
>
<RotateCcw className="h-2 w-2" />
</Button>
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
>
<RotateCcw className="h-2 w-2" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Delete generation"
disabled={deleteGeneration.isPending}
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
>
<Trash2 className="h-2 w-2" />
</Button>
</>
) : (
<>
<DropdownMenu>
@@ -68,6 +68,10 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
'HumeAI TADA 3B Multilingual — built on Llama 3.2 3B. Supports 10 languages with high-fidelity voice cloning via text-acoustic dual alignment.',
kokoro:
'Kokoro 82M by hexgrad. Tiny 82M-parameter TTS that runs at CPU realtime. Supports 8 languages with pre-built voice styles. Apache 2.0 licensed.',
'qwen-custom-voice-1.7B':
'Qwen3-TTS CustomVoice 1.7B by Alibaba. 9 premium preset voices with instruct-based style control for tone, emotion, and prosody. Supports 10 languages.',
'qwen-custom-voice-0.6B':
'Qwen3-TTS CustomVoice 0.6B by Alibaba. Lightweight version with the same 9 preset voices and instruct control. Faster inference for lower-end hardware.',
'whisper-base':
'Smallest Whisper model (74M parameters). Fast transcription with moderate accuracy.',
'whisper-small':
@@ -396,6 +400,7 @@ export function ModelManagement() {
modelStatus?.models.filter(
(m) =>
m.model_name.startsWith('qwen-tts') ||
m.model_name.startsWith('qwen-custom-voice') ||
m.model_name.startsWith('luxtts') ||
m.model_name.startsWith('chatterbox') ||
m.model_name.startsWith('tada') ||
@@ -17,11 +17,18 @@ import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { useUIStore } from '@/stores/uiStore';
/** Human-readable display names for preset engine badges. */
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
kokoro: 'Kokoro',
qwen_custom_voice: 'CustomVoice',
};
interface ProfileCardProps {
profile: VoiceProfileResponse;
disabled?: boolean;
}
export function ProfileCard({ profile }: ProfileCardProps) {
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
@@ -34,6 +41,12 @@ export function ProfileCard({ profile }: ProfileCardProps) {
const isSelected = selectedProfileId === profile.id;
const handleSelect = () => {
// If disabled but already selected, bounce the selection to re-trigger engine auto-switch
if (disabled && isSelected) {
setSelectedProfileId(null);
setTimeout(() => setSelectedProfileId(profile.id), 0);
return;
}
setSelectedProfileId(isSelected ? null : profile.id);
};
@@ -74,8 +87,9 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<>
<Card
className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
isSelected && 'ring-2 ring-accent shadow-md',
'cursor-pointer transition-all flex flex-col h-[162px]',
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
@@ -99,7 +113,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
</Badge>
{profile.voice_type === 'preset' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{profile.preset_engine}
{ENGINE_DISPLAY_NAMES[profile.preset_engine ?? ''] ?? profile.preset_engine}
</Badge>
)}
{profile.voice_type === 'designed' && (
@@ -43,6 +43,7 @@ import {
useAddSample,
useCreateProfile,
useDeleteAvatar,
useDeleteProfile,
useProfile,
useUpdateProfile,
useUploadAvatar,
@@ -59,6 +60,16 @@ import { AudioSampleUpload } from './AudioSampleUpload';
import { SampleList } from './SampleList';
const MAX_AUDIO_DURATION_SECONDS = 30;
const PRESET_ONLY_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
const DEFAULT_ENGINE_OPTIONS = [
{ value: 'qwen', label: 'Qwen3-TTS' },
{ value: 'qwen_custom_voice', label: 'Qwen CustomVoice' },
{ 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 +130,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 +271,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 +366,24 @@ export function ProfileForm() {
}
}, [editingProfile, profileFormDraft, open, form]);
useEffect(() => {
if (
defaultEngine &&
!availableDefaultEngines.some((option) => option.value === defaultEngine)
) {
setDefaultEngine('');
}
}, [availableDefaultEngines, defaultEngine]);
useEffect(() => {
if (!selectedPresetVoiceId) {
return;
}
if (!presetVoices.some((voice: PresetVoice) => voice.voice_id === selectedPresetVoiceId)) {
setSelectedPresetVoiceId('');
}
}, [presetVoices, selectedPresetVoiceId]);
async function handleTranscribe() {
const file = form.getValues('sampleFile');
if (!file) {
@@ -638,12 +674,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;
}
}
@@ -803,6 +859,7 @@ export function ProfileForm() {
</FormControl>
<SelectContent>
<SelectItem value="kokoro">Kokoro 82M</SelectItem>
<SelectItem value="qwen_custom_voice">Qwen CustomVoice</SelectItem>
</SelectContent>
</Select>
</FormItem>
@@ -1140,12 +1197,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">
@@ -1,4 +1,5 @@
import { Mic, Music, Sparkles } from 'lucide-react';
import { Info, Mic, Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useProfiles } from '@/lib/hooks/useProfiles';
@@ -7,17 +8,33 @@ import { ProfileCard } from './ProfileCard';
import { ProfileForm } from './ProfileForm';
/** Engines that use preset (built-in) voices instead of cloned profiles. */
const PRESET_ENGINES = new Set(['kokoro']);
/** Human-readable engine names for empty state messages. */
const ENGINE_NAMES: Record<string, string> = {
kokoro: 'Kokoro',
};
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
export function ProfileList() {
const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Scroll to the selected profile after engine/sort changes
useEffect(() => {
if (!selectedProfileId) return;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const rafId = requestAnimationFrame(() => {
const el = cardRefs.current.get(selectedProfileId);
if (!el) return;
// Temporarily apply scroll-margin so it doesn't land flush at the top
el.style.scrollMarginTop = '180px';
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
timeoutId = setTimeout(() => { el.style.scrollMarginTop = ''; }, 500);
});
return () => {
cancelAnimationFrame(rafId);
if (timeoutId) clearTimeout(timeoutId);
};
}, [selectedProfileId, selectedEngine]);
if (isLoading) {
return null;
@@ -34,10 +51,18 @@ export function ProfileList() {
const allProfiles = profiles || [];
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
// Filter profiles based on selected engine
const filteredProfiles = isPresetEngine
? allProfiles.filter((p) => p.voice_type === 'preset' && p.preset_engine === selectedEngine)
: allProfiles.filter((p) => p.voice_type !== 'preset');
/** Whether a profile is supported by the currently selected engine. */
const isSupported = (p: (typeof allProfiles)[number]) =>
isPresetEngine
? p.voice_type === 'preset' && p.preset_engine === selectedEngine
: p.voice_type !== 'preset';
// Sort so supported profiles come first
const sortedProfiles = [...allProfiles].sort(
(a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1),
);
const hasUnsupported = sortedProfiles.some((p) => !isSupported(p));
return (
<div className="flex flex-col">
@@ -55,29 +80,26 @@ export function ProfileList() {
</Button>
</CardContent>
</Card>
) : filteredProfiles.length === 0 && isPresetEngine ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Music className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-2">
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.
</p>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Create {ENGINE_NAMES[selectedEngine] ?? selectedEngine} Voice
</Button>
</CardContent>
</Card>
) : (
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
{filteredProfiles.map((profile) => (
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
<ProfileCard profile={profile} />
{sortedProfiles.map((profile) => (
<div
key={profile.id}
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
ref={(el) => {
if (el) cardRefs.current.set(profile.id, el);
else cardRefs.current.delete(profile.id);
}}
>
<ProfileCard profile={profile} disabled={!isSupported(profile)} />
</div>
))}
{hasUnsupported && (
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
<Info className="h-3.5 w-3.5 shrink-0" />
<span>Only supported voice profiles can be selected for the current model.</span>
</div>
)}
</div>
)}
</div>
-6
View File
@@ -102,12 +102,6 @@ class ApiClient {
return this.request<{ engine: string; voices: PresetVoice[] }>(`/profiles/presets/${engine}`);
}
async seedPresetProfiles(
engine: string,
): Promise<{ engine: string; created: number; total_available: number }> {
return this.request(`/profiles/presets/${engine}/seed`, { method: 'POST' });
}
async updateProfile(profileId: string, data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
method: 'PUT',
+8 -1
View File
@@ -62,7 +62,14 @@ export interface GenerationRequest {
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B' | '1B' | '3B';
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro';
engine?:
| 'qwen'
| 'qwen_custom_voice'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro';
instruct?: string;
max_chunk_chars?: number;
crossfade_ms?: number;
+1
View File
@@ -69,6 +69,7 @@ export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
chatterbox_turbo: ['en'],
tada: ['en', 'ar', 'zh', 'de', 'es', 'fr', 'it', 'ja', 'pl', 'pt'],
kokoro: ['en', 'es', 'fr', 'hi', 'it', 'pt', 'ja', 'zh'],
qwen_custom_voice: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
} as const;
/** Helper: get language options for a given engine. */
+28 -8
View File
@@ -10,6 +10,7 @@ import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
text: z.string().min(1, '').max(50000),
@@ -17,7 +18,17 @@ const generationSchema = z.object({
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B', '1B', '3B']).optional(),
instruct: z.string().max(500).optional(),
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada', 'kokoro']).optional(),
engine: z
.enum([
'qwen',
'qwen_custom_voice',
'luxtts',
'chatterbox',
'chatterbox_turbo',
'tada',
'kokoro',
])
.optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -35,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -52,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
engine: 'qwen',
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
...options.defaultValues,
},
});
@@ -85,7 +97,9 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
: 'tada-1b'
: engine === 'kokoro'
? 'kokoro'
: `qwen-tts-${data.modelSize}`;
: engine === 'qwen_custom_voice'
? `qwen-custom-voice-${data.modelSize}`
: `qwen-tts-${data.modelSize}`;
const displayName =
engine === 'luxtts'
? 'LuxTTS'
@@ -99,9 +113,13 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
: 'TADA 1B'
: engine === 'kokoro'
? 'Kokoro 82M'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
: engine === 'qwen_custom_voice'
? data.modelSize === '1.7B'
? 'Qwen CustomVoice 1.7B'
: 'Qwen CustomVoice 0.6B'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
// Check if model needs downloading
try {
@@ -116,7 +134,9 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
console.error('Failed to check model status:', error);
}
const hasModelSizes = engine === 'qwen' || engine === 'tada';
const hasModelSizes =
engine === 'qwen' || engine === 'qwen_custom_voice' || engine === 'tada';
const supportsInstruct = engine === 'qwen' || engine === 'qwen_custom_voice';
const effectsChain = options.getEffectsChain?.();
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({
@@ -126,7 +146,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: data.seed,
model_size: hasModelSizes ? data.modelSize : undefined,
engine,
instruct: engine === 'qwen' ? data.instruct || undefined : undefined,
instruct: supportsInstruct ? data.instruct || undefined : undefined,
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
+51 -4
View File
@@ -163,6 +163,7 @@ _stt_backend: Optional[STTBackend] = None
# The factory function uses this for the if/elif chain; the model configs live on the backend classes.
TTS_ENGINES = {
"qwen": "Qwen TTS",
"qwen_custom_voice": "Qwen CustomVoice",
"luxtts": "LuxTTS",
"chatterbox": "Chatterbox TTS",
"chatterbox_turbo": "Chatterbox Turbo",
@@ -205,6 +206,32 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
]
def _get_qwen_custom_voice_configs() -> list[ModelConfig]:
"""Return Qwen CustomVoice model configs."""
return [
ModelConfig(
model_name="qwen-custom-voice-1.7B",
display_name="Qwen CustomVoice 1.7B",
engine="qwen_custom_voice",
hf_repo_id="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
model_size="1.7B",
size_mb=3500,
supports_instruct=True,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
ModelConfig(
model_name="qwen-custom-voice-0.6B",
display_name="Qwen CustomVoice 0.6B",
engine="qwen_custom_voice",
hf_repo_id="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
model_size="0.6B",
size_mb=1200,
supports_instruct=True,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
]
def _get_non_qwen_tts_configs() -> list[ModelConfig]:
"""Return model configs for non-Qwen TTS engines.
@@ -333,12 +360,12 @@ def _get_whisper_configs() -> list[ModelConfig]:
def get_all_model_configs() -> list[ModelConfig]:
"""Return the full list of model configs (TTS + STT)."""
return _get_qwen_model_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
def get_tts_model_configs() -> list[ModelConfig]:
"""Return only TTS model configs."""
return _get_qwen_model_configs() + _get_non_qwen_tts_configs()
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs()
# Lookup helpers — these replace the if/elif chains in main.py
@@ -369,7 +396,7 @@ def engine_has_model_sizes(engine: str) -> bool:
async def load_engine_model(engine: str, model_size: str = "default") -> None:
"""Load a model for the given engine, handling engines with multiple model sizes."""
backend = get_tts_backend_for_engine(engine)
if engine == "qwen":
if engine in ("qwen", "qwen_custom_voice"):
await backend.load_model_async(model_size)
elif engine == "tada":
await backend.load_model(model_size)
@@ -388,7 +415,7 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
cfg = c
break
if engine in ("qwen", "tada"):
if engine in ("qwen", "qwen_custom_voice", "tada"):
if not backend._is_model_cached(model_size):
raise HTTPException(
status_code=400,
@@ -423,6 +450,14 @@ def unload_model_by_config(config: ModelConfig) -> bool:
return True
return False
if config.engine == "qwen_custom_voice":
backend = get_tts_backend_for_engine(config.engine)
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
if backend.is_loaded() and loaded_size == config.model_size:
backend.unload_model()
return True
return False
# All other TTS engines
backend = get_tts_backend_for_engine(config.engine)
if backend.is_loaded():
@@ -446,6 +481,11 @@ def check_model_loaded(config: ModelConfig) -> bool:
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
return tts_model.is_loaded() and loaded_size == config.model_size
if config.engine == "qwen_custom_voice":
backend = get_tts_backend_for_engine(config.engine)
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
return backend.is_loaded() and loaded_size == config.model_size
backend = get_tts_backend_for_engine(config.engine)
return backend.is_loaded()
except Exception:
@@ -463,6 +503,9 @@ def get_model_load_func(config: ModelConfig):
if config.engine == "qwen":
return lambda: tts.get_tts_model().load_model(config.model_size)
if config.engine == "qwen_custom_voice":
return lambda: get_tts_backend_for_engine(config.engine).load_model(config.model_size)
return lambda: get_tts_backend_for_engine(config.engine).load_model()
@@ -528,6 +571,10 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
from .kokoro_backend import KokoroTTSBackend
backend = KokoroTTSBackend()
elif engine == "qwen_custom_voice":
from .qwen_custom_voice_backend import QwenCustomVoiceBackend
backend = QwenCustomVoiceBackend()
else:
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
@@ -0,0 +1,210 @@
"""
Qwen3-TTS CustomVoice backend implementation.
Wraps the Qwen3-TTS-12Hz CustomVoice model for preset-speaker TTS with
instruction-based style control. Uses the same qwen_tts library as the
Base model (pytorch_backend.py) but loads a different checkpoint and
calls generate_custom_voice() instead of generate_voice_clone().
Key differences from the Base engine:
- Uses preset speakers (9 built-in voices) instead of zero-shot cloning
- Supports instruct parameter for tone/emotion/prosody control
- Two model sizes: 1.7B and 0.6B
Languages supported: zh, en, ja, ko, de, fr, ru, pt, es, it
"""
import asyncio
import logging
from typing import Optional
import numpy as np
import torch
from . import TTSBackend, LANGUAGE_CODE_TO_NAME
from .base import (
is_model_cached,
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
logger = logging.getLogger(__name__)
# ── Preset speakers ──────────────────────────────────────────────────
# (speaker_id, display_name, gender, native_language_code, description)
QWEN_CUSTOM_VOICES = [
("Vivian", "Vivian", "female", "zh", "Bright, slightly edgy young female voice"),
("Serena", "Serena", "female", "zh", "Warm, gentle young female voice"),
("Uncle_Fu", "Uncle Fu", "male", "zh", "Seasoned male voice with a low, mellow timbre"),
("Dylan", "Dylan", "male", "zh", "Youthful Beijing male voice with a clear, natural timbre"),
("Eric", "Eric", "male", "zh", "Lively Chengdu male voice with a slightly husky brightness"),
("Ryan", "Ryan", "male", "en", "Dynamic male voice with strong rhythmic drive"),
("Aiden", "Aiden", "male", "en", "Sunny American male voice with a clear midrange"),
("Ono_Anna", "Ono Anna", "female", "ja", "Playful Japanese female voice with a light, nimble timbre"),
("Sohee", "Sohee", "female", "ko", "Warm Korean female voice with rich emotion"),
]
QWEN_CV_DEFAULT_SPEAKER = "Ryan"
# HuggingFace repo IDs per model size
QWEN_CV_HF_REPOS = {
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
}
class QwenCustomVoiceBackend:
"""Qwen3-TTS CustomVoice backend — preset speakers with instruct control."""
def __init__(self, model_size: str = "1.7B"):
self.model = None
self.model_size = model_size
self.device = self._get_device()
self._current_model_size: Optional[str] = None
def _get_device(self) -> str:
return get_torch_device(allow_xpu=True, allow_directml=True)
def is_loaded(self) -> bool:
return self.model is not None
def _get_model_path(self, model_size: str) -> str:
if model_size not in QWEN_CV_HF_REPOS:
raise ValueError(f"Unknown model size: {model_size}")
return QWEN_CV_HF_REPOS[model_size]
def _is_model_cached(self, model_size: Optional[str] = None) -> bool:
size = model_size or self.model_size
return is_model_cached(self._get_model_path(size))
async def load_model_async(self, model_size: Optional[str] = None) -> None:
if model_size is None:
model_size = self.model_size
if self.model is not None and self._current_model_size == model_size:
return
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
await asyncio.to_thread(self._load_model_sync, model_size)
# Alias for compatibility with the TTSBackend protocol
load_model = load_model_async
def _load_model_sync(self, model_size: str) -> None:
model_name = f"qwen-custom-voice-{model_size}"
is_cached = self._is_model_cached(model_size)
with model_load_progress(model_name, is_cached):
from qwen_tts import Qwen3TTSModel
model_path = self._get_model_path(model_size)
logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device)
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
self._current_model_size = model_size
self.model_size = model_size
logger.info("Qwen CustomVoice %s loaded successfully", model_size)
def unload_model(self) -> None:
if self.model is not None:
del self.model
self.model = None
self._current_model_size = None
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("Qwen CustomVoice unloaded")
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
"""
Create voice prompt for CustomVoice.
CustomVoice doesn't use reference audio — it uses preset speakers.
When called for a cloned profile (fallback), uses the default speaker.
For preset profiles, the voice_prompt dict is built by the profile
service and bypasses this method entirely.
"""
return {
"voice_type": "preset",
"preset_engine": "qwen_custom_voice",
"preset_voice_id": QWEN_CV_DEFAULT_SPEAKER,
}, False
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
return await _combine_voice_prompts(audio_paths, reference_texts)
async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio using Qwen CustomVoice.
Args:
text: Text to synthesize
voice_prompt: Dict with preset_voice_id (speaker name)
language: Language code (zh, en, ja, ko, etc.)
seed: Random seed for reproducibility
instruct: Natural language instruction for style control
(e.g. "Speak in an angry tone", "Very happy")
Returns:
Tuple of (audio_array, sample_rate)
"""
await self.load_model_async(None)
speaker = voice_prompt.get("preset_voice_id") or QWEN_CV_DEFAULT_SPEAKER
def _generate_sync():
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
lang_name = LANGUAGE_CODE_TO_NAME.get(language, "auto")
kwargs = {
"text": text,
"language": lang_name.capitalize() if lang_name != "auto" else "Auto",
"speaker": speaker,
}
# Only pass instruct if non-empty
if instruct:
kwargs["instruct"] = instruct
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
return wavs[0], sample_rate
audio, sample_rate = await asyncio.to_thread(_generate_sync)
return audio, sample_rate
+2
View File
@@ -86,6 +86,8 @@ def build_server(cuda=False):
"--hidden-import",
"backend.backends.pytorch_backend",
"--hidden-import",
"backend.backends.qwen_custom_voice_backend",
"--hidden-import",
"backend.utils.audio",
"--hidden-import",
"backend.utils.cache",
+47
View File
@@ -22,6 +22,21 @@ if _custom_models_dir:
_data_dir = Path("data").resolve()
def _path_relative_to_any_data_dir(path: Path) -> Path | None:
"""Extract the path within a data dir from an absolute or relative path."""
parts = path.parts
for idx, part in enumerate(parts):
if part != "data":
continue
tail = parts[idx + 1 :]
if tail:
return Path(*tail)
return Path()
return None
def set_data_dir(path: str | Path):
"""
Set the data directory path.
@@ -45,6 +60,38 @@ def get_data_dir() -> Path:
return _data_dir
def to_storage_path(path: str | Path) -> str:
"""Convert a filesystem path to a DB-safe path relative to the data dir."""
resolved_path = Path(path).resolve()
relative_to_any_data_dir = _path_relative_to_any_data_dir(resolved_path)
if relative_to_any_data_dir is not None:
return str(relative_to_any_data_dir)
try:
return str(resolved_path.relative_to(_data_dir))
except ValueError:
return str(resolved_path)
def resolve_storage_path(path: str | Path | None) -> Path | None:
"""Resolve a DB-stored path against the configured data dir."""
if path is None:
return None
stored_path = Path(path)
if stored_path.is_absolute():
rebased_path = _path_relative_to_any_data_dir(stored_path)
if rebased_path is not None:
candidate = (_data_dir / rebased_path).resolve()
if candidate.exists() or not stored_path.exists():
return candidate
return stored_path
return (_data_dir / stored_path).resolve()
def get_db_path() -> Path:
"""Get database file path."""
return _data_dir / "voicebox.db"
+11 -31
View File
@@ -34,7 +34,7 @@ def run_migrations(engine) -> None:
_migrate_generations(engine, inspector, tables)
_migrate_effect_presets(engine, inspector, tables)
_migrate_generation_versions(engine, inspector, tables)
_resolve_relative_paths(engine, tables)
_normalize_storage_paths(engine, tables)
# -- helpers ---------------------------------------------------------------
@@ -182,21 +182,11 @@ def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
_add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
def _resolve_relative_paths(engine, tables: set[str]) -> None:
"""Resolve any relative file paths in the database to absolute paths.
Earlier versions stored paths relative to CWD (e.g. "data/generations/abc.wav").
These break when the production binary's CWD differs from the data directory.
This migration converts them to absolute paths using the configured data dir.
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.
"""
def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
from ..config import get_data_dir
from ..config import get_data_dir, to_storage_path, resolve_storage_path
data_dir = get_data_dir()
@@ -219,28 +209,18 @@ def _resolve_relative_paths(engine, tables: set[str]) -> None:
if not path_val:
continue
p = Path(path_val)
if p.is_absolute():
resolved = resolve_storage_path(p)
if resolved is None:
continue
# Try rebasing: "data/generations/abc.wav" → data_dir / "generations/abc.wav"
parts = p.parts
if parts and parts[0] == "data":
rebased = data_dir / Path(*parts[1:])
else:
rebased = data_dir / p
normalized = to_storage_path(resolved)
if rebased.exists():
resolved = rebased
else:
# Fallback: resolve relative to CWD
resolved = p.resolve()
if resolved.exists():
if normalized != path_val:
conn.execute(
text(f"UPDATE {table} SET {column} = :path WHERE id = :id"),
{"path": str(resolved), "id": row_id},
{"path": normalized, "id": row_id},
)
total_fixed += 1
if total_fixed > 0:
conn.commit()
logger.info("Resolved %d relative file paths to absolute", total_fixed)
logger.info("Normalized %d stored file paths", total_fixed)
+4 -2
View File
@@ -3,7 +3,8 @@
import json
import logging
import uuid
from pathlib import Path
from .. import config
logger = logging.getLogger(__name__)
@@ -25,7 +26,8 @@ def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) ->
for gen in generations:
if gen.id in existing_version_gen_ids:
continue
if not Path(gen.audio_path).exists():
resolved_audio_path = config.resolve_storage_path(gen.audio_path)
if resolved_audio_path is None or not resolved_audio_path.exists():
continue
version = GenerationVersion(
id=str(uuid.uuid4()),
+1 -1
View File
@@ -78,7 +78,7 @@ class GenerationRequest(BaseModel):
seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
max_chunk_chars: int = Field(
default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
)
+1 -1
View File
@@ -42,7 +42,7 @@ torchaudio
# Kokoro TTS (lightweight 82M-param engine)
kokoro>=0.9.4
misaki[en]>=0.9.4
misaki[en,ja,zh]>=0.9.4
# spacy model for misaki English G2P — must be pre-installed or misaki
# tries spacy.cli.download() at runtime which crashes frozen builds
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
+7 -9
View File
@@ -1,12 +1,10 @@
"""Audio file serving endpoints."""
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from .. import models
from .. import config, models
from ..services import history
from ..database import get_db
@@ -22,8 +20,8 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
if not version:
raise HTTPException(status_code=404, detail="Version not found")
audio_path = Path(version.audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(version.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
@@ -40,8 +38,8 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)):
if not generation:
raise HTTPException(status_code=404, detail="Generation not found")
audio_path = Path(generation.audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
@@ -60,8 +58,8 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
if not sample:
raise HTTPException(status_code=404, detail="Sample not found")
audio_path = Path(sample.audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
+7 -6
View File
@@ -3,7 +3,6 @@
import asyncio
import io
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
@@ -41,10 +40,11 @@ async def preview_effects(
all_versions = versions_mod.list_versions(generation_id, db)
clean_version = next((v for v in all_versions if v.effects_chain is None), None)
source_path = clean_version.audio_path if clean_version else gen.audio_path
if not source_path or not Path(source_path).exists():
resolved_source_path = config.resolve_storage_path(source_path)
if resolved_source_path is None or not resolved_source_path.exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
audio, sample_rate = await asyncio.to_thread(load_audio, str(resolved_source_path))
processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
import soundfile as sf
@@ -193,10 +193,11 @@ async def apply_effects_to_generation(
source_path = clean_version.audio_path
source_version_id = clean_version.id
if not source_path or not Path(source_path).exists():
resolved_source_path = config.resolve_storage_path(source_path)
if resolved_source_path is None or not resolved_source_path.exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
audio, sample_rate = await asyncio.to_thread(load_audio, str(resolved_source_path))
processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
version_id = str(uuid.uuid4())
@@ -208,7 +209,7 @@ async def apply_effects_to_generation(
version = versions_mod.create_version(
generation_id=generation_id,
label=label,
audio_path=str(processed_path),
audio_path=config.to_storage_path(processed_path),
db=db,
effects_chain=chain_dicts,
is_default=data.set_as_default,
+15 -10
View File
@@ -20,6 +20,10 @@ from ..utils.tasks import get_task_manager
router = APIRouter()
def _resolve_generation_engine(data: models.GenerationRequest, profile) -> str:
return data.engine or getattr(profile, "default_engine", None) or getattr(profile, "preset_engine", None) or "qwen"
@router.post("/generate", response_model=models.GenerationResponse)
async def generate_speech(
data: models.GenerationRequest,
@@ -35,7 +39,12 @@ async def generate_speech(
from ..backends import engine_has_model_sizes
engine = data.engine or "qwen"
engine = _resolve_generation_engine(data, profile)
try:
profiles.validate_profile_engine(profile, engine)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
model_size = (data.model_size or "1.7B") if engine_has_model_sizes(engine) else None
generation = await history.create_generation(
@@ -230,15 +239,11 @@ async def stream_speech(
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
# Mirror the regular /generate endpoint behavior more closely:
# if the caller doesn't specify an engine, prefer the profile's default
# engine (or preset engine) before falling back to qwen.
engine = (
data.engine
or getattr(profile, "default_engine", None)
or getattr(profile, "preset_engine", None)
or "qwen"
)
engine = _resolve_generation_engine(data, profile)
try:
profiles.validate_profile_engine(profile, engine)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
tts_model = get_tts_backend_for_engine(engine)
model_size = data.model_size or "1.7B"
+3 -4
View File
@@ -1,13 +1,12 @@
"""Generation history endpoints."""
import io
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from .. import models
from .. import config, models
from ..services import export_import, history
from ..app import safe_content_disposition
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
@@ -162,8 +161,8 @@ async def export_generation_audio(
if not generation.audio_path:
raise HTTPException(status_code=404, detail="Generation has no audio file")
audio_path = Path(generation.audio_path)
if not audio_path.is_file():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
+17 -72
View File
@@ -4,10 +4,8 @@ import io
import json as _json
import logging
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
@@ -91,74 +89,23 @@ async def list_preset_voices(engine: str):
for vid, name, gender, lang in KOKORO_VOICES
],
}
if engine == "qwen_custom_voice":
from ..backends.qwen_custom_voice_backend import QWEN_CUSTOM_VOICES
return {
"engine": engine,
"voices": [
{
"voice_id": speaker_id,
"name": display_name,
"gender": gender,
"language": lang,
}
for speaker_id, display_name, gender, lang, _desc in QWEN_CUSTOM_VOICES
],
}
return {"engine": engine, "voices": []}
@router.post("/profiles/presets/{engine}/seed")
async def seed_preset_profiles_route(
engine: str,
db: Session = Depends(get_db),
):
"""Seed preset voice profiles for an engine.
Creates profiles for all available preset voices that don't already exist.
Returns the count of newly created profiles.
"""
if engine != "kokoro":
raise HTTPException(status_code=400, detail=f"No presets available for engine: {engine}")
try:
from ..backends.kokoro_backend import KOKORO_VOICES
created = 0
for voice_id, display_name, gender, lang in KOKORO_VOICES:
profile_name = display_name
# Disambiguate duplicate display names across languages
# (e.g. "Alpha" exists in Hindi and Japanese, "Dora" in Spanish and Portuguese)
dupes = [v for v in KOKORO_VOICES if v[1] == display_name]
if len(dupes) > 1:
lang_labels = {"en": "English", "es": "Spanish", "fr": "French", "hi": "Hindi",
"it": "Italian", "pt": "Portuguese", "ja": "Japanese", "zh": "Chinese"}
profile_name = f"{display_name} {lang_labels.get(lang, lang)}"
# Skip if preset already exists
existing = (
db.query(DBVoiceProfile)
.filter_by(preset_engine="kokoro", preset_voice_id=voice_id)
.first()
)
if existing:
continue
# Skip name collisions
if db.query(DBVoiceProfile).filter_by(name=profile_name).first():
continue
profile = DBVoiceProfile(
id=str(uuid.uuid4()),
name=profile_name,
description=f"Kokoro preset voice — {display_name} ({gender})",
language=lang,
voice_type="preset",
preset_engine="kokoro",
preset_voice_id=voice_id,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(profile)
created += 1
if created > 0:
db.commit()
logger.info(f"Seeded {created} Kokoro preset profiles")
return {"engine": engine, "created": created, "total_available": len(KOKORO_VOICES)}
except Exception as e:
logger.exception(f"Failed to seed Kokoro profiles: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse)
async def get_profile(
profile_id: str,
@@ -312,8 +259,8 @@ async def get_profile_avatar(
if not profile.avatar_path:
raise HTTPException(status_code=404, detail="No avatar found for this profile")
avatar_path = Path(profile.avatar_path)
if not avatar_path.exists():
avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path is None or not avatar_path.exists():
raise HTTPException(status_code=404, detail="Avatar file not found")
return FileResponse(avatar_path)
@@ -394,8 +341,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")
+11 -9
View File
@@ -73,8 +73,8 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
# Check if profile has avatar
has_avatar = False
if profile.avatar_path:
avatar_path = Path(profile.avatar_path)
if avatar_path.exists():
avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path is not None and avatar_path.exists():
has_avatar = True
# Add avatar to ZIP root with original extension
avatar_ext = avatar_path.suffix
@@ -98,7 +98,9 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
for sample in samples:
# Get filename from audio_path (should be {sample_id}.wav)
audio_path = Path(sample.audio_path)
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is None:
raise ValueError(f"Audio file not found: {sample.audio_path}")
filename = audio_path.name
# Read audio file
@@ -279,7 +281,7 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
# Build version manifest entries
version_entries = []
for v in versions:
v_path = Path(v.audio_path)
v_path = config.resolve_storage_path(v.audio_path)
effects_chain = None
if v.effects_chain:
effects_chain = json.loads(v.effects_chain)
@@ -314,14 +316,14 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
# Add all version audio files
for v in versions:
v_path = Path(v.audio_path)
if v_path.exists():
v_path = config.resolve_storage_path(v.audio_path)
if v_path is not None and 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():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
zip_file.write(audio_path, f"audio/{audio_path.name}")
zip_buffer.seek(0)
@@ -426,7 +428,7 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
profile_id=profile_id,
text=generation_data["text"],
language=generation_data["language"],
audio_path=str(audio_dest),
audio_path=config.to_storage_path(audio_dest),
duration=generation_data["duration"],
seed=generation_data.get("seed"),
instruct=generation_data.get("instruct"),
+8 -6
View File
@@ -163,7 +163,7 @@ def _save_generate(
versions_mod.create_version(
generation_id=generation_id,
label="original",
audio_path=str(clean_audio_path),
audio_path=config.to_storage_path(clean_audio_path),
db=db,
effects_chain=None,
is_default=not has_effects,
@@ -174,6 +174,8 @@ def _save_generate(
if has_effects:
from ..utils.effects import apply_effects, validate_effects_chain
assert effects_chain is not None
error_msg = validate_effects_chain(effects_chain)
if error_msg:
import logging
@@ -189,13 +191,13 @@ def _save_generate(
versions_mod.create_version(
generation_id=generation_id,
label="version-2",
audio_path=str(processed_path),
audio_path=config.to_storage_path(processed_path),
db=db,
effects_chain=effects_chain,
is_default=True,
)
return final_audio_path
return config.to_storage_path(final_audio_path)
def _save_retry(
@@ -211,7 +213,7 @@ def _save_retry(
"""
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
save_audio(audio, str(audio_path), sample_rate)
return str(audio_path)
return config.to_storage_path(audio_path)
def _save_regenerate(
@@ -244,10 +246,10 @@ def _save_regenerate(
versions_mod.create_version(
generation_id=generation_id,
label=label,
audio_path=str(audio_path),
audio_path=config.to_storage_path(audio_path),
db=db,
effects_chain=None,
is_default=True,
)
return str(audio_path)
return config.to_storage_path(audio_path)
+4 -4
View File
@@ -253,8 +253,8 @@ async def delete_generation(
# Delete main audio file (if not already removed by version cleanup)
if generation.audio_path:
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
# Delete from database
@@ -283,8 +283,8 @@ async def delete_generations_by_profile(
count = 0
for generation in generations:
# Delete audio file
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
# Delete from database
+174 -64
View File
@@ -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,79 @@ def _profile_to_response(
)
def _get_preset_voice_ids(engine: str) -> set[str]:
if engine == "kokoro":
from ..backends.kokoro_backend import KOKORO_VOICES
return {voice_id for voice_id, _name, _gender, _lang in KOKORO_VOICES}
if engine == "qwen_custom_voice":
from ..backends.qwen_custom_voice_backend import QWEN_CUSTOM_VOICES
return {voice_id for voice_id, _name, _gender, _lang, _desc in QWEN_CUSTOM_VOICES}
return set()
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"
available_voice_ids = _get_preset_voice_ids(preset_engine)
if available_voice_ids and preset_voice_id not in available_voice_ids:
return f"Preset voice '{preset_voice_id}' is not valid for engine '{preset_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
def validate_profile_engine(profile, engine: str) -> None:
voice_type = getattr(profile, "voice_type", None) or "cloned"
if voice_type == "preset":
preset_engine = getattr(profile, "preset_engine", None)
preset_voice_id = getattr(profile, "preset_voice_id", None)
if not preset_engine or not preset_voice_id:
raise ValueError(f"Preset profile {profile.id} is missing preset engine metadata")
if preset_engine != engine:
raise ValueError(
f"Preset profile {profile.id} only supports engine '{preset_engine}', not '{engine}'"
)
return
if voice_type == "designed":
design_prompt = getattr(profile, "design_prompt", None)
if not design_prompt or not design_prompt.strip():
raise ValueError(f"Designed profile {profile.id} is missing design_prompt")
return
if engine not in CLONING_ENGINES:
raise ValueError(f"Engine '{engine}' does not support cloned voice profiles")
async def create_profile(
data: VoiceProfileCreate,
db: Session,
@@ -94,6 +161,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,
@@ -159,7 +236,7 @@ async def add_profile_sample(
db_sample = DBProfileSample(
id=sample_id,
profile_id=profile_id,
audio_path=str(dest_path),
audio_path=config.to_storage_path(dest_path),
reference_text=reference_text,
)
@@ -180,7 +257,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 +278,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 +293,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 +334,7 @@ async def update_profile(
profile_id: str,
data: VoiceProfileCreate,
db: Session,
) -> Optional[VoiceProfileResponse]:
) -> VoiceProfileResponse | None:
"""
Update a voice profile.
@@ -281,6 +358,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
@@ -348,8 +441,8 @@ async def delete_profile_sample(
# Store profile_id before deleting
profile_id = sample.profile_id
audio_path = Path(sample.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
db.delete(sample)
@@ -366,7 +459,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.
@@ -425,9 +518,16 @@ async def create_voice_prompt_for_profile(
raise ValueError(f"Profile not found: {profile_id}")
voice_type = getattr(profile, "voice_type", None) or "cloned"
validate_profile_engine(profile, engine)
# ── 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 +536,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()
@@ -451,40 +556,48 @@ async def create_voice_prompt_for_profile(
if len(samples) == 1:
sample = samples[0]
sample_audio_path = config.resolve_storage_path(sample.audio_path)
if sample_audio_path is None:
raise ValueError(f"Sample audio not found for profile {profile_id}")
voice_prompt, _ = await tts_model.create_voice_prompt(
sample.audio_path,
str(sample_audio_path),
sample.reference_text,
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 = []
for sample in samples:
sample_audio_path = config.resolve_storage_path(sample.audio_path)
if sample_audio_path is None:
raise ValueError(f"Sample audio not found for profile {profile_id}")
audio_paths.append(str(sample_audio_path))
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(
@@ -512,8 +625,8 @@ async def upload_avatar(
raise ValueError(error_msg)
if profile.avatar_path:
old_avatar = Path(profile.avatar_path)
if old_avatar.exists():
old_avatar = config.resolve_storage_path(profile.avatar_path)
if old_avatar is not None and old_avatar.exists():
old_avatar.unlink()
# Determine file extension from uploaded file
@@ -534,7 +647,7 @@ async def upload_avatar(
process_avatar(image_path, str(output_path))
profile.avatar_path = str(output_path)
profile.avatar_path = config.to_storage_path(output_path)
profile.updated_at = datetime.utcnow()
db.commit()
@@ -561,8 +674,8 @@ async def delete_avatar(
if not profile or not profile.avatar_path:
return False
avatar_path = Path(profile.avatar_path)
if avatar_path.exists():
avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path is not None and avatar_path.exists():
avatar_path.unlink()
profile.avatar_path = None
@@ -571,6 +684,3 @@ async def delete_avatar(
db.commit()
return True
+3 -2
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import func
from .. import config
from ..models import (
StoryCreate,
StoryResponse,
@@ -826,8 +827,8 @@ async def export_story_audio(
if version:
resolved_audio_path = version.audio_path
audio_path = Path(resolved_audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(resolved_audio_path)
if audio_path is None or not audio_path.exists():
continue
try:
+4 -4
View File
@@ -158,8 +158,8 @@ def delete_version(version_id: str, db: Session) -> bool:
gen_id = version.generation_id
# Delete audio file
audio_path = Path(version.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(version.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
db.delete(version)
@@ -193,8 +193,8 @@ def delete_versions_for_generation(generation_id: str, db: Session) -> int:
)
count = 0
for v in versions:
audio_path = Path(v.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(v.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
db.delete(v)
count += 1
+1 -1
View File
@@ -5,7 +5,7 @@ from PyInstaller.utils.hooks import copy_metadata
datas = []
binaries = []
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'kokoro', 'kokoro.pipeline', 'kokoro.model', 'kokoro.istftnet', 'kokoro.modules', 'kokoro.custom_stft', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'kokoro', 'kokoro.pipeline', 'kokoro.model', 'kokoro.istftnet', 'kokoro.modules', 'kokoro.custom_stft', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
datas += copy_metadata('qwen-tts')
datas += copy_metadata('requests')
datas += copy_metadata('transformers')
+428
View File
@@ -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.