diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index 68a42bf8..4089958e 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; @@ -13,7 +13,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -41,7 +40,7 @@ import { useTranscription } from '@/lib/hooks/useTranscription'; import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useUIStore } from '@/stores/uiStore'; -import { Mic, Square, Upload, Monitor } from 'lucide-react'; +import { Mic, Square, Upload, Monitor, Play, Pause } from 'lucide-react'; import { formatAudioDuration } from '@/lib/utils/audio'; import { isTauri } from '@/lib/tauri'; @@ -79,28 +78,27 @@ async function getAudioDuration(file: File & { recordedDuration?: number }): Pro const MAX_AUDIO_DURATION_SECONDS = 30; -const profileSchema = 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[]]), - // Sample fields - only required when creating (not editing) - sampleFile: z.instanceof(File).optional(), - referenceText: z.string().max(1000).optional(), - }) - .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'], - }, - ); +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; @@ -118,6 +116,10 @@ export function ProfileForm() { const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('upload'); const [audioDuration, setAudioDuration] = useState(null); const [isValidatingAudio, setIsValidatingAudio] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const fileInputRef = useRef(null); + const audioRef = useRef(null); const isCreating = !editingProfileId; const form = useForm({ @@ -126,6 +128,8 @@ export function ProfileForm() { name: '', description: '', language: 'en', + sampleFile: undefined, + referenceText: '', }, }); @@ -303,6 +307,54 @@ export function ProfileForm() { cancelSystemRecording(); } form.resetField('sampleFile'); + // Stop any playing audio + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setIsPlaying(false); + } + + function handlePlayPause() { + const file = form.getValues('sampleFile'); + if (!file) return; + + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + setIsPlaying(false); + } else { + audioRef.current.play(); + setIsPlaying(true); + } + } else { + const audio = new Audio(URL.createObjectURL(file)); + audioRef.current = audio; + + audio.addEventListener('ended', () => { + setIsPlaying(false); + if (audioRef.current) { + URL.revokeObjectURL(audioRef.current.src); + } + audioRef.current = null; + }); + + audio.addEventListener('error', () => { + setIsPlaying(false); + toast({ + title: 'Playback error', + description: 'Failed to play audio file', + variant: 'destructive', + }); + if (audioRef.current) { + URL.revokeObjectURL(audioRef.current.src); + } + audioRef.current = null; + }); + + audio.play(); + setIsPlaying(true); + } } async function onSubmit(data: ProfileFormValues) { @@ -322,71 +374,87 @@ export function ProfileForm() { description: `"${data.name}" has been updated successfully.`, }); } else { - // Get file and reference text directly from form state to ensure we have the values + // 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 - if (sampleFile) { - 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) { + try { + const duration = await getAudioDuration(sampleFile); + if (duration > MAX_AUDIO_DURATION_SECONDS) { form.setError('sampleFile', { type: 'manual', - message: 'Failed to validate audio file. Please try a different file.', + message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`, }); toast({ - title: 'Validation error', - description: error instanceof Error ? error.message : 'Failed to validate audio file', + 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 optionally add sample + // Creating: create profile, then add sample const profile = await createProfile.mutateAsync({ name: data.name, description: data.description, language: data.language, }); - // If sample file and reference text provided, add it - if (sampleFile && referenceText && referenceText.trim().length > 0) { - 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 success for profile - toast({ - title: 'Profile created', - description: `"${data.name}" has been created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`, - variant: 'destructive', - }); - } - } else { + try { + await addSample.mutateAsync({ + profileId: profile.id, + file: sampleFile, + referenceText: referenceText, + }); toast({ title: 'Profile created', - description: `"${data.name}" has been created successfully. You can add samples later.`, + 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', }); } } @@ -415,6 +483,13 @@ export function ProfileForm() { if (isSystemRecording) { cancelSystemRecording(); } + // Stop and cleanup audio + if (audioRef.current) { + audioRef.current.pause(); + URL.revokeObjectURL(audioRef.current.src); + audioRef.current = null; + } + setIsPlaying(false); } } @@ -426,7 +501,7 @@ export function ProfileForm() { {editingProfileId ? 'Update your voice profile details.' - : 'Create a new voice profile. You can add a sample now or later.'} + : 'Create a new voice profile with an audio sample to clone the voice.'} @@ -493,10 +568,9 @@ export function ProfileForm() { {isCreating && (
-

