murmuraba
Version:
Real-time audio noise reduction with advanced chunked processing for web applications
1,350 lines (1,316 loc) • 2.19 MB
JavaScript
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');