mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
Add audio sample components for recording, system capture, and upload
- Introduced `AudioSampleRecording`, `AudioSampleSystem`, and `AudioSampleUpload` components to handle audio recording, system audio capture, and file uploads respectively. - Implemented play/pause functionality for audio playback across all components, enhancing user interaction. - Refactored `ProfileForm` and `SampleUpload` to utilize new audio components, improving code organization and maintainability. - Added hooks for audio playback management, ensuring consistent audio handling and cleanup across the application.
This commit is contained in:
@@ -0,0 +1,122 @@
|
|||||||
|
import { Mic, Square, Play, Pause } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
|
||||||
|
interface AudioSampleRecordingProps {
|
||||||
|
file: File | null | undefined;
|
||||||
|
isRecording: boolean;
|
||||||
|
duration: number;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onTranscribe: () => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isTranscribing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioSampleRecording({
|
||||||
|
file,
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onCancel,
|
||||||
|
onTranscribe,
|
||||||
|
onPlayPause,
|
||||||
|
isPlaying,
|
||||||
|
isTranscribing = false,
|
||||||
|
}: AudioSampleRecordingProps) {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Record Audio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isRecording && !file && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onStart}
|
||||||
|
size="lg"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-5 w-5" />
|
||||||
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Click to start recording. Maximum duration: 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<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-2">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||||
|
<span className="text-lg font-mono font-semibold">
|
||||||
|
{formatAudioDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onStop}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop Recording
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{formatAudioDuration(30 - duration)} remaining
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && !isRecording && (
|
||||||
|
<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">
|
||||||
|
<Mic className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-medium">Recording complete</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
File: {file.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onTranscribe}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Record Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Monitor, Square, Play, Pause, Mic } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
|
||||||
|
interface AudioSampleSystemProps {
|
||||||
|
file: File | null | undefined;
|
||||||
|
isRecording: boolean;
|
||||||
|
duration: number;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onTranscribe: () => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isTranscribing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioSampleSystem({
|
||||||
|
file,
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onCancel,
|
||||||
|
onTranscribe,
|
||||||
|
onPlayPause,
|
||||||
|
isPlaying,
|
||||||
|
isTranscribing = false,
|
||||||
|
}: AudioSampleSystemProps) {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Capture System Audio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isRecording && !file && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onStart}
|
||||||
|
size="lg"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Monitor className="h-5 w-5" />
|
||||||
|
Start Capture
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Capture audio from your system. Maximum duration: 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<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-2">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||||
|
<span className="text-lg font-mono font-semibold">
|
||||||
|
{formatAudioDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onStop}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop Capture
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{formatAudioDuration(30 - duration)} remaining
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && !isRecording && (
|
||||||
|
<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">
|
||||||
|
<Monitor className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-medium">Capture complete</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
File: {file.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onTranscribe}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Capture Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
|
||||||
|
interface AudioSampleUploadProps {
|
||||||
|
file: File | null | undefined;
|
||||||
|
onFileChange: (file: File | undefined) => void;
|
||||||
|
onTranscribe: () => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isValidating?: boolean;
|
||||||
|
isTranscribing?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
fieldName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioSampleUpload({
|
||||||
|
file,
|
||||||
|
onFileChange,
|
||||||
|
onTranscribe,
|
||||||
|
onPlayPause,
|
||||||
|
isPlaying,
|
||||||
|
isValidating = false,
|
||||||
|
isTranscribing = false,
|
||||||
|
isDisabled = false,
|
||||||
|
fieldName,
|
||||||
|
}: AudioSampleUploadProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Audio File</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
name={fieldName}
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
onFileChange(selectedFile);
|
||||||
|
} else {
|
||||||
|
onFileChange(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile?.type.startsWith('audio/')) {
|
||||||
|
onFileChange(droppedFile);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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] ${
|
||||||
|
file
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: isDragging
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!file ? (
|
||||||
|
<>
|
||||||
|
<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: {file.name}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onTranscribe}
|
||||||
|
disabled={isTranscribing || isValidating || isDisabled}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
onFileChange(undefined);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||||
|
import { useEffect, useState } 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';
|
||||||
@@ -26,23 +27,26 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
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 { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
import {
|
import {
|
||||||
|
useAddSample,
|
||||||
useCreateProfile,
|
useCreateProfile,
|
||||||
useProfile,
|
useProfile,
|
||||||
useUpdateProfile,
|
useUpdateProfile,
|
||||||
useAddSample,
|
|
||||||
} from '@/lib/hooks/useProfiles';
|
} from '@/lib/hooks/useProfiles';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { Mic, Square, Upload, Monitor, Play, Pause } from 'lucide-react';
|
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
|
|
||||||
// Helper function to get audio duration from File
|
// Helper function to get audio duration from File
|
||||||
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
|
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
|
||||||
@@ -116,10 +120,7 @@ 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, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||||
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>({
|
||||||
@@ -155,8 +156,9 @@ export function ProfileForm() {
|
|||||||
console.error('Failed to get audio duration:', error);
|
console.error('Failed to get audio duration:', error);
|
||||||
setAudioDuration(null);
|
setAudioDuration(null);
|
||||||
// For recordings, we auto-stop at max duration, so we can skip validation errors
|
// For recordings, we auto-stop at max duration, so we can skip validation errors
|
||||||
const isRecordedFile = selectedFile.name.startsWith('recording-') ||
|
const isRecordedFile =
|
||||||
selectedFile.name.startsWith('system-audio-');
|
selectedFile.name.startsWith('recording-') ||
|
||||||
|
selectedFile.name.startsWith('system-audio-');
|
||||||
if (!isRecordedFile) {
|
if (!isRecordedFile) {
|
||||||
form.setError('sampleFile', {
|
form.setError('sampleFile', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
@@ -286,11 +288,6 @@ export function ProfileForm() {
|
|||||||
const result = await transcribe.mutateAsync({ file, language });
|
const result = await transcribe.mutateAsync({ file, language });
|
||||||
|
|
||||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Transcription complete',
|
|
||||||
description: 'Audio has been transcribed successfully.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Transcription failed',
|
title: 'Transcription failed',
|
||||||
@@ -307,54 +304,12 @@ export function ProfileForm() {
|
|||||||
cancelSystemRecording();
|
cancelSystemRecording();
|
||||||
}
|
}
|
||||||
form.resetField('sampleFile');
|
form.resetField('sampleFile');
|
||||||
// Stop any playing audio
|
cleanupAudio();
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current = null;
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlayPause() {
|
function handlePlayPause() {
|
||||||
const file = form.getValues('sampleFile');
|
const file = form.getValues('sampleFile');
|
||||||
if (!file) return;
|
playPause(file);
|
||||||
|
|
||||||
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) {
|
||||||
@@ -483,13 +438,7 @@ export function ProfileForm() {
|
|||||||
if (isSystemRecording) {
|
if (isSystemRecording) {
|
||||||
cancelSystemRecording();
|
cancelSystemRecording();
|
||||||
}
|
}
|
||||||
// Stop and cleanup audio
|
cleanupAudio();
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
URL.revokeObjectURL(audioRef.current.src);
|
|
||||||
audioRef.current = null;
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +537,9 @@ export function ProfileForm() {
|
|||||||
setSampleMode(newMode);
|
setSampleMode(newMode);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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 shrink-0" />
|
<Upload className="h-4 w-4 shrink-0" />
|
||||||
Upload
|
Upload
|
||||||
@@ -610,122 +561,19 @@ export function ProfileForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="sampleFile"
|
name="sampleFile"
|
||||||
render={({ field: { onChange, name } }) => (
|
render={({ field: { onChange, name } }) => (
|
||||||
<FormItem>
|
<AudioSampleUpload
|
||||||
<FormLabel>Audio File</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
onFileChange={onChange}
|
||||||
<div className="flex flex-col gap-2">
|
onTranscribe={handleTranscribe}
|
||||||
<input
|
onPlayPause={handlePlayPause}
|
||||||
type="file"
|
isPlaying={isPlaying}
|
||||||
accept="audio/*"
|
isValidating={isValidatingAudio}
|
||||||
name={name}
|
isTranscribing={transcribe.isPending}
|
||||||
ref={fileInputRef}
|
isDisabled={
|
||||||
onChange={(e) => {
|
audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||||
const file = e.target.files?.[0];
|
}
|
||||||
if (file) {
|
fieldName={name}
|
||||||
onChange(file);
|
/>
|
||||||
} else {
|
|
||||||
onChange(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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}
|
|
||||||
disabled={isValidatingAudio}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -735,95 +583,18 @@ export function ProfileForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="sampleFile"
|
name="sampleFile"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<AudioSampleRecording
|
||||||
<FormLabel>Record Audio</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
isRecording={isRecording}
|
||||||
<div className="space-y-4">
|
duration={duration}
|
||||||
{!isRecording && !selectedFile && (
|
onStart={startRecording}
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
onStop={stopRecording}
|
||||||
<Button
|
onCancel={handleCancelRecording}
|
||||||
type="button"
|
onTranscribe={handleTranscribe}
|
||||||
onClick={startRecording}
|
onPlayPause={handlePlayPause}
|
||||||
size="lg"
|
isPlaying={isPlaying}
|
||||||
className="flex items-center gap-2"
|
isTranscribing={transcribe.isPending}
|
||||||
>
|
/>
|
||||||
<Mic className="h-5 w-5" />
|
|
||||||
Start Recording
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Click to start recording. Maximum duration: 30 seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRecording && (
|
|
||||||
<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-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
|
||||||
<span className="text-lg font-mono font-semibold">
|
|
||||||
{formatAudioDuration(duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={stopRecording}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
Stop Recording
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
{formatAudioDuration(30 - duration)} remaining
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFile && !isRecording && (
|
|
||||||
<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">
|
|
||||||
<Mic className="h-5 w-5 text-primary" />
|
|
||||||
<span className="font-medium">Recording complete</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={handleCancelRecording}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Record Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -834,95 +605,18 @@ export function ProfileForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="sampleFile"
|
name="sampleFile"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<AudioSampleSystem
|
||||||
<FormLabel>Capture System Audio</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
isRecording={isSystemRecording}
|
||||||
<div className="space-y-4">
|
duration={systemDuration}
|
||||||
{!isSystemRecording && !selectedFile && (
|
onStart={startSystemRecording}
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
onStop={stopSystemRecording}
|
||||||
<Button
|
onCancel={handleCancelRecording}
|
||||||
type="button"
|
onTranscribe={handleTranscribe}
|
||||||
onClick={startSystemRecording}
|
onPlayPause={handlePlayPause}
|
||||||
size="lg"
|
isPlaying={isPlaying}
|
||||||
className="flex items-center gap-2"
|
isTranscribing={transcribe.isPending}
|
||||||
>
|
/>
|
||||||
<Monitor className="h-5 w-5" />
|
|
||||||
Start Capture
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Capture audio from your system. Maximum duration: 30 seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSystemRecording && (
|
|
||||||
<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-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
|
||||||
<span className="text-lg font-mono font-semibold">
|
|
||||||
{formatAudioDuration(systemDuration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={stopSystemRecording}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
Stop Capture
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
{formatAudioDuration(30 - systemDuration)} remaining
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFile && !isSystemRecording && (
|
|
||||||
<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">
|
|
||||||
<Monitor className="h-5 w-5 text-primary" />
|
|
||||||
<span className="font-medium">Capture complete</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={handleCancelRecording}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Capture Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
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 {
|
||||||
@@ -18,16 +19,18 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
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 { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
|
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { Mic, Square, Upload, Monitor, Play, Pause } from 'lucide-react';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
|
|
||||||
const sampleSchema = z.object({
|
const sampleSchema = z.object({
|
||||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||||
@@ -51,10 +54,7 @@ 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, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||||
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),
|
||||||
@@ -156,11 +156,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
const result = await transcribe.mutateAsync({ file, language });
|
const result = await transcribe.mutateAsync({ file, language });
|
||||||
|
|
||||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Transcription complete',
|
|
||||||
description: 'Audio has been transcribed successfully.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Transcription failed',
|
title: 'Transcription failed',
|
||||||
@@ -203,13 +198,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
if (isSystemRecording) {
|
if (isSystemRecording) {
|
||||||
cancelSystemRecording();
|
cancelSystemRecording();
|
||||||
}
|
}
|
||||||
// Stop and cleanup audio
|
cleanupAudio();
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
URL.revokeObjectURL(audioRef.current.src);
|
|
||||||
audioRef.current = null;
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
}
|
}
|
||||||
@@ -220,56 +209,13 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
} else if (mode === 'system') {
|
} else if (mode === 'system') {
|
||||||
cancelSystemRecording();
|
cancelSystemRecording();
|
||||||
}
|
}
|
||||||
// Reset file field by clearing the input
|
|
||||||
form.resetField('file');
|
form.resetField('file');
|
||||||
// Stop any playing audio
|
cleanupAudio();
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current = null;
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlayPause() {
|
function handlePlayPause() {
|
||||||
const file = form.getValues('file');
|
const file = form.getValues('file');
|
||||||
if (!file) return;
|
playPause(file);
|
||||||
|
|
||||||
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 (
|
||||||
@@ -284,10 +230,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Tabs
|
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||||
value={mode}
|
|
||||||
onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}
|
|
||||||
>
|
|
||||||
<TabsList
|
<TabsList
|
||||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
>
|
>
|
||||||
@@ -312,119 +255,15 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
render={({ field: { onChange, name } }) => (
|
render={({ field: { onChange, name } }) => (
|
||||||
<FormItem>
|
<AudioSampleUpload
|
||||||
<FormLabel>Audio File</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
onFileChange={onChange}
|
||||||
<div className="flex flex-col gap-2">
|
onTranscribe={handleTranscribe}
|
||||||
<input
|
onPlayPause={handlePlayPause}
|
||||||
type="file"
|
isPlaying={isPlaying}
|
||||||
accept="audio/*"
|
isTranscribing={transcribe.isPending}
|
||||||
name={name}
|
fieldName={name}
|
||||||
ref={fileInputRef}
|
/>
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
onChange(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -434,95 +273,18 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<AudioSampleRecording
|
||||||
<FormLabel>Record Audio</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
isRecording={isRecording}
|
||||||
<div className="space-y-4">
|
duration={duration}
|
||||||
{!isRecording && !selectedFile && (
|
onStart={startRecording}
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
onStop={stopRecording}
|
||||||
<Button
|
onCancel={handleCancelRecording}
|
||||||
type="button"
|
onTranscribe={handleTranscribe}
|
||||||
onClick={startRecording}
|
onPlayPause={handlePlayPause}
|
||||||
size="lg"
|
isPlaying={isPlaying}
|
||||||
className="flex items-center gap-2"
|
isTranscribing={transcribe.isPending}
|
||||||
>
|
/>
|
||||||
<Mic className="h-5 w-5" />
|
|
||||||
Start Recording
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Click to start recording. Maximum duration: 30 seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRecording && (
|
|
||||||
<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-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
|
||||||
<span className="text-lg font-mono font-semibold">
|
|
||||||
{formatAudioDuration(duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={stopRecording}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
Stop Recording
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
{formatAudioDuration(30 - duration)} remaining
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFile && !isRecording && (
|
|
||||||
<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">
|
|
||||||
<Mic className="h-5 w-5 text-primary" />
|
|
||||||
<span className="font-medium">Recording complete</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
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={handleCancelRecording}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Record Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -533,95 +295,18 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<AudioSampleSystem
|
||||||
<FormLabel>Capture System Audio</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
isRecording={isSystemRecording}
|
||||||
<div className="space-y-4">
|
duration={systemDuration}
|
||||||
{!isSystemRecording && !selectedFile && (
|
onStart={startSystemRecording}
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
onStop={stopSystemRecording}
|
||||||
<Button
|
onCancel={handleCancelRecording}
|
||||||
type="button"
|
onTranscribe={handleTranscribe}
|
||||||
onClick={startSystemRecording}
|
onPlayPause={handlePlayPause}
|
||||||
size="lg"
|
isPlaying={isPlaying}
|
||||||
className="flex items-center gap-2"
|
isTranscribing={transcribe.isPending}
|
||||||
>
|
/>
|
||||||
<Monitor className="h-5 w-5" />
|
|
||||||
Start Capture
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Capture audio from your system. Maximum duration: 30 seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSystemRecording && (
|
|
||||||
<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-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
|
||||||
<span className="text-lg font-mono font-semibold">
|
|
||||||
{formatAudioDuration(systemDuration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={stopSystemRecording}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
Stop Capture
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
{formatAudioDuration(30 - systemDuration)} remaining
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFile && !isSystemRecording && mode === 'system' && (
|
|
||||||
<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">
|
|
||||||
<Monitor className="h-5 w-5 text-primary" />
|
|
||||||
<span className="font-medium">Capture complete</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
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={handleCancelRecording}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Capture Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
export function useAudioPlayer() {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const playPause = (file: File | null | undefined) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
if (audioRef.current.src.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(audioRef.current.src);
|
||||||
|
}
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying,
|
||||||
|
playPause,
|
||||||
|
cleanup,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user