mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
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:
+1
-1
@@ -9,7 +9,7 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
ENV/
|
ENV/
|
||||||
|
*.prompt
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -1,5 +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 * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -20,10 +21,13 @@ import {
|
|||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
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({
|
const sampleSchema = z.object({
|
||||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
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 transcribe = useTranscription();
|
||||||
const { data: profile } = useProfile(profileId);
|
const { data: profile } = useProfile(profileId);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [mode, setMode] = useState<'upload' | 'record'>('upload');
|
||||||
|
|
||||||
const form = useForm<SampleFormValues>({
|
const form = useForm<SampleFormValues>({
|
||||||
resolver: zodResolver(sampleSchema),
|
resolver: zodResolver(sampleSchema),
|
||||||
@@ -56,6 +61,39 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
|
|
||||||
const selectedFile = form.watch('file');
|
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() {
|
async function handleTranscribe() {
|
||||||
const file = form.getValues('file');
|
const file = form.getValues('file');
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -112,10 +150,20 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
function handleOpenChange(newOpen: boolean) {
|
function handleOpenChange(newOpen: boolean) {
|
||||||
if (!newOpen) {
|
if (!newOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setMode('upload');
|
||||||
|
if (isRecording) {
|
||||||
|
cancelRecording();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCancelRecording() {
|
||||||
|
cancelRecording();
|
||||||
|
// Reset file field by clearing the input
|
||||||
|
form.resetField('file');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -128,46 +176,154 @@ 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">
|
||||||
<FormField
|
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
|
||||||
control={form.control}
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
name="file"
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
render={({ field: { onChange, value, ...field } }) => (
|
<Upload className="h-4 w-4" />
|
||||||
<FormItem>
|
Upload
|
||||||
<FormLabel>Audio File</FormLabel>
|
</TabsTrigger>
|
||||||
<FormControl>
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Mic className="h-4 w-4" />
|
||||||
<Input
|
Record
|
||||||
type="file"
|
</TabsTrigger>
|
||||||
accept="audio/*"
|
</TabsList>
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
<TabsContent value="upload" className="space-y-4">
|
||||||
if (file) {
|
<FormField
|
||||||
onChange(file);
|
control={form.control}
|
||||||
}
|
name="file"
|
||||||
}}
|
render={({ field: { onChange, value, ...field } }) => (
|
||||||
{...field}
|
<FormItem>
|
||||||
/>
|
<FormLabel>Audio File</FormLabel>
|
||||||
{selectedFile && (
|
<FormControl>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<Input
|
||||||
variant="outline"
|
type="file"
|
||||||
onClick={handleTranscribe}
|
accept="audio/*"
|
||||||
disabled={transcribe.isPending}
|
onChange={(e) => {
|
||||||
className="flex items-center gap-2"
|
const file = e.target.files?.[0];
|
||||||
>
|
if (file) {
|
||||||
<Mic className="h-4 w-4" />
|
onChange(file);
|
||||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
}
|
||||||
</Button>
|
}}
|
||||||
)}
|
{...field}
|
||||||
</div>
|
/>
|
||||||
</FormControl>
|
{selectedFile && (
|
||||||
<FormDescription>
|
<Button
|
||||||
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically extract text from the audio.
|
type="button"
|
||||||
</FormDescription>
|
variant="outline"
|
||||||
<FormMessage />
|
onClick={handleTranscribe}
|
||||||
</FormItem>
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,5 +6,7 @@
|
|||||||
<string>voicebox</string>
|
<string>voicebox</string>
|
||||||
<key>CFBundleIconName</key>
|
<key>CFBundleIconName</key>
|
||||||
<string>voicebox</string>
|
<string>voicebox</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>voicebox needs microphone access to record voice samples for voice cloning.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -47,7 +47,8 @@
|
|||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"devtools": true
|
"devtools": true,
|
||||||
|
"userAgent": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"withGlobalTauri": true
|
"withGlobalTauri": true
|
||||||
|
|||||||
Reference in New Issue
Block a user