Spaces:
Running
on
A100
Running
on
A100
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { useTranscriptionStore } from '../stores/transcriptionStore'; | |
| import { useAudioAnalyzer } from '../hooks/useAudioAnalyzer'; | |
| interface MediaRecorderProps { | |
| onComplete: () => void; | |
| onCancel: () => void; | |
| } | |
| const MediaRecorder: React.FC<MediaRecorderProps> = ({ onComplete, onCancel }) => { | |
| const { recordingType, setRecordedBlob } = useTranscriptionStore(); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [recordingTime, setRecordingTime] = useState(0); | |
| const [stream, setStream] = useState<MediaStream | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [permissionState, setPermissionState] = useState<'prompt' | 'granted' | 'denied'>('prompt'); | |
| const [currentMicrophone, setCurrentMicrophone] = useState<string | null>(null); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const videoRef = useRef<HTMLVideoElement>(null); | |
| const chunksRef = useRef<Blob[]>([]); | |
| const timerRef = useRef<number | null>(null); | |
| const isVideo = recordingType === 'video'; | |
| // Audio analyzer for real-time waveform | |
| const { audioData, connectToStream, disconnect } = useAudioAnalyzer(256); | |
| // Get microphone device info | |
| const getMicrophoneInfo = async (mediaStream: MediaStream) => { | |
| try { | |
| // Get all available audio input devices | |
| const devices = await navigator.mediaDevices.enumerateDevices(); | |
| const audioInputDevices = devices.filter(device => device.kind === 'audioinput'); | |
| // Get the audio track from the current stream | |
| const audioTrack = mediaStream.getAudioTracks()[0]; | |
| if (audioTrack) { | |
| // Get the device settings | |
| const settings = audioTrack.getSettings(); | |
| const deviceId = settings.deviceId; | |
| // Find the matching device in our list | |
| const currentDevice = audioInputDevices.find(device => device.deviceId === deviceId); | |
| if (currentDevice && currentDevice.label) { | |
| setCurrentMicrophone(currentDevice.label); | |
| } else { | |
| // Fallback to device ID if label is not available | |
| setCurrentMicrophone(`Microphone (${deviceId?.substring(0, 8)}...)`); | |
| } | |
| } | |
| } catch (err) { | |
| console.error('Error getting microphone info:', err); | |
| setCurrentMicrophone('Unknown microphone'); | |
| } | |
| }; | |
| // Request permissions and setup media stream | |
| const requestPermissions = async () => { | |
| try { | |
| setError(null); | |
| const constraints: MediaStreamConstraints = { | |
| audio: { | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true, | |
| }, | |
| video: isVideo ? { | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 }, | |
| facingMode: 'user' | |
| } : false | |
| }; | |
| const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); | |
| setStream(mediaStream); | |
| setPermissionState('granted'); | |
| // Get microphone device information | |
| await getMicrophoneInfo(mediaStream); | |
| // Show video preview if recording video | |
| if (isVideo && videoRef.current) { | |
| videoRef.current.srcObject = mediaStream; | |
| videoRef.current.play(); | |
| } | |
| // Connect audio analyzer for waveform visualization | |
| connectToStream(mediaStream); | |
| } catch (err) { | |
| console.error('Error accessing media devices:', err); | |
| setPermissionState('denied'); | |
| if (err instanceof DOMException) { | |
| switch (err.name) { | |
| case 'NotAllowedError': | |
| setError('Permission denied. Please allow access to your microphone' + (isVideo ? ' and camera' : '') + '.'); | |
| break; | |
| case 'NotFoundError': | |
| setError('No ' + (isVideo ? 'camera or ' : '') + 'microphone found.'); | |
| break; | |
| case 'NotReadableError': | |
| setError('Media device is already in use by another application.'); | |
| break; | |
| default: | |
| setError('Failed to access media devices: ' + err.message); | |
| } | |
| } else { | |
| setError('An unexpected error occurred while accessing media devices.'); | |
| } | |
| } | |
| }; | |
| // Start recording | |
| const startRecording = () => { | |
| if (!stream) return; | |
| try { | |
| chunksRef.current = []; | |
| // Try different MIME types in order of preference | |
| const mimeTypes = isVideo | |
| ? ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'] | |
| : ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', '']; | |
| let selectedMimeType = ''; | |
| for (const mimeType of mimeTypes) { | |
| if (mimeType === '' || window.MediaRecorder.isTypeSupported(mimeType)) { | |
| selectedMimeType = mimeType; | |
| break; | |
| } | |
| } | |
| const options: MediaRecorderOptions = selectedMimeType ? { mimeType: selectedMimeType } : {}; | |
| const mediaRecorder = new window.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: isVideo ? 'video/webm' : 'audio/webm' | |
| }); | |
| setRecordedBlob(blob); | |
| onComplete(); | |
| }; | |
| mediaRecorder.start(); | |
| setIsRecording(true); | |
| setRecordingTime(0); | |
| // Start timer | |
| timerRef.current = setInterval(() => { | |
| setRecordingTime(prev => prev + 1); | |
| }, 1000); | |
| } catch (err) { | |
| console.error('Error starting recording:', err); | |
| setError('Failed to start recording: ' + (err instanceof Error ? err.message : 'Unknown error')); | |
| } | |
| }; | |
| // Stop recording | |
| const stopRecording = () => { | |
| if (mediaRecorderRef.current && isRecording) { | |
| mediaRecorderRef.current.stop(); | |
| setIsRecording(false); | |
| if (timerRef.current) { | |
| clearInterval(timerRef.current); | |
| timerRef.current = null; | |
| } | |
| } | |
| }; | |
| // Format recording time | |
| const formatTime = (seconds: number) => { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = seconds % 60; | |
| return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; | |
| }; | |
| // Cleanup on unmount and when recording stops | |
| useEffect(() => { | |
| return () => { | |
| if (stream) { | |
| stream.getTracks().forEach(track => track.stop()); | |
| } | |
| if (timerRef.current) { | |
| clearInterval(timerRef.current); | |
| } | |
| }; | |
| }, [stream]); | |
| // Cleanup stream when recording stops externally | |
| useEffect(() => { | |
| if (!recordingType && stream) { | |
| stream.getTracks().forEach(track => track.stop()); | |
| setStream(null); | |
| setCurrentMicrophone(null); // Clear microphone info | |
| disconnect(); // Also disconnect audio analyzer | |
| } | |
| }, [recordingType, stream, disconnect]); | |
| // Auto-request permissions when component mounts | |
| useEffect(() => { | |
| if (permissionState === 'prompt') { | |
| requestPermissions(); | |
| } | |
| }, []); | |
| return ( | |
| <div className="flex flex-col items-center justify-center min-h-[400px] bg-gray-900 rounded-lg border-2 border-dashed border-gray-600 p-8"> | |
| {/* Header */} | |
| <div className="mb-6 text-center"> | |
| <h3 className="text-xl font-semibold text-white mb-2"> | |
| Record {isVideo ? 'Video' : 'Audio'} | |
| </h3> | |
| <p className="text-gray-400 text-sm"> | |
| {permissionState === 'prompt' && 'Requesting permissions...'} | |
| {permissionState === 'denied' && 'Permission required to record'} | |
| {permissionState === 'granted' && !isRecording && 'Ready to record'} | |
| {isRecording && `Recording... ${formatTime(recordingTime)}`} | |
| </p> | |
| {/* Microphone Device Info */} | |
| {permissionState === 'granted' && currentMicrophone && ( | |
| <div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-300"> | |
| <svg className="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /> | |
| </svg> | |
| <span className="truncate max-w-xs" title={currentMicrophone}> | |
| {currentMicrophone} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Video Preview (only for video recording) */} | |
| {isVideo && permissionState === 'granted' && ( | |
| <div className="mb-6"> | |
| <video | |
| ref={videoRef} | |
| className="w-80 h-60 bg-black rounded-lg object-cover" | |
| muted | |
| playsInline | |
| /> | |
| </div> | |
| )} | |
| {/* Audio Visualization */} | |
| {permissionState === 'granted' && ( | |
| <div className="mb-6 flex items-center justify-center"> | |
| <div className="w-80 h-20 bg-gray-800 rounded-lg flex items-center justify-center"> | |
| <div className="flex items-center space-x-1"> | |
| {/* Real-time audio visualization bars */} | |
| {Array.from({ length: 32 }, (_, i) => { | |
| // Use a wider frequency range for better distribution | |
| // Map across 60% of the frequency spectrum for voice and some harmonics | |
| const voiceRangeEnd = Math.floor(audioData.length * 0.6); | |
| const dataIndex = Math.floor((i / 32) * voiceRangeEnd); | |
| const amplitude = audioData[dataIndex] || 0; | |
| // Apply logarithmic scaling to prevent saturation and better distribute levels | |
| const normalizedAmplitude = amplitude / 255; | |
| const logScaled = Math.log10(1 + normalizedAmplitude * 9) / Math.log10(10); // Log scale 0-1 | |
| const height = Math.max(4, logScaled * 60); // Scale to 4-60px | |
| return ( | |
| <div | |
| key={i} | |
| className="w-1 bg-blue-500 rounded-full transition-all duration-75" | |
| style={{ | |
| height: `${height}px` | |
| }} | |
| /> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Error Display */} | |
| {error && ( | |
| <div className="mb-4 p-3 bg-red-900/20 border border-red-500 rounded-lg"> | |
| <p className="text-red-300 text-sm">{error}</p> | |
| </div> | |
| )} | |
| {/* Controls */} | |
| <div className="flex gap-4"> | |
| {permissionState === 'denied' && ( | |
| <button | |
| onClick={requestPermissions} | |
| className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors" | |
| > | |
| Request Permission | |
| </button> | |
| )} | |
| {permissionState === 'granted' && !isRecording && ( | |
| <button | |
| onClick={startRecording} | |
| disabled={!stream} | |
| className="px-6 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 text-white rounded-lg transition-colors flex items-center gap-2" | |
| > | |
| <div className="w-4 h-4 bg-white rounded-full"></div> | |
| Start Recording | |
| </button> | |
| )} | |
| {isRecording && ( | |
| <button | |
| onClick={stopRecording} | |
| className="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors flex items-center gap-2" | |
| > | |
| <div className="w-4 h-4 bg-white"></div> | |
| Stop Recording | |
| </button> | |
| )} | |
| <button | |
| onClick={onCancel} | |
| disabled={isRecording} | |
| className="px-6 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 text-white rounded-lg transition-colors" | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| {/* Tips */} | |
| <div className="mt-6 text-center"> | |
| <p className="text-gray-400 text-xs max-w-md"> | |
| Speak clearly and minimize background noise for best transcription results. | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default MediaRecorder; | |