UNPKG

murmuraba

Version:

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

1,350 lines (1,316 loc) 2.19 MB
import { jsxs, jsx, Fragment } from 'react/jsx-runtime'; import * as React from 'react'; import React__default, { useRef, useState, useCallback, useMemo, useEffect, isValidElement, Children, forwardRef, createContext, useContext, PureComponent, cloneElement, createElement, useImperativeHandle, Component, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; import require$$0 from 'stream'; import require$$2 from 'events'; import require$$0$1 from 'buffer'; import require$$1 from 'util'; function _mergeNamespaces(n, m) { m.forEach(function (e) { e && typeof e !== 'string' && !Array.isArray(e) && Object.keys(e).forEach(function (k) { if (k !== 'default' && !(k in n)) { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); }); return Object.freeze(n); } class AudioResampler { static resamplePCMIfNeeded(pcmData, options) { const { targetSampleRate, inputSampleRate, logger } = options; // Validate input parameters if (!pcmData || pcmData.length === 0) { throw new Error('PCM data cannot be empty'); } if (!Number.isFinite(inputSampleRate) || inputSampleRate <= 0) { throw new Error(`Invalid input sample rate: ${inputSampleRate}`); } if (!Number.isFinite(targetSampleRate) || targetSampleRate <= 0) { throw new Error(`Invalid target sample rate: ${targetSampleRate}`); } // No resampling needed if (inputSampleRate === targetSampleRate) { logger?.debug(`No resampling needed: already at ${targetSampleRate}Hz`); return { resampledData: pcmData, outputSampleRate: targetSampleRate, wasResampled: false }; } logger?.info(`Resampling from ${inputSampleRate}Hz to ${targetSampleRate}Hz...`); try { const resampled = this.resamplePCM(pcmData, inputSampleRate, targetSampleRate); logger?.debug(`Resampling complete: ${resampled.length} samples at ${targetSampleRate}Hz`); return { resampledData: resampled, outputSampleRate: targetSampleRate, wasResampled: true }; } catch (error) { throw new Error(`Resampling failed: ${error instanceof Error ? error.message : String(error)}`); } } static resampleToRNNoiseRate(pcmData, inputSampleRate, logger) { return this.resamplePCMIfNeeded(pcmData, { targetSampleRate: this.TARGET_SAMPLE_RATE, inputSampleRate, logger }); } static pcm16ToFloat32(pcm) { const f = new Float32Array(pcm.length); for (let i = 0; i < pcm.length; ++i) { f[i] = pcm[i] / 32768.0; } return f; } static float32ToPcm16(float32) { const pcm = new Int16Array(float32.length); for (let i = 0; i < float32.length; ++i) { pcm[i] = Math.max(-32768, Math.min(32767, Math.round(float32[i] * 32768))); } return pcm; } static resamplePCM(pcm, fromRate, toRate) { const input = this.pcm16ToFloat32(pcm); const output = this.linearInterpolationResample(input, fromRate, toRate); return this.float32ToPcm16(output); } /** * Simple linear interpolation resampler * This is a basic implementation that should work for most audio resampling needs */ static linearInterpolationResample(input, fromRate, toRate) { if (fromRate === toRate) { return input; } const ratio = fromRate / toRate; const outputLength = Math.floor(input.length / ratio); const output = new Float32Array(outputLength); for (let i = 0; i < outputLength; i++) { const srcIndex = i * ratio; const srcIndexFloor = Math.floor(srcIndex); const srcIndexCeil = Math.min(srcIndexFloor + 1, input.length - 1); const fraction = srcIndex - srcIndexFloor; // Linear interpolation between two samples output[i] = input[srcIndexFloor] * (1 - fraction) + input[srcIndexCeil] * fraction; } return output; } } AudioResampler.TARGET_SAMPLE_RATE = 48000; let EventEmitter$1 = class EventEmitter { constructor() { this.events = new Map(); } on(event, handler) { if (!this.events.has(event)) { this.events.set(event, new Set()); } this.events.get(event).add(handler); } off(event, handler) { const handlers = this.events.get(event); if (handlers) { handlers.delete(handler); if (handlers.size === 0) { this.events.delete(event); } } } emit(event, ...args) { const handlers = this.events.get(event); if (handlers) { handlers.forEach(handler => { try { handler(...args); } catch (error) { console.error(`Error in event handler for ${String(event)}:`, error); } }); } } once(event, handler) { const wrappedHandler = ((...args) => { this.off(event, wrappedHandler); handler(...args); }); this.on(event, wrappedHandler); } removeAllListeners(event) { if (event) { this.events.delete(event); } else { this.events.clear(); } } listenerCount(event) { const handlers = this.events.get(event); return handlers ? handlers.size : 0; } }; class StateManager extends EventEmitter$1 { constructor() { super(...arguments); this.currentState = 'uninitialized'; this.allowedTransitions = new Map([ ['uninitialized', ['initializing', 'error']], ['initializing', ['creating-context', 'loading-wasm', 'ready', 'degraded', 'error']], ['creating-context', ['loading-wasm', 'ready', 'degraded', 'error']], ['loading-wasm', ['ready', 'degraded', 'error']], ['ready', ['processing', 'destroying', 'error']], ['processing', ['ready', 'paused', 'destroying', 'error']], ['paused', ['processing', 'ready', 'destroying', 'error']], ['degraded', ['processing', 'destroying', 'error']], ['destroying', ['destroyed', 'error']], ['destroyed', []], ['error', ['initializing', 'destroying']], ]); } getState() { return this.currentState; } canTransitionTo(newState) { const allowed = this.allowedTransitions.get(this.currentState) || []; return allowed.includes(newState); } transitionTo(newState) { if (!this.canTransitionTo(newState)) { console.warn(`Invalid state transition: ${this.currentState} -> ${newState}`); return false; } const oldState = this.currentState; this.currentState = newState; this.emit('state-change', oldState, newState); return true; } isInState(...states) { return states.includes(this.currentState); } requireState(...states) { if (!this.isInState(...states)) { throw new Error(`Operation requires state to be one of: ${states.join(', ')}, ` + `but current state is: ${this.currentState}`); } } reset() { const oldState = this.currentState; this.currentState = 'uninitialized'; if (oldState !== 'uninitialized') { this.emit('state-change', oldState, 'uninitialized'); } } } let Logger$1 = class Logger { constructor(prefix = '[Murmuraba]') { this.level = 'info'; this.prefix = prefix; } setLevel(level) { this.level = level; } setLogHandler(handler) { this.onLog = handler; } shouldLog(level) { const levels = ['none', 'error', 'warn', 'info', 'debug']; const currentIndex = levels.indexOf(this.level); const messageIndex = levels.indexOf(level); return currentIndex > 0 && messageIndex <= currentIndex; } log(level, message, data) { if (!this.shouldLog(level)) return; const timestamp = new Date().toISOString(); const formattedMessage = `${this.prefix} [${timestamp}] [${level.toUpperCase()}] ${message}`; if (this.onLog) { this.onLog(level, formattedMessage, data); } else { const logMethod = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; if (data !== undefined) { logMethod(formattedMessage, data); } else { logMethod(formattedMessage); } } } error(message, data) { this.log('error', message, data); } warn(message, data) { this.log('warn', message, data); } info(message, data) { this.log('info', message, data); } debug(message, data) { this.log('debug', message, data); } }; class MurmubaraError extends Error { constructor(code, message, details) { super(message); this.name = 'MurmubaraError'; this.code = code; this.details = details; } } const ErrorCodes = { WASM_NOT_LOADED: 'WASM_NOT_LOADED', INVALID_STREAM: 'INVALID_STREAM', ENGINE_BUSY: 'ENGINE_BUSY', INITIALIZATION_FAILED: 'INITIALIZATION_FAILED', PROCESSING_FAILED: 'PROCESSING_FAILED', CLEANUP_FAILED: 'CLEANUP_FAILED', WORKER_ERROR: 'WORKER_ERROR', INVALID_CONFIG: 'INVALID_CONFIG', NOT_INITIALIZED: 'NOT_INITIALIZED', ALREADY_INITIALIZED: 'ALREADY_INITIALIZED', }; class WorkerManager { constructor(logger) { this.workers = new Map(); this.logger = logger; } createWorker(id, workerPath) { if (this.workers.has(id)) { throw new MurmubaraError(ErrorCodes.WORKER_ERROR, `Worker with id ${id} already exists`); } try { const worker = new Worker(workerPath); this.workers.set(id, worker); this.logger.debug(`Worker created: ${id}`); return worker; } catch (error) { this.logger.error(`Failed to create worker: ${id}`, error); throw new MurmubaraError(ErrorCodes.WORKER_ERROR, `Failed to create worker: ${error instanceof Error ? error.message : String(error)}`); } } getWorker(id) { return this.workers.get(id); } sendMessage(id, message) { const worker = this.workers.get(id); if (!worker) { throw new MurmubaraError(ErrorCodes.WORKER_ERROR, `Worker ${id} not found`); } worker.postMessage(message); this.logger.debug(`Message sent to worker ${id}:`, message); } terminateWorker(id) { const worker = this.workers.get(id); if (worker) { worker.terminate(); this.workers.delete(id); this.logger.debug(`Worker terminated: ${id}`); } } terminateAll() { this.logger.info(`Terminating all ${this.workers.size} workers`); for (const [id, worker] of this.workers) { worker.terminate(); this.logger.debug(`Worker terminated: ${id}`); } this.workers.clear(); } getActiveWorkerCount() { return this.workers.size; } getWorkerIds() { return Array.from(this.workers.keys()); } } class MetricsManager extends EventEmitter$1 { constructor() { super(...arguments); this.metrics = { noiseReductionLevel: 0, processingLatency: 0, inputLevel: 0, outputLevel: 0, timestamp: Date.now(), frameCount: 0, droppedFrames: 0, vadLevel: 0, isVoiceActive: false, }; this.frameTimestamps = []; this.maxFrameHistory = 100; this.vadHistory = []; this.currentVAD = 0; } startAutoUpdate(intervalMs = 33) { this.stopAutoUpdate(); this.updateInterval = setInterval(() => { this.calculateLatency(); // Include all VAD data in the emitted metrics const metricsWithAverage = { ...this.metrics, averageVad: this.getAverageVAD(), vadLevel: this.currentVAD, // Ensure vadLevel is always included isVoiceActive: this.currentVAD > 0.3 // Update voice activity state }; this.emit('metrics-update', metricsWithAverage); }, intervalMs); } stopAutoUpdate() { if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = undefined; } } updateInputLevel(level) { this.metrics.inputLevel = Math.max(0, Math.min(1, level)); } updateOutputLevel(level) { this.metrics.outputLevel = Math.max(0, Math.min(1, level)); } updateNoiseReduction(level) { this.metrics.noiseReductionLevel = Math.max(0, Math.min(100, level)); } updateVAD(vadLevel) { const clampedVAD = Math.max(0, Math.min(1, vadLevel)); this.metrics.vadLevel = clampedVAD; this.metrics.isVoiceActive = clampedVAD > 0.3; // Threshold for voice detection this.currentVAD = clampedVAD; this.vadHistory.push(clampedVAD); if (this.vadHistory.length > this.maxFrameHistory) { this.vadHistory.shift(); } // Log significant VAD updates for debugging if (clampedVAD > 0.01) { console.log(`[MetricsManager] VAD updated: ${clampedVAD.toFixed(3)}`); } } recordFrame(timestamp = Date.now()) { this.frameTimestamps.push(timestamp); if (this.frameTimestamps.length > this.maxFrameHistory) { this.frameTimestamps.shift(); } this.metrics.frameCount++; this.metrics.timestamp = timestamp; } recordDroppedFrame() { this.metrics.droppedFrames++; } recordChunk(chunk) { this.emit('chunk-processed', chunk); } calculateLatency() { if (this.frameTimestamps.length < 2) { this.metrics.processingLatency = 0; return; } const deltas = []; for (let i = 1; i < this.frameTimestamps.length; i++) { deltas.push(this.frameTimestamps[i] - this.frameTimestamps[i - 1]); } const avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length; this.metrics.processingLatency = avgDelta; } getMetrics() { return { ...this.metrics, vadLevel: this.currentVAD, // Always include current VAD averageVad: this.getAverageVAD(), isVoiceActive: this.currentVAD > 0.3 }; } reset() { this.metrics = { noiseReductionLevel: 0, processingLatency: 0, inputLevel: 0, outputLevel: 0, timestamp: Date.now(), frameCount: 0, droppedFrames: 0, vadLevel: 0, isVoiceActive: false, }; this.frameTimestamps = []; } calculateRMS(samples) { let sum = 0; for (let i = 0; i < samples.length; i++) { sum += samples[i] * samples[i]; } return Math.sqrt(sum / samples.length); } calculatePeak(samples) { let peak = 0; for (let i = 0; i < samples.length; i++) { peak = Math.max(peak, Math.abs(samples[i])); } return peak; } getAverageVAD() { if (this.vadHistory.length === 0) return 0; return this.vadHistory.reduce((a, b) => a + b, 0) / this.vadHistory.length; } getVoiceActivityPercentage() { if (this.vadHistory.length === 0) return 0; const voiceFrames = this.vadHistory.filter(v => v > 0.5).length; return (voiceFrames / this.vadHistory.length) * 100; } } /** * Secure Event Bridge for internal communication * Replaces the insecure global __murmurabaTDDBridge */ class SecureEventBridge extends EventEmitter$1 { constructor() { super(); this.chunkProcessor = null; this.recordingManagers = new Map(); // Generate a unique access token for this session this.accessToken = this.generateAccessToken(); } /** * Get the singleton instance */ static getInstance() { if (!SecureEventBridge.instance) { SecureEventBridge.instance = new SecureEventBridge(); } return SecureEventBridge.instance; } /** * Reset the singleton (mainly for testing) */ static reset() { if (SecureEventBridge.instance) { SecureEventBridge.instance.removeAllListeners(); SecureEventBridge.instance.recordingManagers.clear(); SecureEventBridge.instance = null; } } /** * Register a chunk processor with validation */ registerChunkProcessor(processor, token) { if (!this.validateToken(token)) { throw new Error('Invalid access token for chunk processor registration'); } this.chunkProcessor = processor; } /** * Register a recording manager with validation */ registerRecordingManager(id, manager, token) { if (!this.validateToken(token)) { throw new Error('Invalid access token for recording manager registration'); } if (!id || typeof id !== 'string') { throw new Error('Invalid recording manager ID'); } this.recordingManagers.set(id, manager); this.emit('recording-manager-registered', id); } /** * Unregister a recording manager */ unregisterRecordingManager(id, token) { if (!this.validateToken(token)) { throw new Error('Invalid access token for recording manager unregistration'); } this.recordingManagers.delete(id); this.emit('recording-manager-unregistered', id); } /** * Notify metrics to all registered managers */ notifyMetrics(metrics, token) { if (!this.validateToken(token)) { throw new Error('Invalid access token for metrics notification'); } // Validate metrics object if (!this.validateMetrics(metrics)) { console.warn('Invalid metrics object received'); return; } // Emit metrics event this.emit('metrics', metrics); // Notify all registered recording managers this.recordingManagers.forEach((manager) => { try { if (manager && typeof manager.notifyMetrics === 'function') { manager.notifyMetrics(metrics); } } catch (error) { console.error('Error notifying recording manager:', error); } }); } /** * Get the access token (only for internal use) */ getAccessToken() { return this.accessToken; } /** * Get registered recording managers count */ getRecordingManagersCount() { return this.recordingManagers.size; } /** * Generate a secure access token */ generateAccessToken() { const array = new Uint8Array(32); if (typeof window !== 'undefined' && window.crypto) { window.crypto.getRandomValues(array); } else if (typeof global !== 'undefined' && global.crypto) { global.crypto.getRandomValues(array); } else { // Fallback for environments without crypto for (let i = 0; i < array.length; i++) { array[i] = Math.floor(Math.random() * 256); } } return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); } /** * Validate access token */ validateToken(token) { return token === this.accessToken; } /** * Validate metrics object */ validateMetrics(metrics) { if (!metrics || typeof metrics !== 'object') { return false; } // Check required fields const requiredFields = [ 'noiseReductionLevel', 'processingLatency', 'inputLevel', 'outputLevel', 'frameCount', 'droppedFrames', 'timestamp' ]; for (const field of requiredFields) { if (!(field in metrics)) { return false; } // Validate numeric fields if (field !== 'timestamp' && (typeof metrics[field] !== 'number' || isNaN(metrics[field]) || !isFinite(metrics[field]))) { return false; } } // Validate ranges if (metrics.inputLevel < 0 || metrics.inputLevel > 1) return false; if (metrics.outputLevel < 0 || metrics.outputLevel > 1) return false; if (metrics.noiseReductionLevel < 0 || metrics.noiseReductionLevel > 100) return false; if (metrics.processingLatency < 0) return false; return true; } } SecureEventBridge.instance = null; class ChunkProcessor extends EventEmitter$1 { constructor(sampleRate, config, logger, metricsManager) { super(); this.currentChunk = []; this.chunkStartTime = 0; this.chunkIndex = 0; this.currentSampleCount = 0; this.overlapBuffer = []; // Metrics accumulation for TDD integration this.accumulatedMetrics = { totalNoiseReduction: 0, frameCount: 0, totalLatency: 0, periodStartTime: null, totalVAD: 0, vadReadings: [] }; this.logger = logger; this.sampleRate = sampleRate; this.metricsManager = metricsManager; // Initialize secure event bridge this.eventBridge = SecureEventBridge.getInstance(); this.bridgeToken = this.eventBridge.getAccessToken(); this.eventBridge.registerChunkProcessor(this, this.bridgeToken); this.config = { chunkDuration: config.chunkDuration, onChunkProcessed: config.onChunkProcessed || undefined, overlap: config.overlap || 0, }; // Calculate samples per chunk this.samplesPerChunk = Math.floor((this.config.chunkDuration / 1000) * this.sampleRate); this.logger.info(`ChunkProcessor initialized:`, { sampleRate: this.sampleRate, chunkDuration: this.config.chunkDuration, samplesPerChunk: this.samplesPerChunk, overlap: this.config.overlap, }); } /** * Add samples to the current chunk */ addSamples(samples) { // Initialize start time on first sample with high-resolution timer if (this.chunkStartTime === 0) { this.chunkStartTime = performance.now(); } this.currentChunk.push(new Float32Array(samples)); this.currentSampleCount += samples.length; // Check if we have enough samples for a chunk while (this.currentSampleCount >= this.samplesPerChunk) { this.processCurrentChunk(); } } /** * Process the current chunk */ processCurrentChunk() { const chunkId = `chunk-${this.chunkIndex++}`; const endTime = performance.now(); // Combine all samples into a single array const totalSamples = this.extractChunkSamples(); // Apply overlap if configured const processedSamples = this.applyOverlap(totalSamples); // Create chunk object const chunk = { id: chunkId, data: processedSamples, startTime: this.chunkStartTime, endTime: endTime, sampleRate: this.sampleRate, channelCount: 1, }; // Emit chunk ready event this.emit('chunk-ready', chunk); // Calculate and emit metrics this.emitChunkMetrics(chunk, totalSamples, processedSamples); // Reset for next chunk this.chunkStartTime = endTime; } /** * Extract samples for current chunk */ extractChunkSamples() { const result = new Float32Array(this.samplesPerChunk); let offset = 0; let remainingSamples = this.samplesPerChunk; while (remainingSamples > 0 && this.currentChunk.length > 0) { const buffer = this.currentChunk[0]; const samplesToTake = Math.min(remainingSamples, buffer.length); // Copy samples result.set(buffer.subarray(0, samplesToTake), offset); offset += samplesToTake; remainingSamples -= samplesToTake; if (samplesToTake === buffer.length) { // Used entire buffer this.currentChunk.shift(); } else { // Partial buffer used, keep remainder this.currentChunk[0] = buffer.subarray(samplesToTake); } } this.currentSampleCount -= this.samplesPerChunk; return result; } /** * Apply overlap window to smooth chunk transitions */ applyOverlap(samples) { if (this.config.overlap === 0) { return samples; } const overlapSamples = Math.floor(this.samplesPerChunk * this.config.overlap); const result = new Float32Array(samples.length); // Copy main samples result.set(samples); // Apply overlap from previous chunk if (this.overlapBuffer.length > 0) { const previousOverlap = this.combineBuffers(this.overlapBuffer); const fadeLength = Math.min(overlapSamples, previousOverlap.length); // Crossfade between chunks for (let i = 0; i < fadeLength; i++) { const fadeIn = i / fadeLength; const fadeOut = 1 - fadeIn; result[i] = result[i] * fadeIn + previousOverlap[i] * fadeOut; } } // Save overlap for next chunk this.overlapBuffer = [samples.subarray(samples.length - overlapSamples)]; return result; } /** * Calculate and emit chunk metrics */ emitChunkMetrics(chunk, originalSamples, processedSamples) { // Calculate metrics const originalRMS = this.metricsManager.calculateRMS(originalSamples); const processedRMS = this.metricsManager.calculateRMS(processedSamples); const originalPeak = this.metricsManager.calculatePeak(originalSamples); const processedPeak = this.metricsManager.calculatePeak(processedSamples); const noiseRemoved = originalRMS > 0 ? ((originalRMS - processedRMS) / originalRMS) * 100 : 0; // Get current VAD from accumulated metrics const currentVAD = this.accumulatedMetrics.vadReadings.length > 0 ? this.accumulatedMetrics.vadReadings[this.accumulatedMetrics.vadReadings.length - 1] : 0; const averageVAD = this.accumulatedMetrics.vadReadings.length > 0 ? this.accumulatedMetrics.totalVAD / this.accumulatedMetrics.vadReadings.length : 0; const metrics = { originalSize: originalSamples.length * 4, // Float32 = 4 bytes processedSize: processedSamples.length * 4, noiseRemoved: Math.max(0, Math.min(100, noiseRemoved)), metrics: { noiseReductionLevel: noiseRemoved, processingLatency: chunk.endTime - chunk.startTime, inputLevel: originalPeak, outputLevel: processedPeak, timestamp: chunk.endTime, frameCount: Math.floor(processedSamples.length / 480), // RNNoise frame size droppedFrames: 0, vadLevel: currentVAD, averageVad: averageVAD, isVoiceActive: currentVAD > 0.3, }, duration: this.config.chunkDuration, startTime: chunk.startTime, endTime: chunk.endTime, averageVad: averageVAD, }; // Record metrics in metrics manager this.metricsManager.recordChunk(metrics); // Emit to listeners this.emit('chunk-processed', metrics); // Call user callback if provided if (this.config.onChunkProcessed) { try { this.config.onChunkProcessed(metrics); } catch (error) { this.logger.error('Error in chunk processed callback:', error); } } this.logger.debug(`Chunk ${chunk.id} processed:`, { duration: `${metrics.duration}ms`, noiseRemoved: `${metrics.noiseRemoved.toFixed(1)}%`, latency: `${metrics.metrics.processingLatency}ms`, }); } /** * Force process remaining samples as final chunk */ flush() { if (this.currentSampleCount > 0) { this.logger.info(`Flushing final chunk with ${this.currentSampleCount} samples`); // Pad with silence if needed const remainingSamples = this.samplesPerChunk - this.currentSampleCount; if (remainingSamples > 0) { this.addSamples(new Float32Array(remainingSamples)); } this.processCurrentChunk(); } this.reset(); } /** * Reset the processor */ reset() { this.currentChunk = []; this.overlapBuffer = []; this.currentSampleCount = 0; this.chunkIndex = 0; this.chunkStartTime = Date.now(); this.logger.debug('ChunkProcessor reset'); } /** * Combine multiple buffers into one */ combineBuffers(buffers) { const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0); const result = new Float32Array(totalLength); let offset = 0; for (const buffer of buffers) { result.set(buffer, offset); offset += buffer.length; } return result; } /** * Get current buffer status */ getStatus() { return { currentSampleCount: this.currentSampleCount, samplesPerChunk: this.samplesPerChunk, chunkIndex: this.chunkIndex, bufferFillPercentage: (this.currentSampleCount / this.samplesPerChunk) * 100, }; } /** * TDD Integration: Process individual frame and accumulate metrics * This allows RecordingManager integration with real-time metrics */ async processFrame(originalFrame, timestamp, processedFrame, vadLevel) { // Set period start time on first frame if (this.accumulatedMetrics.periodStartTime === null) { this.accumulatedMetrics.periodStartTime = timestamp; } // Use processedFrame if provided, otherwise assume no processing (original = processed) const processed = processedFrame || originalFrame; // Calculate noise reduction for this frame const originalRMS = this.metricsManager.calculateRMS(originalFrame); const processedRMS = this.metricsManager.calculateRMS(processed); const frameNoiseReduction = originalRMS > 0 ? ((originalRMS - processedRMS) / originalRMS) * 100 : 0; // Accumulate metrics this.accumulatedMetrics.totalNoiseReduction += Math.max(0, Math.min(100, frameNoiseReduction)); this.accumulatedMetrics.frameCount++; this.accumulatedMetrics.totalLatency += 0.01; // Assume ~0.01ms per frame processing // Accumulate VAD data if (vadLevel !== undefined) { this.accumulatedMetrics.totalVAD += vadLevel; this.accumulatedMetrics.vadReadings.push(vadLevel); } // Emit frame-processed event for temporal tracking this.emit('frame-processed', timestamp); this.logger.debug(`Frame processed: ${frameNoiseReduction.toFixed(1)}% reduction at ${timestamp}ms`); } /** * TDD Integration: Complete current period and emit aggregated metrics * This is called when RecordingManager finishes a recording chunk */ completePeriod(duration) { const endTime = Date.now(); const startTime = this.accumulatedMetrics.periodStartTime || (endTime - duration); const aggregatedMetrics = { averageNoiseReduction: this.accumulatedMetrics.frameCount > 0 ? this.accumulatedMetrics.totalNoiseReduction / this.accumulatedMetrics.frameCount : 0, totalFrames: this.accumulatedMetrics.frameCount, averageLatency: this.accumulatedMetrics.frameCount > 0 ? this.accumulatedMetrics.totalLatency / this.accumulatedMetrics.frameCount : 0, periodDuration: duration, startTime, endTime }; this.logger.info(`Period complete: ${aggregatedMetrics.totalFrames} frames, ${aggregatedMetrics.averageNoiseReduction.toFixed(1)}% avg reduction`); // Emit period-complete event this.emit('period-complete', aggregatedMetrics); // Send metrics through secure event bridge const processingMetrics = { noiseReductionLevel: aggregatedMetrics.averageNoiseReduction, processingLatency: aggregatedMetrics.averageLatency, inputLevel: 0.5, // Default value - should be calculated from actual audio levels outputLevel: 0.5, // Default value - should be calculated from actual audio levels frameCount: aggregatedMetrics.totalFrames, droppedFrames: 0, // Default value - should track actual dropped frames timestamp: aggregatedMetrics.endTime, vadLevel: this.accumulatedMetrics.vadReadings.length > 0 ? this.accumulatedMetrics.vadReadings.reduce((a, b) => a + b, 0) / this.accumulatedMetrics.vadReadings.length : 0.5, isVoiceActive: false // Default value - should be based on VAD analysis }; // Notify through secure bridge this.eventBridge.notifyMetrics(processingMetrics, this.bridgeToken); this.logger.debug('Metrics sent through secure event bridge'); // Reset accumulator for next period this.resetAccumulator(); return aggregatedMetrics; } /** * Reset metrics accumulator for new period */ resetAccumulator() { this.accumulatedMetrics = { totalNoiseReduction: 0, frameCount: 0, totalLatency: 0, periodStartTime: null, totalVAD: 0, vadReadings: [] }; } /** * Get current accumulated metrics without completing the period */ getCurrentAccumulatedMetrics() { if (this.accumulatedMetrics.frameCount === 0) { return null; } const currentTime = Date.now(); const startTime = this.accumulatedMetrics.periodStartTime || currentTime; return { averageNoiseReduction: this.accumulatedMetrics.totalNoiseReduction / this.accumulatedMetrics.frameCount, totalFrames: this.accumulatedMetrics.frameCount, averageLatency: this.accumulatedMetrics.totalLatency / this.accumulatedMetrics.frameCount, periodDuration: currentTime - startTime, startTime, endTime: currentTime }; } } /** * Simple Automatic Gain Control - REFACTORED * Based on WebSearch results for Web Audio API AGC * * Key findings from WebSearch: * - Use DynamicsCompressorNode for reducing dynamics range * - Use AnalyserNode + GainNode for manual AGC * - Always use setTargetAtTime to prevent clicks * - RMS calculation for accurate level detection */ class SimpleAGC { constructor(audioContext, targetLevel = 0.5) { this.audioContext = audioContext; this.targetLevel = targetLevel; this.attackTime = 0.08; // 80ms attack - balanced response for voice this.releaseTime = 0.4; // 400ms release - smooth adaptation this.maxGain = 3.5; // 3.5x maximum gain for safe louder output // Create nodes as per WebSearch recommendation this.analyser = audioContext.createAnalyser(); this.gainNode = audioContext.createGain(); // Configure analyser for time-domain analysis this.analyser.fftSize = 256; this.bufferLength = this.analyser.frequencyBinCount; this.dataArray = new Uint8Array(this.bufferLength); // Connect nodes this.analyser.connect(this.gainNode); } /** * Update gain based on current audio level * Implements attack/release timing as recommended by WebSearch */ updateGain() { const currentRMS = this.calculateRMS(); // Only adjust if we have signal (avoid divide by zero) if (currentRMS > 0) { const targetGain = this.calculateTargetGain(currentRMS); this.applyGainSmoothing(targetGain); } } /** * Calculate RMS (Root Mean Square) level * Formula from WebSearch MDN examples */ calculateRMS() { this.analyser.getByteTimeDomainData(this.dataArray); let sum = 0; for (let i = 0; i < this.bufferLength; i++) { // Convert from 0-255 to -1 to 1 const normalized = (this.dataArray[i] - 128) / 128; sum += normalized * normalized; } return Math.sqrt(sum / this.bufferLength); } /** * Calculate target gain with safety limits */ calculateTargetGain(currentRMS) { const rawGain = this.targetLevel / currentRMS; return Math.min(rawGain, this.maxGain); } /** * Apply gain with proper timing to prevent clicks * Uses exponential ramp as per WebSearch recommendation */ applyGainSmoothing(targetGain) { const currentGain = this.gainNode.gain.value; const isIncreasing = targetGain > currentGain; // Use attack time when increasing, release time when decreasing const timeConstant = isIncreasing ? this.attackTime : this.releaseTime; this.gainNode.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, timeConstant); } /** * Get current gain value for monitoring */ getCurrentGain() { return this.gainNode.gain.value; } /** * Connect source -> analyser -> gain -> destination */ connect(source, destination) { source.connect(this.analyser); this.gainNode.connect(destination); } } class AudioWorkletEngine { constructor(config = {}) { this.name = 'AudioWorklet'; this.description = 'High-performance audio processing using AudioWorklet API'; this.isInitialized = false; this.audioContext = null; this.workletNode = null; this.config = { enableRNNoise: config.enableRNNoise ?? true, rnnoiseWasmUrl: config.rnnoiseWasmUrl }; } isAudioWorkletSupported() { if (typeof window === 'undefined') { return false; } try { // Check if AudioContext exists const AudioContextClass = window.AudioContext || window.webkitAudioContext; if (!AudioContextClass) { return false; } // Check if AudioWorklet class exists if (!window.AudioWorklet) { return false; } // Create a test context to check for audioWorklet property const testContext = new AudioContextClass(); const supported = 'audioWorklet' in testContext; // Clean up test context if it has close method if (testContext.close) { testContext.close(); } return supported; } catch (error) { return false; } } async initialize() { if (this.isInitialized) return; if (!this.isAudioWorkletSupported()) { throw new Error('AudioWorklet is not supported in this browser'); } // Create AudioContext const AudioContextClass = window.AudioContext || window.webkitAudioContext; this.audioContext = new AudioContextClass(); // Load the AudioWorklet processor const processorCode = this.getProcessorCode(); const blob = new Blob([processorCode], { type: 'application/javascript' }); const processorUrl = URL.createObjectURL(blob); try { await this.audioContext.audioWorklet.addModule(processorUrl); this.isInitialized = true; } finally { // Clean up the blob URL URL.revokeObjectURL(processorUrl); } } getProcessorCode() { // This will be the inline AudioWorkletProcessor code return ` class RNNoiseProcessor extends AudioWorkletProcessor { constructor() { super(); this.isActive = true; this.frameSize = 480; // RNNoise frame size this.inputBuffer = new Float32Array(this.frameSize); this.bufferIndex = 0; this.isRNNoiseReady = false; this.rnnoiseModule = null; this.rnnoiseState = null; this.inputPtr = null; this.outputPtr = null; // Performance metrics this.framesProcessed = 0; this.processingTimeSum = 0; this.bufferUnderruns = 0; // Setup message handling this.port.onmessage = (event) => { this.handleMessage(event.data); }; } handleMessage(message) { switch (message.type) { case 'initialize': if (message.data.enableRNNoise) { // Use wasmData if available, otherwise try wasmUrl (will warn) this.initializeRNNoise(message.data.wasmData || message.data.wasmUrl); } break; case 'updateSettings': // Handle settings updates break; case 'loadWASM': // Expects wasmData to be ArrayBuffer, not URL this.initializeRNNoise(message.data.wasmData || message.data.wasmUrl); break; } } async initializeRNNoise(wasmData) { try { // wasmData should be an ArrayBuffer passed from main thread // since fetch is not available in AudioWorklet context if (wasmData && wasmData instanceof ArrayBuffer) { // This is where we would instantiate the WASM module // For now, we just mark it as ready console.log('RNNoise WASM data received in AudioWorklet:', wasmData.byteLength, 'bytes'); // Mark as ready (in real implementation, after WASM is instantiated) this.isRNNoiseReady = true; } else if (typeof wasmData === 'string') { // Legacy path - warn that fetch is not available console.warn('AudioWorklet: Cannot use fetch() to load WASM. Pass ArrayBuffer from main thread instead.'); this.isRNNoiseReady = false; } } catch (error) { console.error('Failed to initialize RNNoise in AudioWorklet:', error); this.isRNNoiseReady = false; } } processFrame(frame) { // This is where RNNoise processing would happen // For now, apply simple gain reduction to simulate noise suppression const processed = new Float32Array(frame.length); for (let i = 0; i < frame.length; i++) { processed[i] = frame[i] * 0.8; // Simulated noise reduction } return processed; } process(inputs, outputs, parameters) { const startTime = currentFrame; // Use currentFrame instead of currentTime const input = inputs[0]; const output = outputs[0]; if (!input || !input[0] || !output || !output[0]) { this.bufferUnderruns++; // Still fill output with silence if (output && output[0]) { output[0].fill(0); } return this.isActive; } const inputChannel = input[0]; const outputChannel = output[0]; const frameLength = inputChannel.length; // Calculate input RMS for VAD and metrics let inputRMS = 0; for (let i = 0; i < frameLength; i++) { inputRMS += inputChannel[i] * inputChannel[i]; } inputRMS = Math.sqrt(inputRMS / frameLength); // Direct processing for better real-time performance if (this.isRNNoiseReady) { // Process in 480-sample chunks for RNNoise let outputIndex = 0; for (let i = 0; i < frameLength; i++) { this.inputBuffer[this.bufferIndex++] = inputChannel[i]; if (this.bufferIndex === this.frameSize) { // Process the frame const processedFrame = this.processFrame(this.inputBuffer); // Copy processed frame to output for (let j = 0; j < this.frameSize && outputIndex < frameLength; j++) { outputChannel[outputIndex++] = processedFrame[j]; } this.bufferIndex = 0; this.framesProcessed++; } } // Handle remaining samples in buffer by copying to output const remainingSamples = frameLength - outputIndex; for (let i = 0; i < remainingSamples && i < this.bufferIndex; i++) { outputChannel[outputIndex + i] = this.inputBuffer[i]; } } else { // Pass-through mode when RNNoise is not ready for (let i = 0; i < frameLength; i++) { outputChannel[i] = inputChannel[i] * 0.8; // Apply slight attenuation } } // Calculate output RMS let outputRMS = 0; for (let i = 0; i < frameLength; i++) { outputRMS += outputChannel[i] * outputChannel[i]; } outputRMS = Math.sqrt(outputRMS / frameLength); // Track performance const endTime = currentFrame; const processingTime = endTime - startTime; this.processingTimeSum += processingTime; // Send real-time metrics more frequently and with more detail if (this.framesProcessed % 10 === 0) { // Send every 10 audio callback frames this.port.postMessage({ type: 'performance', metrics: { processingTime: this.processingTimeSum / 10, bufferUnderruns: this.bufferUnderruns, framesProcessed: this.framesProcessed, inputLevel: inputRMS, outputLevel: outputRMS, noiseReduction: inputRMS > 0 ? (1 - outputRMS / inputRMS) * 100 : 0, vadLevel: inputRMS > 0.01 ? Math.min(inputRMS * 10, 1) : 0, isVoiceActive: inputRMS > 0.03, timestamp: Date.now() } }); this.processingTimeSum = 0; this.bufferUnderruns = 0; } return this.isActive; } } registerProcessor('rnnoise-processor', RNNoiseProcessor); `; } process(inputBuffer) { if (!this.isInitialized) { throw new Error('AudioWorkletEngine not initialized'); } // For now, return the input as-is // This will be replaced with actual AudioWorklet processing return inputBuffer; } async createWorkletNode() { if (!this.isInitialized || !this.audioContext) { throw new Error('AudioWorkletEngine not initialized'); } // Create the AudioWorkletNode this.workletNode = new AudioWorkletNode(this.audioContext, 'rnnoise-processor', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1], processorOptions: { sampleRate: this.audioContext.sampleRate } }); // Set up message handler for performance metrics this.workletNode.port.onmessage = (event) => { if (event.data.type === 'performance' && this.performanceCallback) { this.performanceCallback(event.data.metrics); } }; // Load WASM data in main thread if URL is provided let wasmData = null; if (this.config.enableRNNoise && this.config.rnnoiseWasmUrl) { try { const response = await fetch(this.config.rnnoiseWasmUrl); if (response.ok) { wasmData = await response.arrayBuffer(); console.log('Loaded RNNoise WASM in main thread:', wasmData.byteLength, 'bytes');