import { zodResolver } from '@hookform/resolvers/zod'; import { Mic, Monitor, Upload } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Form, FormControl, 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 { 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, useProfile, useUpdateProfile, } from '@/lib/hooks/useProfiles'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useTranscription } from '@/lib/hooks/useTranscription'; import { isTauri } from '@/lib/tauri'; import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio'; import { 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 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(), }); 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; export function ProfileForm() { 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 { data: editingProfile } = useProfile(editingProfileId || ''); const createProfile = useCreateProfile(); const updateProfile = useUpdateProfile(); const addSample = useAddSample(); const transcribe = useTranscription(); const { toast } = useToast(); const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('upload'); const [audioDuration, setAudioDuration] = useState(null); const [isValidatingAudio, setIsValidatingAudio] = useState(false); const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer(); const isCreating = !editingProfileId; const form = useForm({ resolver: zodResolver(profileSchema), defaultValues: { name: '', description: '', language: 'en', sampleFile: undefined, referenceText: '', }, }); const selectedFile = form.watch('sampleFile'); // 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.', }); }, }); // 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]); useEffect(() => { if (editingProfile) { form.reset({ name: editingProfile.name, description: editingProfile.description || '', language: editingProfile.language as LanguageCode, sampleFile: undefined, referenceText: undefined, }); } else { form.reset({ name: '', description: '', language: 'en', sampleFile: undefined, referenceText: undefined, }); setSampleMode('upload'); } }, [editingProfile, form]); 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); } async function onSubmit(data: ProfileFormValues) { try { if (editingProfileId) { // Editing: just update profile await updateProfile.mutateAsync({ profileId: editingProfileId, data: { name: data.name, description: data.description, language: data.language, }, }); toast({ title: 'Voice updated', description: `"${data.name}" has been updated successfully.`, }); } else { // Creating: 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, }); try { await addSample.mutateAsync({ profileId: profile.id, file: sampleFile, referenceText: referenceText, }); toast({ title: 'Profile created', description: `"${data.name}" has been created with a sample.`, }); } catch (sampleError) { // Profile was created but sample failed - still show error toast({ title: 'Failed to add sample', description: `Profile "${data.name}" was created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`, variant: 'destructive', }); } } form.reset(); setEditingProfileId(null); setOpen(false); } catch (error) { toast({ title: 'Error', description: error instanceof Error ? error.message : 'Failed to save profile', variant: 'destructive', }); } } function handleOpenChange(open: boolean) { setOpen(open); if (!open) { setEditingProfileId(null); form.reset(); setSampleMode('upload'); if (isRecording) { cancelRecording(); } if (isSystemRecording) { cancelSystemRecording(); } cleanupAudio(); } } return ( {editingProfileId ? 'Edit Voice' : 'Create Voice Profile'} {editingProfileId ? 'Update your voice profile details and manage samples.' : 'Create a new voice profile with an audio sample to clone the voice.'}
{/* Left column: Profile info */}
( Name )} /> ( Description (Optional)