/** * AudioRecorder Component * * Records audio using MediaRecorder API and automatically uploads to server. * Handles browser permissions, recording state, and error scenarios. */ function AudioRecorder({ noteId, onRecordingComplete, onCancel }) { const [isRecording, setIsRecording] = React.useState(false); const [duration, setDuration] = React.useState(0); const [status, setStatus] = React.useState('Ready to record'); const [error, setError] = React.useState(null); const [isUploading, setIsUploading] = React.useState(false); const mediaRecorderRef = React.useRef(null); const audioChunksRef = React.useRef([]); const timerRef = React.useRef(null); const streamRef = React.useRef(null); // Cleanup on unmount React.useEffect(() => { return () => { if (timerRef.current) { clearInterval(timerRef.current); } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } }; }, []); // Check browser compatibility const checkBrowserSupport = () => { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setError('Audio recording not supported in this browser'); setStatus('Browser not supported'); return false; } if (typeof MediaRecorder === 'undefined') { setError('MediaRecorder API not available'); setStatus('Recording not available'); return false; } return true; }; const startRecording = async () => { if (!checkBrowserSupport()) { return; } try { setStatus('Requesting microphone access...'); setError(null); // Request microphone permission with optimal settings const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); streamRef.current = stream; // Determine best MIME type let mimeType = 'audio/webm'; if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { mimeType = 'audio/webm;codecs=opus'; } else if (MediaRecorder.isTypeSupported('audio/webm')) { mimeType = 'audio/webm'; } else if (MediaRecorder.isTypeSupported('audio/mp4')) { mimeType = 'audio/mp4'; } else if (MediaRecorder.isTypeSupported('audio/ogg')) { mimeType = 'audio/ogg'; } // Create MediaRecorder const mediaRecorder = new MediaRecorder(stream, { mimeType }); mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; // Handle data available event mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunksRef.current.push(event.data); } }; // Handle recording stop mediaRecorder.onstop = async () => { const audioBlob = new Blob(audioChunksRef.current, { type: mimeType }); await uploadRecording(audioBlob, mimeType); }; // Handle errors mediaRecorder.onerror = (event) => { console.error('[AudioRecorder] Recording error:', event); setError('Recording failed'); setStatus('Error occurred'); setIsRecording(false); }; // Start recording mediaRecorder.start(); setIsRecording(true); setStatus('Recording...'); setDuration(0); // Start timer timerRef.current = setInterval(() => { setDuration(prev => { const newDuration = prev + 1; // Auto-stop after 10 minutes (600 seconds) if (newDuration >= 600) { stopRecording(); setError('Maximum recording duration (10 min) reached'); } return newDuration; }); }, 1000); } catch (err) { console.error('[AudioRecorder] Start error:', err); // Handle specific permission errors if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { setError('Microphone permission denied. Please allow access in browser settings.'); setStatus('Permission denied'); } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { setError('No microphone found. Please connect a microphone.'); setStatus('No microphone'); } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') { setError('Microphone is being used by another application.'); setStatus('Microphone busy'); } else { setError(`Failed to start recording: ${err.message}`); setStatus('Error'); } setIsRecording(false); } }; const stopRecording = () => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); setStatus('Processing...'); // Stop timer if (timerRef.current) { clearInterval(timerRef.current); } // Stop all tracks if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } } }; const uploadRecording = async (audioBlob, mimeType) => { try { setIsUploading(true); setStatus('Uploading recording...'); // Create file from blob const fileExtension = mimeType.includes('webm') ? 'webm' : mimeType.includes('mp4') ? 'm4a' : 'ogg'; const filename = `recording_${Date.now()}.${fileExtension}`; const file = new File([audioBlob], filename, { type: mimeType }); // Upload to server const formData = new FormData(); formData.append('file', file); formData.append('position', 0); const token = localStorage.getItem('token'); const response = await fetch(`${API_URL}/notes/${noteId}/content/audio`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); if (response.ok) { const data = await response.json(); setStatus('Upload complete!'); // Call success callback if (onRecordingComplete) { onRecordingComplete(data); } } else { const errorData = await response.json(); throw new Error(errorData.detail || 'Upload failed'); } } catch (uploadError) { console.error('[AudioRecorder] Upload error:', uploadError); setError(`Upload failed: ${uploadError.message}`); setStatus('Upload failed'); } finally { setIsUploading(false); } }; const formatDuration = (seconds) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; }; // Component styles const styles = { container: { background: 'var(--card-bg)', borderRadius: '12px', padding: '2rem', width: '100%' }, header: { fontSize: '1.25rem', fontWeight: '600', marginBottom: '1.5rem', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }, statusContainer: { textAlign: 'center', marginBottom: '1.5rem' }, status: { fontSize: '0.95rem', color: 'var(--text-secondary)', marginBottom: '0.5rem' }, duration: { fontSize: '2rem', fontWeight: '700', color: isRecording ? 'var(--danger)' : 'var(--text-primary)', fontFamily: 'monospace' }, recordingIndicator: { display: 'inline-block', width: '12px', height: '12px', borderRadius: '50%', background: 'var(--danger)', marginLeft: '0.5rem', animation: 'pulse 1.5s ease-in-out infinite' }, error: { background: 'rgba(239, 68, 68, 0.1)', border: '1px solid var(--danger)', borderRadius: '8px', padding: '1rem', marginBottom: '1rem', color: 'var(--danger)', fontSize: '0.875rem', textAlign: 'center' }, controls: { display: 'flex', gap: '1rem', justifyContent: 'center' }, button: { padding: '0.75rem 1.5rem', border: 'none', borderRadius: '8px', fontSize: '1rem', cursor: 'pointer', fontWeight: '600', transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: '0.5rem' }, primaryButton: { background: isRecording ? 'var(--danger)' : 'var(--primary)', color: 'white' }, secondaryButton: { background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' } }; return (