From d70b878b71d40d56523a7bcc6f183694378f0887 Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 19 Mar 2026 19:32:49 -0700 Subject: [PATCH] fix: tighten kokoro profile handling --- .../Generation/EngineModelSelector.tsx | 21 +- .../components/VoiceProfiles/ProfileForm.tsx | 61 ++- .../components/VoiceProfiles/ProfileList.tsx | 2 +- backend/database/migrations.py | 13 +- backend/routes/profiles.py | 15 +- backend/services/profiles.py | 169 ++++--- docs/plans/API_REFACTOR_PLAN.md | 428 ++++++++++++++++++ 7 files changed, 618 insertions(+), 91 deletions(-) create mode 100644 docs/plans/API_REFACTOR_PLAN.md diff --git a/app/src/components/Generation/EngineModelSelector.tsx b/app/src/components/Generation/EngineModelSelector.tsx index 1dfd4e9b..80fd82af 100644 --- a/app/src/components/Generation/EngineModelSelector.tsx +++ b/app/src/components/Generation/EngineModelSelector.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import type { UseFormReturn } from 'react-hook-form'; import { FormControl } from '@/components/ui/form'; import { @@ -41,12 +42,9 @@ const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']); /** Engines that support cloned (reference audio) profiles. */ const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']); -/** - * All engine options are always available. The profile grid already - * filters by engine, so the dropdown doesn't need to restrict options. - */ -function getAvailableOptions(_selectedProfile?: VoiceProfileResponse | null) { - return ENGINE_OPTIONS; +function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) { + if (!selectedProfile) return ENGINE_OPTIONS; + return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine)); } function getSelectValue(engine: string, modelSize?: string): string { @@ -108,12 +106,13 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo const selectValue = getSelectValue(engine, modelSize); const availableOptions = getAvailableOptions(selectedProfile); - // If current engine isn't in available options, auto-switch to first available const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue); - if (!currentEngineAvailable && availableOptions.length > 0) { - // Defer to avoid setting state during render - setTimeout(() => handleEngineChange(form, availableOptions[0].value), 0); - } + + useEffect(() => { + if (!currentEngineAvailable && availableOptions.length > 0) { + handleEngineChange(form, availableOptions[0].value); + } + }, [availableOptions, currentEngineAvailable, form]); const itemClass = compact ? 'text-xs text-muted-foreground' : undefined; const triggerClass = compact diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index d3f53eac..0c4987b5 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -43,6 +43,7 @@ import { useAddSample, useCreateProfile, useDeleteAvatar, + useDeleteProfile, useProfile, useUpdateProfile, useUploadAvatar, @@ -59,6 +60,15 @@ import { AudioSampleUpload } from './AudioSampleUpload'; import { SampleList } from './SampleList'; const MAX_AUDIO_DURATION_SECONDS = 30; +const PRESET_ONLY_ENGINES = new Set(['kokoro']); +const DEFAULT_ENGINE_OPTIONS = [ + { value: 'qwen', label: 'Qwen3-TTS' }, + { value: 'luxtts', label: 'LuxTTS' }, + { value: 'chatterbox', label: 'Chatterbox' }, + { value: 'chatterbox_turbo', label: 'Chatterbox Turbo' }, + { value: 'tada', label: 'TADA' }, + { value: 'kokoro', label: 'Kokoro 82M' }, +] as const; const baseProfileSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), @@ -119,6 +129,7 @@ export function ProfileForm() { const createProfile = useCreateProfile(); const updateProfile = useUpdateProfile(); const addSample = useAddSample(); + const deleteProfile = useDeleteProfile(); const uploadAvatar = useUploadAvatar(); const deleteAvatar = useDeleteAvatar(); const transcribe = useTranscription(); @@ -259,6 +270,12 @@ export function ProfileForm() { (!isCreating && editingProfile?.voice_type === 'preset')), }); const presetVoices = presetVoicesData?.voices ?? []; + const isSampleBasedProfile = isCreating + ? voiceSource === 'clone' + : editingProfile?.voice_type !== 'preset'; + const availableDefaultEngines = DEFAULT_ENGINE_OPTIONS.filter( + (option) => !isSampleBasedProfile || !PRESET_ONLY_ENGINES.has(option.value), + ); // Show recording errors useEffect(() => { @@ -348,6 +365,15 @@ export function ProfileForm() { } }, [editingProfile, profileFormDraft, open, form]); + useEffect(() => { + if ( + defaultEngine && + !availableDefaultEngines.some((option) => option.value === defaultEngine) + ) { + setDefaultEngine(''); + } + }, [availableDefaultEngines, defaultEngine]); + async function handleTranscribe() { const file = form.getValues('sampleFile'); if (!file) { @@ -638,12 +664,32 @@ export function ProfileForm() { description: `"${data.name}" has been created with a sample.`, }); } catch (sampleError) { - // Profile was created but sample failed - still show error + let rollbackSucceeded = false; + try { + await deleteProfile.mutateAsync(profile.id); + rollbackSucceeded = true; + } catch (rollbackError) { + toast({ + title: 'Rollback failed', + description: + rollbackError instanceof Error + ? rollbackError.message + : 'Created profile could not be removed after sample upload failure.', + variant: 'destructive', + }); + } + toast({ title: 'Failed to add sample', - description: `Profile "${data.name}" was created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`, + description: + sampleError instanceof Error + ? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}` + : rollbackSucceeded + ? 'Failed to add sample. The profile was rolled back.' + : 'Failed to add sample.', variant: 'destructive', }); + return; } } @@ -1140,12 +1186,11 @@ export function ProfileForm() { No preference - Qwen3-TTS - LuxTTS - Chatterbox - Chatterbox Turbo - TADA - Kokoro 82M + {availableDefaultEngines.map((option) => ( + + {option.label} + + ))}

diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx index 97dde233..606f4a2e 100644 --- a/app/src/components/VoiceProfiles/ProfileList.tsx +++ b/app/src/components/VoiceProfiles/ProfileList.tsx @@ -63,7 +63,7 @@ export function ProfileList() { No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet.

- The default voice will be used. Create a profile to choose a specific voice. + Create a profile to choose a specific voice before generating.