Enhance audio sample handling in ProfileForm and SampleUpload components

- Introduced play/pause functionality for audio samples, allowing users to preview uploaded audio files.
- Added drag-and-drop support for file uploads, improving user experience when selecting audio files.
- Refactored audio validation and error handling to ensure proper feedback for audio file requirements.
- Updated UI elements for better clarity and consistency in audio file management.
This commit is contained in:
Jamie Pine
2026-01-26 16:38:52 -08:00
parent 8cd868d33f
commit 834323068d
2 changed files with 454 additions and 186 deletions
+268 -139
View File
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,7 +13,6 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -41,7 +40,7 @@ import { useTranscription } from '@/lib/hooks/useTranscription';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useUIStore } from '@/stores/uiStore'; 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 { formatAudioDuration } from '@/lib/utils/audio';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
@@ -79,28 +78,27 @@ async function getAudioDuration(file: File & { recordedDuration?: number }): Pro
const MAX_AUDIO_DURATION_SECONDS = 30; const MAX_AUDIO_DURATION_SECONDS = 30;
const profileSchema = z const baseProfileSchema = z.object({
.object({ name: z.string().min(1, 'Name is required').max(100),
name: z.string().min(1, 'Name is required').max(100), description: z.string().max(500).optional(),
description: z.string().max(500).optional(), language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), sampleFile: z.instanceof(File).optional(),
// Sample fields - only required when creating (not editing) referenceText: z.string().max(1000).optional(),
sampleFile: z.instanceof(File).optional(), });
referenceText: z.string().max(1000).optional(),
}) const profileSchema = baseProfileSchema.refine(
.refine( (data) => {
(data) => { // If sample file is provided, reference text is required
// If sample file is provided, reference text is required if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) { return false;
return false; }
} return true;
return true; },
}, {
{ message: 'Reference text is required when adding a sample',
message: 'Reference text is required when adding a sample', path: ['referenceText'],
path: ['referenceText'], },
}, );
);
type ProfileFormValues = z.infer<typeof profileSchema>; type ProfileFormValues = z.infer<typeof profileSchema>;
@@ -118,6 +116,10 @@ export function ProfileForm() {
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('upload'); const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('upload');
const [audioDuration, setAudioDuration] = useState<number | null>(null); const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [isValidatingAudio, setIsValidatingAudio] = useState(false); const [isValidatingAudio, setIsValidatingAudio] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const isCreating = !editingProfileId; const isCreating = !editingProfileId;
const form = useForm<ProfileFormValues>({ const form = useForm<ProfileFormValues>({
@@ -126,6 +128,8 @@ export function ProfileForm() {
name: '', name: '',
description: '', description: '',
language: 'en', language: 'en',
sampleFile: undefined,
referenceText: '',
}, },
}); });
@@ -303,6 +307,54 @@ export function ProfileForm() {
cancelSystemRecording(); cancelSystemRecording();
} }
form.resetField('sampleFile'); 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) { async function onSubmit(data: ProfileFormValues) {
@@ -322,71 +374,87 @@ export function ProfileForm() {
description: `"${data.name}" has been updated successfully.`, description: `"${data.name}" has been updated successfully.`,
}); });
} else { } 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 sampleFile = form.getValues('sampleFile');
const referenceText = form.getValues('referenceText'); 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 // Validate audio duration before creating profile
if (sampleFile) { try {
try { const duration = await getAudioDuration(sampleFile);
const duration = await getAudioDuration(sampleFile); if (duration > MAX_AUDIO_DURATION_SECONDS) {
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', { form.setError('sampleFile', {
type: 'manual', 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({ toast({
title: 'Validation error', title: 'Invalid audio file',
description: error instanceof Error ? error.message : 'Failed to validate audio file', description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
variant: 'destructive', variant: 'destructive',
}); });
return; // Prevent form submission 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({ const profile = await createProfile.mutateAsync({
name: data.name, name: data.name,
description: data.description, description: data.description,
language: data.language, language: data.language,
}); });
// If sample file and reference text provided, add it try {
if (sampleFile && referenceText && referenceText.trim().length > 0) { await addSample.mutateAsync({
try { profileId: profile.id,
await addSample.mutateAsync({ file: sampleFile,
profileId: profile.id, referenceText: referenceText,
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 {
toast({ toast({
title: 'Profile created', 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) { if (isSystemRecording) {
cancelSystemRecording(); 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() {
<DialogDescription> <DialogDescription>
{editingProfileId {editingProfileId
? 'Update your voice profile details.' ? '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.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -493,10 +568,9 @@ export function ProfileForm() {
{isCreating && ( {isCreating && (
<div className="space-y-4 border-l pl-6"> <div className="space-y-4 border-l pl-6">
<div> <div>
<h3 className="text-sm font-medium mb-2">Add Sample (Optional)</h3> <h3 className="text-sm font-medium mb-2">Add Sample</h3>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Add an audio sample to get started immediately. You can add more samples Provide an audio sample to clone the voice. You can add more samples later.
later.
</p> </p>
</div> </div>
@@ -516,16 +590,16 @@ export function ProfileForm() {
> >
<TabsList className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}> <TabsList className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}>
<TabsTrigger value="upload" className="flex items-center gap-2"> <TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4 shrink-0" />
Upload Upload
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2"> <TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4 shrink-0" />
Record Record
</TabsTrigger> </TabsTrigger>
{isTauri() && isSystemAudioSupported && ( {isTauri() && isSystemAudioSupported && (
<TabsTrigger value="system" className="flex items-center gap-2"> <TabsTrigger value="system" className="flex items-center gap-2">
<Monitor className="h-4 w-4" /> <Monitor className="h-4 w-4 shrink-0" />
System Audio System Audio
</TabsTrigger> </TabsTrigger>
)} )}
@@ -535,16 +609,16 @@ export function ProfileForm() {
<FormField <FormField
control={form.control} control={form.control}
name="sampleFile" name="sampleFile"
render={({ field: { onChange, name, ref } }) => ( render={({ field: { onChange, name } }) => (
<FormItem> <FormItem>
<FormLabel>Audio File</FormLabel> <FormLabel>Audio File</FormLabel>
<FormControl> <FormControl>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Input <input
type="file" type="file"
accept="audio/*" accept="audio/*"
name={name} name={name}
ref={ref} ref={fileInputRef}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
@@ -553,50 +627,103 @@ export function ProfileForm() {
onChange(undefined); onChange(undefined);
} }
}} }}
className="hidden"
/> />
{selectedFile && ( <div
<> role="button"
{isValidatingAudio && ( tabIndex={0}
<p className="text-sm text-muted-foreground"> onDragOver={(e) => {
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 ? (
<>
<Button
type="button"
size="lg"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2"
>
<Upload className="h-5 w-5" />
Choose File
</Button>
<p className="text-sm text-muted-foreground text-center">
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
</p> </p>
)} </>
{!isValidatingAudio && audioDuration !== null && ( ) : (
<div className="flex items-center gap-2 text-sm"> <>
<span className="text-muted-foreground">Duration:</span> <div className="flex items-center gap-2">
<span <Upload className="h-5 w-5 text-primary" />
className={ <span className="font-medium">File uploaded</span>
audioDuration > MAX_AUDIO_DURATION_SECONDS
? 'text-destructive font-medium'
: 'text-foreground'
}
>
{formatAudioDuration(audioDuration)}
</span>
<span className="text-muted-foreground">
/ {formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)} max
</span>
</div> </div>
)} <p className="text-sm text-muted-foreground text-center">
<Button File: {selectedFile.name}
type="button" </p>
variant="outline" <div className="flex gap-2">
onClick={handleTranscribe} <Button
disabled={transcribe.isPending || isValidatingAudio || (audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS)} type="button"
className="flex items-center gap-2 w-full" size="icon"
> variant="outline"
<Mic className="h-4 w-4" /> onClick={handlePlayPause}
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'} disabled={isValidatingAudio}
</Button> >
</> {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
)} </Button>
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending || isValidatingAudio || (audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS)}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
onChange(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
>
Remove
</Button>
</div>
</>
)}
</div>
</div> </div>
</FormControl> </FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Maximum duration:{' '}
{formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}. Click "Transcribe"
to automatically extract text from the audio.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -613,7 +740,7 @@ export function ProfileForm() {
<FormControl> <FormControl>
<div className="space-y-4"> <div className="space-y-4">
{!isRecording && !selectedFile && ( {!isRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-dashed rounded-lg"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button <Button
type="button" type="button"
onClick={startRecording} onClick={startRecording}
@@ -630,7 +757,7 @@ export function ProfileForm() {
)} )}
{isRecording && ( {isRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" /> <div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
@@ -649,14 +776,13 @@ export function ProfileForm() {
Stop Recording Stop Recording
</Button> </Button>
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Recording in progress... ({formatAudioDuration(30 - duration)}{' '} {formatAudioDuration(30 - duration)} remaining
remaining)
</p> </p>
</div> </div>
)} )}
{selectedFile && !isRecording && ( {selectedFile && !isRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" /> <Mic className="h-5 w-5 text-primary" />
<span className="font-medium">Recording complete</span> <span className="font-medium">Recording complete</span>
@@ -665,6 +791,14 @@ export function ProfileForm() {
File: {selectedFile.name} File: {selectedFile.name}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={handlePlayPause}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -688,10 +822,6 @@ export function ProfileForm() {
)} )}
</div> </div>
</FormControl> </FormControl>
<FormDescription>
Record audio directly from your microphone. Maximum duration is 30
seconds.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -709,7 +839,7 @@ export function ProfileForm() {
<FormControl> <FormControl>
<div className="space-y-4"> <div className="space-y-4">
{!isSystemRecording && !selectedFile && ( {!isSystemRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-dashed rounded-lg"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button <Button
type="button" type="button"
onClick={startSystemRecording} onClick={startSystemRecording}
@@ -726,7 +856,7 @@ export function ProfileForm() {
)} )}
{isSystemRecording && ( {isSystemRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" /> <div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
@@ -745,14 +875,13 @@ export function ProfileForm() {
Stop Capture Stop Capture
</Button> </Button>
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Capturing system audio... ({formatAudioDuration(30 - systemDuration)}{' '} {formatAudioDuration(30 - systemDuration)} remaining
remaining)
</p> </p>
</div> </div>
)} )}
{selectedFile && !isSystemRecording && ( {selectedFile && !isSystemRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" /> <Monitor className="h-5 w-5 text-primary" />
<span className="font-medium">Capture complete</span> <span className="font-medium">Capture complete</span>
@@ -761,6 +890,14 @@ export function ProfileForm() {
File: {selectedFile.name} File: {selectedFile.name}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={handlePlayPause}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -784,10 +921,6 @@ export function ProfileForm() {
)} )}
</div> </div>
</FormControl> </FormControl>
<FormDescription>
Capture audio from your system (speakers, applications). Maximum duration is 30
seconds.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -809,10 +942,6 @@ export function ProfileForm() {
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription>
This should match exactly what is spoken in the audio file. Required if
you add a sample.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
+186 -47
View File
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import * as z from 'zod'; import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -13,13 +13,11 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
@@ -27,7 +25,7 @@ import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
import { useTranscription } from '@/lib/hooks/useTranscription'; import { useTranscription } from '@/lib/hooks/useTranscription';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
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 { formatAudioDuration } from '@/lib/utils/audio';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
@@ -53,6 +51,10 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
const { data: profile } = useProfile(profileId); const { data: profile } = useProfile(profileId);
const { toast } = useToast(); const { toast } = useToast();
const [mode, setMode] = useState<'upload' | 'record' | 'system'>('upload'); const [mode, setMode] = useState<'upload' | 'record' | 'system'>('upload');
const [isDragging, setIsDragging] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const form = useForm<SampleFormValues>({ const form = useForm<SampleFormValues>({
resolver: zodResolver(sampleSchema), resolver: zodResolver(sampleSchema),
@@ -201,6 +203,13 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
if (isSystemRecording) { if (isSystemRecording) {
cancelSystemRecording(); cancelSystemRecording();
} }
// Stop and cleanup audio
if (audioRef.current) {
audioRef.current.pause();
URL.revokeObjectURL(audioRef.current.src);
audioRef.current = null;
}
setIsPlaying(false);
} }
onOpenChange(newOpen); onOpenChange(newOpen);
} }
@@ -213,6 +222,54 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
} }
// Reset file field by clearing the input // Reset file field by clearing the input
form.resetField('file'); form.resetField('file');
// Stop any playing audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setIsPlaying(false);
}
function handlePlayPause() {
const file = form.getValues('file');
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);
}
} }
return ( return (
@@ -235,16 +292,16 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`} className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
> >
<TabsTrigger value="upload" className="flex items-center gap-2"> <TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4 shrink-0" />
Upload Upload
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2"> <TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4 shrink-0" />
Record Record
</TabsTrigger> </TabsTrigger>
{isTauri() && isSystemAudioSupported && ( {isTauri() && isSystemAudioSupported && (
<TabsTrigger value="system" className="flex items-center gap-2"> <TabsTrigger value="system" className="flex items-center gap-2">
<Monitor className="h-4 w-4" /> <Monitor className="h-4 w-4 shrink-0" />
System Audio System Audio
</TabsTrigger> </TabsTrigger>
)} )}
@@ -254,40 +311,118 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
<FormField <FormField
control={form.control} control={form.control}
name="file" name="file"
render={({ field: { onChange, value, ...field } }) => ( render={({ field: { onChange, name } }) => (
<FormItem> <FormItem>
<FormLabel>Audio File</FormLabel> <FormLabel>Audio File</FormLabel>
<FormControl> <FormControl>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2">
<Input <input
type="file" type="file"
accept="audio/*" accept="audio/*"
name={name}
ref={fileInputRef}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
onChange(file); onChange(file);
} }
}} }}
{...field} className="hidden"
/> />
{selectedFile && ( <div
<Button role="button"
type="button" tabIndex={0}
variant="outline" onDragOver={(e) => {
onClick={handleTranscribe} e.preventDefault();
disabled={transcribe.isPending} setIsDragging(true);
className="flex items-center gap-2" }}
> onDragLeave={(e) => {
<Mic className="h-4 w-4" /> e.preventDefault();
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'} setIsDragging(false);
</Button> }}
)} 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 ? (
<>
<Button
type="button"
size="lg"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2"
>
<Upload className="h-5 w-5" />
Choose File
</Button>
<p className="text-sm text-muted-foreground text-center">
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
</p>
</>
) : (
<>
<div className="flex items-center gap-2">
<Upload className="h-5 w-5 text-primary" />
<span className="font-medium">File uploaded</span>
</div>
<p className="text-sm text-muted-foreground text-center">
File: {selectedFile.name}
</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={handlePlayPause}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
onChange(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
>
Remove
</Button>
</div>
</>
)}
</div>
</div> </div>
</FormControl> </FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
extract text from the audio.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -304,7 +439,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
<FormControl> <FormControl>
<div className="space-y-4"> <div className="space-y-4">
{!isRecording && !selectedFile && ( {!isRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button <Button
type="button" type="button"
onClick={startRecording} onClick={startRecording}
@@ -321,7 +456,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
)} )}
{isRecording && ( {isRecording && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" /> <div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
@@ -340,14 +475,13 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
Stop Recording Stop Recording
</Button> </Button>
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Recording in progress... ({formatAudioDuration(30 - duration)}{' '} {formatAudioDuration(30 - duration)} remaining
remaining)
</p> </p>
</div> </div>
)} )}
{selectedFile && !isRecording && ( {selectedFile && !isRecording && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" /> <Mic className="h-5 w-5 text-primary" />
<span className="font-medium">Recording complete</span> <span className="font-medium">Recording complete</span>
@@ -356,6 +490,14 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
File: {selectedFile.name} File: {selectedFile.name}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={handlePlayPause}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -379,9 +521,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
)} )}
</div> </div>
</FormControl> </FormControl>
<FormDescription>
Record audio directly from your microphone. Maximum duration is 30 seconds.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -399,7 +538,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
<FormControl> <FormControl>
<div className="space-y-4"> <div className="space-y-4">
{!isSystemRecording && !selectedFile && ( {!isSystemRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button <Button
type="button" type="button"
onClick={startSystemRecording} onClick={startSystemRecording}
@@ -416,7 +555,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
)} )}
{isSystemRecording && ( {isSystemRecording && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" /> <div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
@@ -435,14 +574,13 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
Stop Capture Stop Capture
</Button> </Button>
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Capturing system audio... ({formatAudioDuration(30 - systemDuration)}{' '} {formatAudioDuration(30 - systemDuration)} remaining
remaining)
</p> </p>
</div> </div>
)} )}
{selectedFile && !isSystemRecording && mode === 'system' && ( {selectedFile && !isSystemRecording && mode === 'system' && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5"> <div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" /> <Monitor className="h-5 w-5 text-primary" />
<span className="font-medium">Capture complete</span> <span className="font-medium">Capture complete</span>
@@ -451,6 +589,14 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
File: {selectedFile.name} File: {selectedFile.name}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={handlePlayPause}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -474,10 +620,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
)} )}
</div> </div>
</FormControl> </FormControl>
<FormDescription>
Capture audio from your system (applications, browser tabs, etc.). Works
natively without browser dialogs.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -499,9 +641,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription>
This should match exactly what is spoken in the audio file.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}