UNPKG

murmuraba

Version:

Real-time audio noise reduction with advanced chunked processing for web applications

451 lines (450 loc) 19 kB
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { initializeAudioEngine, destroyEngine, processStream, processStreamChunked, getEngineStatus, getDiagnostics, onMetricsUpdate, processFile, setInputGain, getInputGain, setAgcEnabled, isAgcEnabled, } from '../../api'; import { getAudioConverter, destroyAudioConverter } from '../../utils/audio-converter'; // Import managers import { URLManager } from './url-manager'; import { ChunkManager } from './chunk-manager'; import { RecordingManager } from './recording-manager'; import { AudioExporter } from './audio-exporter'; import { PlaybackManager } from './playback-manager'; import { createRecordingFunctions } from './recording-functions'; // Import hooks import { useRecordingState } from './use-recording-state'; // Import constants import { RECORDING_UPDATE_INTERVAL, LOG_PREFIX } from './constants'; /** * Main Murmuraba hook with medical-grade recording functionality * Refactored for better maintainability */ export function useMurmubaraEngine(options = {}) { const { autoInitialize = false, onInitError, ...config } = options; // State management const [isInitialized, setIsInitialized] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [engineState, setEngineState] = useState('uninitialized'); const [metrics, setMetrics] = useState(null); const [diagnostics, setDiagnostics] = useState(null); const [inputGain, setInputGainState] = useState(1.0); const [agcEnabled, setAgcEnabledState] = useState(true); // Use dedicated recording state hook const { recordingState, startRecording: recordingStateStart, stopRecording: recordingStateStop, pauseRecording: recordingStatePause, resumeRecording: recordingStateResume, addChunk, toggleChunkPlayback: recordingStateTogglePlayback, toggleChunkExpansion, clearRecordings: recordingStateClear, updateRecordingTime } = useRecordingState(); const [currentStream, setCurrentStream] = useState(null); const [originalStream, setOriginalStream] = useState(null); const [streamController, setStreamController] = useState(null); // Initialize managers const urlManagerRef = useRef(null); const chunkManagerRef = useRef(null); const recordingManagerRef = useRef(null); const audioExporterRef = useRef(null); const playbackManagerRef = useRef(null); // Lazy initialize managers if (!urlManagerRef.current) { urlManagerRef.current = new URLManager(); } if (!chunkManagerRef.current) { chunkManagerRef.current = new ChunkManager(urlManagerRef.current); } if (!recordingManagerRef.current) { recordingManagerRef.current = new RecordingManager(urlManagerRef.current); } if (!audioExporterRef.current) { audioExporterRef.current = new AudioExporter(); } if (!playbackManagerRef.current) { playbackManagerRef.current = new PlaybackManager(); } // Other refs const metricsUnsubscribeRef = useRef(null); const initializePromiseRef = useRef(null); const audioConverterRef = useRef(null); // Update diagnostics const updateDiagnostics = useCallback(() => { if (!isInitialized) { setDiagnostics(null); return null; } try { const diag = getDiagnostics(); setDiagnostics(diag); return diag; } catch { return null; } }, [isInitialized]); // Fix race condition: Update diagnostics when isInitialized changes to true useEffect(() => { if (isInitialized && !diagnostics) { updateDiagnostics(); } }, [isInitialized, diagnostics, updateDiagnostics]); // Initialize engine // CRITICAL FIX: Memoize config to prevent re-initialization loops // Only memoize based on properties that actually exist in the config type const memoizedConfig = useMemo(() => config, [ config?.bufferSize, config?.algorithm, config?.noiseReductionLevel, config?.logLevel, config?.autoCleanup, config?.cleanupDelay, config?.useWorker, config?.allowDegraded ]); const initialize = useCallback(async () => { console.log(`🚀 ${LOG_PREFIX.LIFECYCLE} Initializing MurmubaraEngine...`); // Use refs to get current values without creating dependencies const currentConfig = memoizedConfig; const currentOnInitError = onInitError; if (initializePromiseRef.current) { console.log(`⏳ ${LOG_PREFIX.LIFECYCLE} Already initializing, returning existing promise`); return initializePromiseRef.current; } if (isInitialized) { console.log(`✅ ${LOG_PREFIX.LIFECYCLE} Already initialized, skipping`); return; } setIsLoading(true); setError(null); initializePromiseRef.current = (async () => { try { console.log(`🔧 ${LOG_PREFIX.LIFECYCLE} Calling initializeAudioEngine with config:`, currentConfig); await initializeAudioEngine(currentConfig); // Set up metrics listener with throttling to prevent excessive re-renders // Throttle to 500ms (2 updates per second) instead of 100ms (10 updates per second) let lastUpdate = 0; let pendingMetrics = null; let timeoutId = null; const throttledSetMetrics = (newMetrics) => { const now = Date.now(); const timeSinceLastUpdate = now - lastUpdate; if (timeSinceLastUpdate >= 500) { // Enough time has passed, update immediately setMetrics(newMetrics); lastUpdate = now; pendingMetrics = null; } else { // Store the latest metrics and schedule an update pendingMetrics = newMetrics; if (!timeoutId) { timeoutId = setTimeout(() => { if (pendingMetrics) { setMetrics(pendingMetrics); lastUpdate = Date.now(); pendingMetrics = null; } timeoutId = null; }, 500 - timeSinceLastUpdate); } } }; const unsubscribe = onMetricsUpdate(throttledSetMetrics); metricsUnsubscribeRef.current = () => { unsubscribe(); if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } }; // Initialize audio converter audioConverterRef.current = getAudioConverter(); audioExporterRef.current.setAudioConverter(audioConverterRef.current); setIsInitialized(true); setEngineState('ready'); console.log(`🎉 ${LOG_PREFIX.LIFECYCLE} Engine initialized successfully!`); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to initialize audio engine'; console.error(`❌ ${LOG_PREFIX.LIFECYCLE} Initialization failed:`, errorMessage); setError(errorMessage); setEngineState('error'); if (currentOnInitError) { currentOnInitError(err instanceof Error ? err : new Error(errorMessage)); } throw err; } finally { setIsLoading(false); initializePromiseRef.current = null; } })(); return initializePromiseRef.current; }, []); // CRITICAL FIX: Empty dependency array - use refs for current values // Destroy engine const destroy = useCallback(async (force = false) => { console.log(`🔥 ${LOG_PREFIX.LIFECYCLE} Destroying engine...`, { force }); if (!isInitialized) { console.log(`⚠️ ${LOG_PREFIX.LIFECYCLE} Engine not initialized, skipping destroy`); return; } try { // Stop any ongoing recording if (recordingState.isRecording) { console.log(`🛑 ${LOG_PREFIX.LIFECYCLE} Stopping ongoing recording before destroy`); recordingManagerRef.current?.stopRecording(); } // Clean up event listeners if (metricsUnsubscribeRef.current) { metricsUnsubscribeRef.current(); metricsUnsubscribeRef.current = null; } // CRITICAL: Destroy audio converter to prevent memory leaks destroyAudioConverter(); // DON'T revoke URLs - keep chunks playable after engine destroy // urlManagerRef.current!.revokeAllUrls(); await destroyEngine({ force }); setIsInitialized(false); setEngineState('destroyed'); setMetrics(null); setDiagnostics(null); console.log(`💀 ${LOG_PREFIX.LIFECYCLE} Engine destroyed successfully`); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); throw err; } }, [isInitialized, recordingState.isRecording]); // Export functions (delegated to AudioExporter) const exportChunkAsWav = useCallback(async (chunkId, audioType) => { const chunk = chunkManagerRef.current.findChunk(recordingState.chunks, chunkId); if (!chunk) throw new Error(`Chunk not found: ${chunkId}`); return audioExporterRef.current.exportChunkAsWav(chunk, audioType); }, [recordingState.chunks]); const exportChunkAsMp3 = useCallback(async (chunkId, audioType, bitrate) => { const chunk = chunkManagerRef.current.findChunk(recordingState.chunks, chunkId); if (!chunk) throw new Error(`Chunk not found: ${chunkId}`); return audioExporterRef.current.exportChunkAsMp3(chunk, audioType, bitrate); }, [recordingState.chunks]); const downloadChunk = useCallback(async (chunkId, format, audioType) => { const chunk = chunkManagerRef.current.findChunk(recordingState.chunks, chunkId); if (!chunk) throw new Error(`Chunk not found: ${chunkId}`); return audioExporterRef.current.downloadChunk(chunk, format, audioType); }, [recordingState.chunks]); const downloadAllChunksAsZip = useCallback(async (audioType = 'both') => { return audioExporterRef.current.downloadAllChunksAsZip(recordingState.chunks, audioType); }, [recordingState.chunks]); // Utility functions const formatTime = useCallback((seconds) => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${minutes}:${secs.toString().padStart(2, '0')}`; }, []); /** * Set the input gain level */ const updateInputGain = useCallback((gain) => { try { if (!isInitialized) { throw new Error('Engine not initialized'); } setInputGain(gain); setInputGainState(gain); console.log(`[${LOG_PREFIX}] Input gain set to ${gain}x`); } catch (error) { console.error(`[${LOG_PREFIX}] Failed to set input gain:`, error); setError(error instanceof Error ? error.message : 'Failed to set input gain'); } }, [isInitialized]); /** * Get the current input gain level */ const getCurrentInputGain = useCallback(() => { try { if (!isInitialized) { return inputGain; } const gain = getInputGain(); setInputGainState(gain); return gain; } catch (error) { console.error(`[${LOG_PREFIX}] Failed to get input gain:`, error); return inputGain; } }, [isInitialized, inputGain]); const updateAgcEnabled = useCallback(async (enabled) => { try { if (!isInitialized) { throw new Error('Engine not initialized'); } setAgcEnabled(enabled); setAgcEnabledState(enabled); console.log(`[${LOG_PREFIX}] AGC ${enabled ? 'enabled' : 'disabled'}`); } catch (error) { console.error(`[${LOG_PREFIX}] Failed to set AGC enabled:`, error); setError(error instanceof Error ? error.message : String(error)); throw error; } }, [isInitialized]); const getAgcEnabled = useCallback(() => { try { if (!isInitialized) { return agcEnabled; } const enabled = isAgcEnabled(); setAgcEnabledState(enabled); return enabled; } catch (error) { console.error(`[${LOG_PREFIX}] Failed to get AGC enabled:`, error); return agcEnabled; } }, [isInitialized, agcEnabled]); const getAverageNoiseReduction = useCallback(() => { return chunkManagerRef.current.getAverageNoiseReduction(recordingState.chunks); }, [recordingState.chunks]); // Reinitialize function for fresh start const reinitialize = useCallback(async () => { console.log(`♻️ ${LOG_PREFIX.LIFECYCLE} Reinitializing engine...`); if (isInitialized) { await destroy(true); } await initialize(); }, [isInitialized, destroy, initialize]); // Create recording functions const recordingFunctions = createRecordingFunctions({ isInitialized, recordingState, recordingStateHook: { recordingState, startRecording: recordingStateStart, stopRecording: recordingStateStop, pauseRecording: recordingStatePause, resumeRecording: recordingStateResume, addChunk, toggleChunkPlayback: recordingStateTogglePlayback, toggleChunkExpansion, clearRecordings: recordingStateClear, updateRecordingTime }, currentStream, originalStream, setCurrentStream, setOriginalStream, setStreamController, setError, chunkManager: chunkManagerRef.current, recordingManager: recordingManagerRef.current, initialize }); // Playback functions const toggleChunkPlayback = useCallback(async (chunkId, audioType) => { const chunk = chunkManagerRef.current.findChunk(recordingState.chunks, chunkId); if (!chunk) return; await playbackManagerRef.current.toggleChunkPlayback(chunk, audioType, (id, isPlaying) => { recordingStateTogglePlayback(id, isPlaying, audioType); }); }, [recordingState.chunks, recordingStateTogglePlayback]); // Effects // Auto-initialize useEffect(() => { if (autoInitialize && !isInitialized && !isLoading) { console.log(`🤖 ${LOG_PREFIX.LIFECYCLE} Auto-initializing engine...`); initialize(); } }, [autoInitialize, isInitialized, isLoading, initialize]); // Update recording time useEffect(() => { if (recordingState.isRecording && !recordingState.isPaused) { const startTime = Date.now() - recordingState.recordingTime * 1000; const interval = setInterval(() => { updateRecordingTime(Math.floor((Date.now() - startTime) / 1000)); }, RECORDING_UPDATE_INTERVAL); return () => clearInterval(interval); } }, [recordingState.isRecording, recordingState.isPaused, recordingState.recordingTime, updateRecordingTime]); // Update engine state periodically useEffect(() => { if (!isInitialized) return; const interval = setInterval(() => { try { const status = getEngineStatus(); setEngineState(status); } catch { // Engine might be destroyed } }, 1000); return () => clearInterval(interval); }, [isInitialized]); // Cleanup on unmount useEffect(() => { console.log(`🌟 ${LOG_PREFIX.LIFECYCLE} Component mounted, setting up cleanup handler`); // Capture refs for cleanup const urlManager = urlManagerRef.current; const playbackManager = playbackManagerRef.current; const recordingManager = recordingManagerRef.current; return () => { console.log(`👋 ${LOG_PREFIX.LIFECYCLE} Component unmounting, cleaning up...`); // CRITICAL: Destroy audio converter to prevent memory leaks destroyAudioConverter(); // Clean up recording manager recordingManager?.cleanup(); // Clean up all URLs urlManager?.revokeAllUrls(); // Clean up audio elements playbackManager?.cleanup(); }; }, []); return { // State isInitialized, isLoading, error, engineState, metrics, diagnostics, inputGain, // Recording State recordingState, currentStream, streamController, // Actions initialize, reinitialize, destroy, processStream, processStreamChunked, processFile, // Recording Actions startRecording: recordingFunctions.startRecording, stopRecording: recordingFunctions.stopRecording, pauseRecording: recordingFunctions.pauseRecording, resumeRecording: recordingFunctions.resumeRecording, clearRecordings: recordingFunctions.clearRecordings, // Audio Playback Actions toggleChunkPlayback, toggleChunkExpansion, // Export Actions exportChunkAsWav, exportChunkAsMp3, downloadChunk, downloadAllChunksAsZip, // Gain Control setInputGain: updateInputGain, getInputGain: getCurrentInputGain, // AGC Control agcEnabled, setAgcEnabled: updateAgcEnabled, getAgcEnabled, // Utility getDiagnostics: updateDiagnostics, resetError: () => setError(null), formatTime, getAverageNoiseReduction, }; }