mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user