Add audio recording feature to SampleUpload component

- Introduced functionality to record audio directly from the microphone, allowing users to switch between uploading files and recording.
- Implemented state management for recording status and duration, with user feedback through toast notifications.
- Updated Info.plist to include microphone usage description for better user transparency.
- Enhanced the UI with tabs for selecting between upload and record modes, improving user experience.
This commit is contained in:
Jamie Pine
2026-01-25 12:47:07 -08:00
parent c876fabcd4
commit 6fa2966371
5 changed files with 391 additions and 43 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ __pycache__/
venv/
env/
ENV/
*.prompt
# Build outputs
dist/
build/
+197 -41
View File
@@ -1,5 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useState, useEffect } from 'react';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
@@ -20,10 +21,13 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/use-toast';
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { Mic } from 'lucide-react';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { Mic, Square, Upload } from 'lucide-react';
import { formatAudioDuration } from '@/lib/utils/audio';
const sampleSchema = z.object({
file: z.instanceof(File, { message: 'Please select an audio file' }),
@@ -46,6 +50,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
const transcribe = useTranscription();
const { data: profile } = useProfile(profileId);
const { toast } = useToast();
const [mode, setMode] = useState<'upload' | 'record'>('upload');
const form = useForm<SampleFormValues>({
resolver: zodResolver(sampleSchema),
@@ -56,6 +61,39 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
const selectedFile = form.watch('file');
const {
isRecording,
duration,
error: recordingError,
startRecording,
stopRecording,
cancelRecording,
} = useAudioRecording({
maxDurationSeconds: 30,
onRecordingComplete: (blob) => {
// Convert blob to File object
const file = new File([blob], `recording-${Date.now()}.webm`, {
type: blob.type || 'audio/webm',
});
form.setValue('file', file, { shouldValidate: true });
toast({
title: 'Recording complete',
description: 'Audio has been recorded successfully.',
});
},
});
// Show recording errors
useEffect(() => {
if (recordingError) {
toast({
title: 'Recording error',
description: recordingError,
variant: 'destructive',
});
}
}, [recordingError, toast]);
async function handleTranscribe() {
const file = form.getValues('file');
if (!file) {
@@ -112,10 +150,20 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
form.reset();
setMode('upload');
if (isRecording) {
cancelRecording();
}
}
onOpenChange(newOpen);
}
function handleCancelRecording() {
cancelRecording();
// Reset file field by clearing the input
form.resetField('file');
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
@@ -128,46 +176,154 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel>Audio File</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="file"
accept="audio/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
{...field}
/>
{selectedFile && (
<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>
)}
</div>
</FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically extract text from the audio.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4" />
Record
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel>Audio File</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="file"
accept="audio/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
{...field}
/>
{selectedFile && (
<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>
)}
</div>
</FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically extract text from the audio.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormLabel>Record Audio</FormLabel>
<FormControl>
<div className="space-y-4">
{!isRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
<Button
type="button"
onClick={startRecording}
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 gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5">
<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">
Recording in progress... ({formatAudioDuration(30 - duration)} remaining)
</p>
</div>
)}
{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 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"
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>
<FormDescription>
Record audio directly from your microphone. Maximum duration is 30 seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<FormField
control={form.control}
+189
View File
@@ -0,0 +1,189 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { isTauri } from '@/lib/tauri';
interface UseAudioRecordingOptions {
maxDurationSeconds?: number;
onRecordingComplete?: (blob: Blob) => void;
}
export function useAudioRecording({
maxDurationSeconds = 30,
onRecordingComplete,
}: UseAudioRecordingOptions = {}) {
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
setDuration(0);
// Check if getUserMedia is available
// In Tauri, navigator.mediaDevices might not be available immediately
if (typeof navigator === 'undefined') {
const errorMsg = 'Navigator API is not available. This might be a Tauri configuration issue.';
setError(errorMsg);
throw new Error(errorMsg);
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// Try waiting a bit for Tauri webview to initialize
await new Promise((resolve) => setTimeout(resolve, 100));
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const isTauriEnv = isTauri();
console.error('MediaDevices check:', {
hasNavigator: typeof navigator !== 'undefined',
hasMediaDevices: !!navigator?.mediaDevices,
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
isTauri: isTauriEnv,
});
const errorMsg = isTauriEnv
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
setError(errorMsg);
throw new Error(errorMsg);
}
}
// Request microphone access
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
streamRef.current = stream;
// Create MediaRecorder with preferred MIME type
const options: MediaRecorderOptions = {
mimeType: 'audio/webm;codecs=opus',
};
// Fallback to default if webm not supported
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
delete options.mimeType;
}
const mediaRecorder = new MediaRecorder(stream, options);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
onRecordingComplete?.(blob);
// Stop all tracks
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
};
mediaRecorder.onerror = (event) => {
setError('Recording error occurred');
console.error('MediaRecorder error:', event);
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setIsRecording(true);
startTimeRef.current = Date.now();
// Start timer
timerRef.current = window.setInterval(() => {
if (startTimeRef.current) {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
// Auto-stop at max duration
if (elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
}
}
}, 100);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to access microphone. Please check permissions.';
setError(errorMessage);
setIsRecording(false);
}
}, [maxDurationSeconds, onRecordingComplete]);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
}, [isRecording]);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current) {
mediaRecorderRef.current.stop();
setIsRecording(false);
chunksRef.current = [];
setDuration(0);
}
// Stop all tracks
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
}
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
};
}, []);
return {
isRecording,
duration,
error,
startRecording,
stopRecording,
cancelRecording,
};
}
+2
View File
@@ -6,5 +6,7 @@
<string>voicebox</string>
<key>CFBundleIconName</key>
<string>voicebox</string>
<key>NSMicrophoneUsageDescription</key>
<string>voicebox needs microphone access to record voice samples for voice cloning.</string>
</dict>
</plist>
+2 -1
View File
@@ -47,7 +47,8 @@
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"devtools": true
"devtools": true,
"userAgent": null
}
],
"withGlobalTauri": true