Add Sample (Optional)

+

Add Sample

- Add an audio sample to get started immediately. You can add more samples - later. + Provide an audio sample to clone the voice. You can add more samples later.

@@ -516,16 +590,16 @@ export function ProfileForm() { > - + Upload - + Record {isTauri() && isSystemAudioSupported && ( - + System Audio )} @@ -535,16 +609,16 @@ export function ProfileForm() { ( + render={({ field: { onChange, name } }) => ( Audio File
- { const file = e.target.files?.[0]; if (file) { @@ -553,50 +627,103 @@ export function ProfileForm() { onChange(undefined); } }} + className="hidden" /> - {selectedFile && ( - <> - {isValidatingAudio && ( -

- Validating audio... +

{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file && file.type.startsWith('audio/')) { + onChange(file); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${ + selectedFile + ? 'border-primary bg-primary/5' + : isDragging + ? 'border-primary bg-primary/5' + : 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50' + }`} + > + {!selectedFile ? ( + <> + +

+ Click to choose a file or drag and drop. Maximum duration: 30 seconds.

- )} - {!isValidatingAudio && audioDuration !== null && ( -
- Duration: - MAX_AUDIO_DURATION_SECONDS - ? 'text-destructive font-medium' - : 'text-foreground' - } - > - {formatAudioDuration(audioDuration)} - - - / {formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)} max - + + ) : ( + <> +
+ + File uploaded
- )} - - - )} +

+ File: {selectedFile.name} +

+
+ + + +
+ + )} +
- - Supported formats: WAV, MP3, M4A. Maximum duration:{' '} - {formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}. Click "Transcribe" - to automatically extract text from the audio. - )} @@ -613,7 +740,7 @@ export function ProfileForm() {
{!isRecording && !selectedFile && ( -
+

- Recording in progress... ({formatAudioDuration(30 - duration)}{' '} - remaining) + {formatAudioDuration(30 - duration)} remaining

)} {selectedFile && !isRecording && ( -
+
Recording complete @@ -665,6 +791,14 @@ export function ProfileForm() { File: {selectedFile.name}

+

- Capturing system audio... ({formatAudioDuration(30 - systemDuration)}{' '} - remaining) + {formatAudioDuration(30 - systemDuration)} remaining

)} {selectedFile && !isSystemRecording && ( -
+
Capture complete @@ -761,6 +890,14 @@ export function ProfileForm() { File: {selectedFile.name}

+ - )} +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file && file.type.startsWith('audio/')) { + onChange(file); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${ + selectedFile + ? 'border-primary bg-primary/5' + : isDragging + ? 'border-primary bg-primary/5' + : 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50' + }`} + > + {!selectedFile ? ( + <> + +

+ Click to choose a file or drag and drop. Maximum duration: 30 seconds. +

+ + ) : ( + <> +
+ + File uploaded +
+

+ File: {selectedFile.name} +

+
+ + + +
+ + )} +
- - Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically - extract text from the audio. - )} @@ -304,7 +439,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
{!isRecording && !selectedFile && ( -
+

- Recording in progress... ({formatAudioDuration(30 - duration)}{' '} - remaining) + {formatAudioDuration(30 - duration)} remaining

)} {selectedFile && !isRecording && ( -
+
Recording complete @@ -356,6 +490,14 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp File: {selectedFile.name}

+

- Capturing system audio... ({formatAudioDuration(30 - systemDuration)}{' '} - remaining) + {formatAudioDuration(30 - systemDuration)} remaining

)} {selectedFile && !isSystemRecording && mode === 'system' && ( -
+
Capture complete @@ -451,6 +589,14 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp File: {selectedFile.name}

+