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.