UNPKG

murmuraba

Version:

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

475 lines (474 loc) 22.3 kB
import { processFileWithMetrics } from '../../api/process-file-with-metrics'; import { MIN_VALID_BLOB_SIZE, LOG_PREFIX } from './constants'; import { AudioConverter } from '../../utils/audio-converter'; import { SecureEventBridge } from '../../core/secure-event-bridge'; import { RecordingLogger, ProcessingLogger } from '../../utils/logger'; export class RecordingManager { constructor(urlManager) { this.urlManager = urlManager; this.mediaRecorder = null; this.originalRecorder = null; this.chunkRecordings = new Map(); this.processChunkInterval = null; this.stopCycleFlag = false; this.cycleCount = 0; this.cycleTimeout = null; // TDD Integration: Metrics provider from ChunkProcessor this.metricsProvider = null; this.currentMetrics = null; // Use secure event bridge instead of global state this.eventBridge = SecureEventBridge.getInstance(); this.bridgeToken = this.eventBridge.getAccessToken(); this.managerId = this.generateId(); // Register with secure event bridge this.eventBridge.registerRecordingManager(this.managerId, this, this.bridgeToken); // Subscribe to metrics events this.eventBridge.on('metrics', (metrics) => { this.notifyMetrics(metrics); }); RecordingLogger.info('RecordingManager registered with secure event bridge'); } generateId() { return `rm-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * TDD Integration: Set metrics provider from ChunkProcessor */ setMetricsProvider(provider) { this.metricsProvider = provider; } /** * TDD Integration: Receive metrics from ChunkProcessor */ receiveMetrics(metrics) { this.currentMetrics = metrics; RecordingLogger.debug('Received real metrics', { averageNoiseReduction: metrics.averageNoiseReduction?.toFixed(1) || 0, unit: 'percent' }); } /** * Secure Integration: Notify metrics received from secure event bridge */ notifyMetrics(metrics) { // Convert ProcessingMetrics to the format expected by recording manager this.currentMetrics = { averageNoiseReduction: metrics.noiseReductionLevel, averageLatency: metrics.processingLatency, totalFrames: metrics.frameCount, timestamp: metrics.timestamp }; RecordingLogger.debug('Received metrics via secure bridge', { noiseReductionLevel: metrics.noiseReductionLevel.toFixed(1), unit: 'percent' }); } /** * TDD Integration: Get real metrics for a time period */ getRealMetrics(startTime, endTime) { // Try current metrics first if (this.currentMetrics) { return this.currentMetrics; } // Try metrics provider if (this.metricsProvider) { return this.metricsProvider.getAggregatedMetrics(startTime, endTime); } // Fallback to safe defaults (NOT negative values) return { averageNoiseReduction: 0, totalFrames: Math.floor((endTime - startTime) / 10), averageLatency: 0 }; } /** * Start concatenated streaming for medical-grade recording */ async startCycle(processedStream, originalStream, chunkDuration, onChunkProcessed) { // Use a default mime type for now const mimeType = 'audio/webm;codecs=opus'; this.cycleCount = 0; this.stopCycleFlag = false; const startNewRecordingCycle = () => { if (this.stopCycleFlag) return; this.cycleCount++; const cycleStartTime = Date.now(); RecordingLogger.info('Starting recording cycle', { cycleNumber: this.cycleCount }); // Create chunk ID for this cycle const chunkId = `chunk-${cycleStartTime}-${Math.random().toString(36).substr(2, 9)}`; // Initialize recording storage this.chunkRecordings.set(chunkId, { processed: [], original: [], finalized: false }); // Create new recorders for this cycle const currentRecorder = new MediaRecorder(processedStream, { mimeType }); const currentOriginalRecorder = new MediaRecorder(originalStream, { mimeType }); currentRecorder.ondataavailable = (event) => { if (event.data.size >= MIN_VALID_BLOB_SIZE) { const chunkRecording = this.chunkRecordings.get(chunkId); if (chunkRecording && !chunkRecording.finalized) { chunkRecording.processed.push(event.data); ProcessingLogger.debug('Recording cycle processed data', { cycleNumber: this.cycleCount, dataSize: event.data.size, type: 'processed' }); } } else { console.warn(`⚠️ ${LOG_PREFIX.CONCAT_STREAM} Invalid blob size detected! Size: ${event.data.size} bytes (minimum: ${MIN_VALID_BLOB_SIZE} bytes)`, { cycleNumber: this.cycleCount, blobSize: event.data.size, type: 'processed' }); } }; currentOriginalRecorder.ondataavailable = (event) => { if (event.data.size >= MIN_VALID_BLOB_SIZE) { const chunkRecording = this.chunkRecordings.get(chunkId); if (chunkRecording && !chunkRecording.finalized) { chunkRecording.original.push(event.data); console.log(`💾 ${LOG_PREFIX.CONCAT_STREAM} Cycle #${this.cycleCount} - Original data: ${event.data.size} bytes`); } } else { console.warn(`⚠️ ${LOG_PREFIX.CONCAT_STREAM} Invalid blob size detected! Size: ${event.data.size} bytes (minimum: ${MIN_VALID_BLOB_SIZE} bytes)`, { cycleNumber: this.cycleCount, blobSize: event.data.size, type: 'original' }); } }; currentRecorder.onerror = (error) => { console.error(`❌ ${LOG_PREFIX.CONCAT_STREAM} Processed recorder error:`, error); }; currentOriginalRecorder.onerror = (error) => { console.error(`❌ ${LOG_PREFIX.CONCAT_STREAM} Original recorder error:`, error); }; // When recording stops, process and create chunk currentRecorder.onstop = () => { console.log(`🔄 ${LOG_PREFIX.CONCAT_STREAM} Recorder stopped for cycle #${this.cycleCount}`); const chunkRecording = this.chunkRecordings.get(chunkId); if (chunkRecording && !chunkRecording.finalized) { // Only process if we have valid data if (chunkRecording.processed.length > 0 || chunkRecording.original.length > 0) { this.processChunkRecording(chunkId, chunkRecording, cycleStartTime, mimeType, onChunkProcessed); } else { console.warn(`⚠️ ${LOG_PREFIX.CONCAT_STREAM} Cycle #${this.cycleCount} discarded - no valid blobs collected`); // Clean up the empty recording this.chunkRecordings.delete(chunkId); } } }; // Start recording currentRecorder.start(1000); currentOriginalRecorder.start(1000); // Store refs this.mediaRecorder = currentRecorder; this.originalRecorder = currentOriginalRecorder; }; // Stop current cycle and start new one const cycleRecording = () => { if (this.stopCycleFlag) { console.log(`🚫 ${LOG_PREFIX.CONCAT_STREAM} Cycle skipped - stop flag set`); return; } console.log(`⏹️ ${LOG_PREFIX.CONCAT_STREAM} Stopping cycle #${this.cycleCount}`); // Store current recorders to ensure onstop handlers complete const currentMediaRecorder = this.mediaRecorder; const currentOriginalRecorder = this.originalRecorder; // Stop recorders if they're recording if (currentMediaRecorder?.state === 'recording') { currentMediaRecorder.stop(); } if (currentOriginalRecorder?.state === 'recording') { currentOriginalRecorder.stop(); } // Start new cycle after a delay to ensure processing completes if (!this.stopCycleFlag) { this.cycleTimeout = setTimeout(() => { if (!this.stopCycleFlag) { startNewRecordingCycle(); } }, 1000); // Increased delay to ensure chunk processing } }; // Start first cycle startNewRecordingCycle(); // Set up interval for cycling this.processChunkInterval = setInterval(cycleRecording, chunkDuration * 1000); } /** * Process recorded chunk data */ async processChunkRecording(chunkId, chunkRecording, cycleStartTime, mimeType, onChunkProcessed) { const originalBlob = new Blob(chunkRecording.original, { type: mimeType }); console.log(`📦 ${LOG_PREFIX.CONCAT_STREAM} Original blob: ${originalBlob.size} bytes`); // Validate blob size let isValid = true; let errorMessage = ''; if (originalBlob.size === 0) { console.error(`❌ ${LOG_PREFIX.CONCAT_STREAM} Original blob is empty, skipping chunk creation`); this.chunkRecordings.delete(chunkId); return; } if (originalBlob.size < MIN_VALID_BLOB_SIZE) { isValid = false; errorMessage = `Audio too small (${originalBlob.size} bytes). Recording may be corrupted.`; console.error(`❌ ${LOG_PREFIX.CONCAT_STREAM} Invalid blob size in chunk!`); } // Create original URL immediately const originalUrl = isValid ? this.urlManager.createObjectURL(chunkId, originalBlob) : undefined; const cycleEndTime = Date.now(); // Process original audio through RNNoise to get metrics and processed audio let processedUrl; let noiseReduction = 0; let frameCount = 0; let averageVad = 0; let vadData = []; let actualDuration = 0; // Calcularemos la duración real del audio if (isValid) { try { // Convert WebM to WAV first console.log(`🔄 ${LOG_PREFIX.CONCAT_STREAM} Converting WebM to WAV for chunk ${chunkId}`); const wavBlob = await AudioConverter.webmToWav(originalBlob); // Convert WAV blob to ArrayBuffer const arrayBuffer = await wavBlob.arrayBuffer(); // Calcular duración real del audio WAV const dataView = new DataView(arrayBuffer); const sampleRate = dataView.getUint32(24, true); // Sample rate está en offset 24 const dataSize = dataView.getUint32(40, true); // Tamaño de datos está en offset 40 const bytesPerSample = dataView.getUint16(34, true) / 8; // Bits per sample / 8 const numChannels = dataView.getUint16(22, true); // Número de canales const totalSamples = dataSize / (bytesPerSample * numChannels); actualDuration = (totalSamples / sampleRate) * 1000; // Duración en milisegundos console.log(`📏 ${LOG_PREFIX.CONCAT_STREAM} Chunk ${chunkId} - Duración real: ${(actualDuration / 1000).toFixed(2)}s (SR: ${sampleRate}Hz, ${numChannels}ch)`); // Process with metrics like AudioDemo const result = await processFileWithMetrics(arrayBuffer); // Create processed blob from result const processedBlob = new Blob([result.processedBuffer], { type: 'audio/wav' }); processedUrl = this.urlManager.createObjectURL(chunkId, processedBlob); // Extract VAD metrics averageVad = result.averageVad; frameCount = result.metrics.length; // Convert metrics to VAD timeline data const vadSampleRate = 48000; // Assuming 48kHz const frameSize = 480; // RNNoise frame size vadData = result.metrics.map((metric, index) => ({ time: (index * frameSize) / vadSampleRate, vad: metric.vad })); console.log(`📊 VAD Data generated: ${vadData.length} points, avg=${averageVad.toFixed(3)}`); // Calculate actual noise reduction (inverse of VAD - lower VAD means more noise reduction) noiseReduction = (1 - averageVad) * 100; console.log(`🎯 ${LOG_PREFIX.CONCAT_STREAM} Processed chunk ${chunkId}: VAD=${averageVad.toFixed(3)}, noise reduction=${noiseReduction.toFixed(1)}%, ${frameCount} frames`); } catch (error) { console.error(`❌ ${LOG_PREFIX.CONCAT_STREAM} Failed to process chunk:`, error); isValid = false; errorMessage = `Processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`; } } // Si por alguna razón no pudimos calcular la duración real, usar la estimada if (actualDuration === 0) { actualDuration = cycleEndTime - cycleStartTime; console.warn(`⚠️ ${LOG_PREFIX.CONCAT_STREAM} No se pudo calcular duración real, usando estimada: ${(actualDuration / 1000).toFixed(2)}s`); } // Create chunk with real metrics from processing const newChunk = { id: chunkId, index: this.cycleCount - 1, // Use cycleCount as index startTime: cycleStartTime, endTime: cycleEndTime, duration: actualDuration, // Keep duration in milliseconds processedAudioUrl: processedUrl, originalAudioUrl: originalUrl, isPlaying: false, isExpanded: false, isValid, errorMessage, noiseRemoved: noiseReduction, originalSize: originalBlob.size, processedSize: processedUrl ? originalBlob.size : 0, // Same size for WAV averageVad, vadData, metrics: { processingLatency: 0, frameCount: frameCount, inputLevel: 1.0, outputLevel: 1.0, noiseReductionLevel: noiseReduction / 100, timestamp: Date.now(), droppedFrames: 0 } }; chunkRecording.finalized = true; console.log(`✅ ${LOG_PREFIX.CONCAT_STREAM} Cycle #${this.cycleCount} complete: ${(actualDuration / 1000).toFixed(1)}s chunk`); onChunkProcessed(newChunk); } /** * Stop recording and release all audio resources */ stopRecording() { console.log(`🛑 ${LOG_PREFIX.CONCAT_STREAM} Stopping concatenated streaming and releasing all audio resources...`); this.stopCycleFlag = true; // Clear intervals and timeouts first if (this.processChunkInterval) { clearInterval(this.processChunkInterval); this.processChunkInterval = null; } if (this.cycleTimeout) { clearTimeout(this.cycleTimeout); this.cycleTimeout = null; } // Stop recorders and wait for final chunks const promises = []; // Stop and clean up the processed stream recorder if (this.mediaRecorder) { if (this.mediaRecorder.state !== 'inactive') { const stopPromise = new Promise((resolve) => { const originalOnStop = this.mediaRecorder.onstop; this.mediaRecorder.onstop = (event) => { if (originalOnStop && this.mediaRecorder) { originalOnStop.call(this.mediaRecorder, event); } // CRITICAL: Release the stream tracks from the MediaRecorder if (this.mediaRecorder?.stream) { this.mediaRecorder.stream.getTracks().forEach(track => { track.stop(); console.log(`🔇 ${LOG_PREFIX.CONCAT_STREAM} Stopped MediaRecorder track:`, track.kind); }); } resolve(); }; this.mediaRecorder.stop(); }); promises.push(stopPromise); } else { // Even if inactive, still release the stream tracks if (this.mediaRecorder.stream) { this.mediaRecorder.stream.getTracks().forEach(track => { track.stop(); console.log(`🔇 ${LOG_PREFIX.CONCAT_STREAM} Stopped inactive MediaRecorder track:`, track.kind); }); } } } // Stop and clean up the original stream recorder if (this.originalRecorder) { if (this.originalRecorder.state !== 'inactive') { const stopPromise = new Promise((resolve) => { const originalOnStop = this.originalRecorder.onstop; this.originalRecorder.onstop = (event) => { if (originalOnStop && this.originalRecorder) { originalOnStop.call(this.originalRecorder, event); } // CRITICAL: Release the original stream tracks if (this.originalRecorder?.stream) { this.originalRecorder.stream.getTracks().forEach(track => { track.stop(); console.log(`🔇 ${LOG_PREFIX.CONCAT_STREAM} Stopped original recorder track:`, track.kind); }); } resolve(); }; this.originalRecorder.stop(); }); promises.push(stopPromise); } else { // Even if inactive, still release the stream tracks if (this.originalRecorder.stream) { this.originalRecorder.stream.getTracks().forEach(track => { track.stop(); console.log(`🔇 ${LOG_PREFIX.CONCAT_STREAM} Stopped inactive original recorder track:`, track.kind); }); } } } // Wait for all stop handlers to complete before cleanup Promise.all(promises).then(() => { // Clear recordings after processing this.chunkRecordings.clear(); // Reset recorders and clear all references this.mediaRecorder = null; this.originalRecorder = null; this.stopCycleFlag = false; this.cycleCount = 0; console.log(`✅ ${LOG_PREFIX.CONCAT_STREAM} Recording stopped completely and all audio resources released`); }).catch(error => { console.error(`❌ ${LOG_PREFIX.CONCAT_STREAM} Error during recording cleanup:`, error); // Still reset everything even if there was an error this.mediaRecorder = null; this.originalRecorder = null; this.chunkRecordings.clear(); }); } /** * Pause recording */ pauseRecording() { if (this.mediaRecorder?.state === 'recording') { this.mediaRecorder.pause(); } if (this.originalRecorder?.state === 'recording') { this.originalRecorder.pause(); } } /** * Resume recording */ resumeRecording() { if (this.mediaRecorder?.state === 'paused') { this.mediaRecorder.resume(); } if (this.originalRecorder?.state === 'paused') { this.originalRecorder.resume(); } } /** * Check if currently recording */ isRecording() { return this.mediaRecorder?.state === 'recording' || this.originalRecorder?.state === 'recording'; } /** * Start concatenated streaming for medical-grade recording * This is an alias for startCycle for backward compatibility */ async startConcatenatedStreaming(processedStream, originalStream, chunkDuration, onChunkProcessed) { return this.startCycle(processedStream, originalStream, chunkDuration, onChunkProcessed); } /** * Check if recording is paused */ isPaused() { return this.mediaRecorder?.state === 'paused' || this.originalRecorder?.state === 'paused'; } /** * Clean up and unregister from the secure event bridge */ cleanup() { // Unregister from secure event bridge this.eventBridge.unregisterRecordingManager(this.managerId, this.bridgeToken); this.eventBridge.removeAllListeners('metrics'); // Clean up any remaining recordings this.chunkRecordings.clear(); // Clear intervals if (this.processChunkInterval) { clearInterval(this.processChunkInterval); this.processChunkInterval = null; } if (this.cycleTimeout) { clearTimeout(this.cycleTimeout); this.cycleTimeout = null; } console.log(`🧹 [SECURE-INTEGRATION] RecordingManager cleaned up and unregistered`); } }