import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery } from '@tanstack/react-query'; import { Edit2, Mic, Monitor, Music, Upload, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { EffectConfig, PresetVoice, VoiceType } from '@/lib/api/types'; import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages'; import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer'; import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; import { useAddSample, useCreateProfile, useDeleteAvatar, useDeleteProfile, useProfile, useUpdateProfile, useUploadAvatar, } from '@/lib/hooks/useProfiles'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useTranscription } from '@/lib/hooks/useTranscription'; import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio'; import { usePlatform } from '@/platform/PlatformContext'; import { useServerStore } from '@/stores/serverStore'; import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore'; import { AudioSampleRecording } from './AudioSampleRecording'; import { AudioSampleSystem } from './AudioSampleSystem'; 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), description: z.string().max(500).optional(), language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), sampleFile: z.instanceof(File).optional(), referenceText: z.string().max(1000).optional(), avatarFile: z.instanceof(File).optional(), }); const profileSchema = baseProfileSchema.refine( (data) => { // If sample file is provided, reference text is required if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) { return false; } return true; }, { message: 'Reference text is required when adding a sample', path: ['referenceText'], }, ); type ProfileFormValues = z.infer; // Helper to convert File to base64 async function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } // Helper to convert base64 to File function base64ToFile(base64: string, fileName: string, fileType: string): File { const arr = base64.split(','); const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } return new File([u8arr], fileName, { type: fileType }); } export function ProfileForm() { const platform = usePlatform(); const open = useUIStore((state) => state.profileDialogOpen); const setOpen = useUIStore((state) => state.setProfileDialogOpen); const editingProfileId = useUIStore((state) => state.editingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const profileFormDraft = useUIStore((state) => state.profileFormDraft); const setProfileFormDraft = useUIStore((state) => state.setProfileFormDraft); const { data: editingProfile } = useProfile(editingProfileId || ''); const createProfile = useCreateProfile(); const updateProfile = useUpdateProfile(); const addSample = useAddSample(); const deleteProfile = useDeleteProfile(); const uploadAvatar = useUploadAvatar(); const deleteAvatar = useDeleteAvatar(); const transcribe = useTranscription(); const { toast } = useToast(); const [voiceSource, setVoiceSource] = useState<'clone' | 'builtin'>('clone'); const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record'); const [audioDuration, setAudioDuration] = useState(null); const [isValidatingAudio, setIsValidatingAudio] = useState(false); const [avatarPreview, setAvatarPreview] = useState(null); const [selectedPresetEngine, setSelectedPresetEngine] = useState('kokoro'); const [selectedPresetVoiceId, setSelectedPresetVoiceId] = useState(''); const avatarInputRef = useRef(null); const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer(); const isCreating = !editingProfileId; const serverUrl = useServerStore((state) => state.serverUrl); const [profileEffectsChain, setProfileEffectsChain] = useState([]); const [effectsDirty, setEffectsDirty] = useState(false); const [defaultEngine, setDefaultEngine] = useState(''); const form = useForm({ resolver: zodResolver(profileSchema), defaultValues: { name: '', description: '', language: 'en', sampleFile: undefined, referenceText: '', avatarFile: undefined, }, }); const selectedFile = form.watch('sampleFile'); const selectedAvatarFile = form.watch('avatarFile'); // Validate audio duration when file is selected useEffect(() => { if (selectedFile && selectedFile instanceof File) { setIsValidatingAudio(true); getAudioDuration(selectedFile as File & { recordedDuration?: number }) .then((duration) => { setAudioDuration(duration); if (duration > MAX_AUDIO_DURATION_SECONDS) { form.setError('sampleFile', { type: 'manual', message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`, }); } else { form.clearErrors('sampleFile'); } }) .catch((error) => { console.error('Failed to get audio duration:', error); setAudioDuration(null); // For recordings, we auto-stop at max duration, so we can skip validation errors const isRecordedFile = selectedFile.name.startsWith('recording-') || selectedFile.name.startsWith('system-audio-'); if (!isRecordedFile) { form.setError('sampleFile', { type: 'manual', message: 'Failed to validate audio file. Please try a different file.', }); } else { // Clear any existing errors for recorded files form.clearErrors('sampleFile'); } }) .finally(() => { setIsValidatingAudio(false); }); } else { setAudioDuration(null); form.clearErrors('sampleFile'); } }, [selectedFile, form]); const { isRecording, duration, error: recordingError, startRecording, stopRecording, cancelRecording, } = useAudioRecording({ maxDurationSeconds: 29, onRecordingComplete: (blob, recordedDuration) => { const file = new File([blob], `recording-${Date.now()}.webm`, { type: blob.type || 'audio/webm', }) as File & { recordedDuration?: number }; // Store the actual recorded duration to bypass metadata reading issues on Windows if (recordedDuration !== undefined) { file.recordedDuration = recordedDuration; } form.setValue('sampleFile', file, { shouldValidate: true }); toast({ title: 'Recording complete', description: 'Audio has been recorded successfully.', }); }, }); const { isRecording: isSystemRecording, duration: systemDuration, error: systemRecordingError, isSupported: isSystemAudioSupported, startRecording: startSystemRecording, stopRecording: stopSystemRecording, cancelRecording: cancelSystemRecording, } = useSystemAudioCapture({ maxDurationSeconds: 29, onRecordingComplete: (blob, recordedDuration) => { const file = new File([blob], `system-audio-${Date.now()}.wav`, { type: blob.type || 'audio/wav', }) as File & { recordedDuration?: number }; // Store the actual recorded duration to bypass metadata reading issues on Windows if (recordedDuration !== undefined) { file.recordedDuration = recordedDuration; } form.setValue('sampleFile', file, { shouldValidate: true }); toast({ title: 'System audio captured', description: 'Audio has been captured successfully.', }); }, }); // Fetch available preset voices for the selected engine const presetEngineToQuery = isCreating ? selectedPresetEngine : (editingProfile?.preset_engine ?? ''); const { data: presetVoicesData } = useQuery({ queryKey: ['presetVoices', presetEngineToQuery], queryFn: () => apiClient.listPresetVoices(presetEngineToQuery), enabled: !!presetEngineToQuery && ((voiceSource === 'builtin' && isCreating) || (!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(() => { if (recordingError) { toast({ title: 'Recording error', description: recordingError, variant: 'destructive', }); } }, [recordingError, toast]); // Show system audio recording errors useEffect(() => { if (systemRecordingError) { toast({ title: 'System audio capture error', description: systemRecordingError, variant: 'destructive', }); } }, [systemRecordingError, toast]); // Handle avatar preview useEffect(() => { if (selectedAvatarFile instanceof File) { const url = URL.createObjectURL(selectedAvatarFile); setAvatarPreview(url); return () => URL.revokeObjectURL(url); } else if (editingProfile?.avatar_path) { setAvatarPreview(`${serverUrl}/profiles/${editingProfile.id}/avatar`); } else { setAvatarPreview(null); } }, [selectedAvatarFile, editingProfile, serverUrl]); // Restore form state from draft or editing profile useEffect(() => { if (editingProfile) { form.reset({ name: editingProfile.name, description: editingProfile.description || '', language: editingProfile.language as LanguageCode, sampleFile: undefined, referenceText: undefined, avatarFile: undefined, }); setProfileEffectsChain(editingProfile.effects_chain ?? []); setEffectsDirty(false); setDefaultEngine(editingProfile.default_engine ?? ''); } else if (profileFormDraft && open) { // Restore from draft when opening in create mode form.reset({ name: profileFormDraft.name, description: profileFormDraft.description, language: profileFormDraft.language as LanguageCode, referenceText: profileFormDraft.referenceText, sampleFile: undefined, avatarFile: undefined, }); setSampleMode(profileFormDraft.sampleMode); // Restore the file if we have it saved if ( profileFormDraft.sampleFileData && profileFormDraft.sampleFileName && profileFormDraft.sampleFileType ) { const file = base64ToFile( profileFormDraft.sampleFileData, profileFormDraft.sampleFileName, profileFormDraft.sampleFileType, ); form.setValue('sampleFile', file); } } else if (!open) { // Only reset to defaults when modal is closed and no draft form.reset({ name: '', description: '', language: 'en', sampleFile: undefined, referenceText: undefined, avatarFile: undefined, }); setSampleMode('record'); setAvatarPreview(null); } }, [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) { toast({ title: 'No file selected', description: 'Please select an audio file first.', variant: 'destructive', }); return; } try { const language = form.getValues('language'); const result = await transcribe.mutateAsync({ file, language }); form.setValue('referenceText', result.text, { shouldValidate: true }); } catch (error) { toast({ title: 'Transcription failed', description: error instanceof Error ? error.message : 'Failed to transcribe audio', variant: 'destructive', }); } } function handleCancelRecording() { if (sampleMode === 'record') { cancelRecording(); } else if (sampleMode === 'system') { cancelSystemRecording(); } form.resetField('sampleFile'); cleanupAudio(); } function handlePlayPause() { const file = form.getValues('sampleFile'); playPause(file); } function handleAvatarFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (file) { if (!file.type.startsWith('image/')) { toast({ title: 'Invalid file type', description: 'Please select an image file (PNG, JPG, or WebP)', variant: 'destructive', }); return; } if (file.size > 5 * 1024 * 1024) { toast({ title: 'File too large', description: 'Image must be less than 5MB', variant: 'destructive', }); return; } form.setValue('avatarFile', file); } } async function handleRemoveAvatar() { if (editingProfileId && editingProfile?.avatar_path) { try { await deleteAvatar.mutateAsync(editingProfileId); toast({ title: 'Avatar removed', description: 'Avatar image has been removed successfully.', }); } catch (error) { toast({ title: 'Failed to remove avatar', description: error instanceof Error ? error.message : 'Unknown error', variant: 'destructive', }); } } form.setValue('avatarFile', undefined); setAvatarPreview(null); if (avatarInputRef.current) { avatarInputRef.current.value = ''; } } async function onSubmit(data: ProfileFormValues) { try { if (editingProfileId) { // Editing: update profile await updateProfile.mutateAsync({ profileId: editingProfileId, data: { name: data.name, description: data.description, language: data.language, default_engine: defaultEngine || undefined, }, }); // Handle avatar upload/update if file changed if (data.avatarFile) { try { await uploadAvatar.mutateAsync({ profileId: editingProfileId, file: data.avatarFile, }); } catch (avatarError) { toast({ title: 'Avatar upload failed', description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar', variant: 'destructive', }); } } // Save effects chain if changed if (effectsDirty) { try { await apiClient.updateProfileEffects( editingProfileId, profileEffectsChain.length > 0 ? profileEffectsChain : null, ); } catch (fxError) { toast({ title: 'Effects update failed', description: fxError instanceof Error ? fxError.message : 'Failed to save effects chain', variant: 'destructive', }); return; } } toast({ title: 'Voice updated', description: `"${data.name}" has been updated successfully.`, }); } else if (voiceSource === 'builtin') { // Creating preset profile from built-in voice if (!selectedPresetVoiceId) { toast({ title: 'No voice selected', description: 'Please select a built-in voice.', variant: 'destructive', }); return; } const profile = await createProfile.mutateAsync({ name: data.name, description: data.description, language: data.language, voice_type: 'preset' as VoiceType, preset_engine: selectedPresetEngine, preset_voice_id: selectedPresetVoiceId, default_engine: selectedPresetEngine, }); // Handle avatar upload if provided if (data.avatarFile) { try { await uploadAvatar.mutateAsync({ profileId: profile.id, file: data.avatarFile, }); } catch (avatarError) { toast({ title: 'Avatar upload failed', description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar', variant: 'destructive', }); } } toast({ title: 'Profile created', description: `"${data.name}" has been created with a built-in voice.`, }); } else { // Creating cloned profile: require sample file and reference text const sampleFile = form.getValues('sampleFile'); const referenceText = form.getValues('referenceText'); if (!sampleFile) { form.setError('sampleFile', { type: 'manual', message: 'Audio sample is required', }); toast({ title: 'Audio sample required', description: 'Please provide an audio sample to create the voice profile.', variant: 'destructive', }); return; } if (!referenceText || referenceText.trim().length === 0) { form.setError('referenceText', { type: 'manual', message: 'Reference text is required', }); toast({ title: 'Reference text required', description: 'Please provide the reference text for the audio sample.', variant: 'destructive', }); return; } // Validate audio duration before creating profile try { const duration = await getAudioDuration(sampleFile); if (duration > MAX_AUDIO_DURATION_SECONDS) { form.setError('sampleFile', { type: 'manual', message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`, }); toast({ title: 'Invalid audio file', description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`, variant: 'destructive', }); return; // Prevent form submission } } catch (error) { form.setError('sampleFile', { type: 'manual', message: 'Failed to validate audio file. Please try a different file.', }); toast({ title: 'Validation error', description: error instanceof Error ? error.message : 'Failed to validate audio file', variant: 'destructive', }); return; // Prevent form submission } // Creating: create profile, then add sample const profile = await createProfile.mutateAsync({ name: data.name, description: data.description, language: data.language, default_engine: defaultEngine || undefined, }); // Convert non-WAV uploads to WAV so the backend can always use soundfile. // Recorded audio is already WAV (from useAudioRecording's convertToWav call). let fileToUpload: File = sampleFile; if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) { try { const wavBlob = await convertToWav(sampleFile); const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav'); fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' }); } catch { // If browser can't decode the format, send the original and let the backend try. } } try { await addSample.mutateAsync({ profileId: profile.id, file: fileToUpload, referenceText: referenceText, }); // Handle avatar upload if provided if (data.avatarFile) { try { await uploadAvatar.mutateAsync({ profileId: profile.id, file: data.avatarFile, }); } catch (avatarError) { toast({ title: 'Avatar upload failed', description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar', variant: 'destructive', }); } } toast({ title: 'Profile created', description: `"${data.name}" has been created with a sample.`, }); } catch (sampleError) { 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: 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; } } // Clear draft and reset form on success setProfileFormDraft(null); form.reset(); setEditingProfileId(null); setOpen(false); } catch (error) { toast({ title: 'Error', description: error instanceof Error ? error.message : 'Failed to save profile', variant: 'destructive', }); } } async function handleOpenChange(newOpen: boolean) { if (!newOpen && isCreating) { // Save draft when closing the create modal const values = form.getValues(); const hasContent = values.name || values.description || values.referenceText || values.sampleFile; if (hasContent) { const draft: ProfileFormDraft = { name: values.name || '', description: values.description || '', language: values.language || 'en', referenceText: values.referenceText || '', sampleMode, }; // Save file as base64 if present if (values.sampleFile) { try { draft.sampleFileName = values.sampleFile.name; draft.sampleFileType = values.sampleFile.type; draft.sampleFileData = await fileToBase64(values.sampleFile); } catch { // If file conversion fails, just don't save the file } } setProfileFormDraft(draft); } } setOpen(newOpen); if (!newOpen) { setEditingProfileId(null); // Don't reset form here - let the effect handle it based on draft state if (isRecording) { cancelRecording(); } if (isSystemRecording) { cancelSystemRecording(); } cleanupAudio(); } } return (
{editingProfileId ? 'Edit Voice' : 'Create Voice'} {editingProfileId ? 'Update your voice profile details and manage samples.' : 'Create a new voice profile from an audio sample or a built-in voice.'} {isCreating && profileFormDraft && (
Draft restored
)}
{/* Left column: Sample management */}
{isCreating ? ( <> {/* Voice source selector */}
{voiceSource === 'builtin' ? (
Choose a pre-built voice. These don't require an audio sample. {/* Engine selector */} Engine {/* Voice picker */} Voice
{presetVoices.map((voice: PresetVoice) => ( ))}
) : ( <> { const newMode = v as 'upload' | 'record' | 'system'; // Cancel any active recordings when switching modes if (isRecording && newMode !== 'record') { cancelRecording(); } if (isSystemRecording && newMode !== 'system') { cancelSystemRecording(); } setSampleMode(newMode); }} > Upload Record {platform.metadata.isTauri && isSystemAudioSupported && ( System Audio )} ( MAX_AUDIO_DURATION_SECONDS } fieldName={name} /> )} /> ( )} /> {platform.metadata.isTauri && isSystemAudioSupported && ( ( )} /> )} ( Reference Text