diff --git a/.gitignore b/.gitignore index 2cbd3e5c..05f7ef0d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ __pycache__/ venv/ env/ ENV/ - +*.prompt # Build outputs dist/ build/ diff --git a/app/src/components/VoiceProfiles/SampleUpload.tsx b/app/src/components/VoiceProfiles/SampleUpload.tsx index 90d2e068..859d2756 100644 --- a/app/src/components/VoiceProfiles/SampleUpload.tsx +++ b/app/src/components/VoiceProfiles/SampleUpload.tsx @@ -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({ 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 ( @@ -128,46 +176,154 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
- ( - - Audio File - -
- { - const file = e.target.files?.[0]; - if (file) { - onChange(file); - } - }} - {...field} - /> - {selectedFile && ( - - )} -
-
- - Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically extract text from the audio. - - -
- )} - /> + setMode(v as 'upload' | 'record')}> + + + + Upload + + + + Record + + + + + ( + + Audio File + +
+ { + const file = e.target.files?.[0]; + if (file) { + onChange(file); + } + }} + {...field} + /> + {selectedFile && ( + + )} +
+
+ + Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically extract text from the audio. + + +
+ )} + /> +
+ + + ( + + Record Audio + +
+ {!isRecording && !selectedFile && ( +
+ +

+ Click to start recording. Maximum duration: 30 seconds. +

+
+ )} + + {isRecording && ( +
+
+
+
+ + {formatAudioDuration(duration)} + +
+
+ +

+ Recording in progress... ({formatAudioDuration(30 - duration)} remaining) +

+
+ )} + + {selectedFile && !isRecording && ( +
+
+ + Recording complete +
+

+ File: {selectedFile.name} +

+
+ + +
+
+ )} +
+ + + Record audio directly from your microphone. Maximum duration is 30 seconds. + + + + )} + /> + + void; +} + +export function useAudioRecording({ + maxDurationSeconds = 30, + onRecordingComplete, +}: UseAudioRecordingOptions = {}) { + const [isRecording, setIsRecording] = useState(false); + const [duration, setDuration] = useState(0); + const [error, setError] = useState(null); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const timerRef = useRef(null); + const startTimeRef = useRef(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, + }; +} diff --git a/tauri/src-tauri/Info.plist b/tauri/src-tauri/Info.plist index f3ca2e7d..63df5b17 100644 --- a/tauri/src-tauri/Info.plist +++ b/tauri/src-tauri/Info.plist @@ -6,5 +6,7 @@ voicebox CFBundleIconName voicebox + NSMicrophoneUsageDescription + voicebox needs microphone access to record voice samples for voice cloning. diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json index 53ed5fd9..54ec70f6 100644 --- a/tauri/src-tauri/tauri.conf.json +++ b/tauri/src-tauri/tauri.conf.json @@ -47,7 +47,8 @@ "minHeight": 600, "resizable": true, "fullscreen": false, - "devtools": true + "devtools": true, + "userAgent": null } ], "withGlobalTauri": true