murmuraba
Version:
Real-time audio noise reduction with advanced chunked processing for web applications
400 lines (378 loc) • 15.6 kB
JavaScript
export 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');
}
}
catch (error) {
console.warn('Failed to load RNNoise WASM in main thread:', error);
}
}
// Send initialization message with ArrayBuffer instead of URL
this.workletNode.port.postMessage({
type: 'initialize',
data: {
enableRNNoise: this.config.enableRNNoise,
wasmData: wasmData // Send ArrayBuffer, not URL
}
});
return this.workletNode;
}
async processWithWorklet(inputBuffer) {
if (!this.isInitialized || !this.audioContext) {
throw new Error('AudioWorkletEngine not initialized');
}
// Create offline context for processing
const offlineContext = new OfflineAudioContext(1, // mono
inputBuffer.length, this.audioContext.sampleRate);
// Create buffer source
const audioBuffer = offlineContext.createBuffer(1, inputBuffer.length, offlineContext.sampleRate);
audioBuffer.copyToChannel(inputBuffer, 0);
const source = offlineContext.createBufferSource();
source.buffer = audioBuffer;
// Load worklet in offline context
const processorCode = this.getProcessorCode();
const blob = new Blob([processorCode], { type: 'application/javascript' });
const processorUrl = URL.createObjectURL(blob);
try {
await offlineContext.audioWorklet.addModule(processorUrl);
// Create worklet node in offline context
const workletNode = new AudioWorkletNode(offlineContext, 'rnnoise-processor', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1]
});
// Connect nodes
source.connect(workletNode);
workletNode.connect(offlineContext.destination);
// Start and render
source.start();
const renderedBuffer = await offlineContext.startRendering();
// Extract the processed audio
const outputBuffer = new Float32Array(inputBuffer.length);
renderedBuffer.copyFromChannel(outputBuffer, 0);
return outputBuffer;
}
finally {
URL.revokeObjectURL(processorUrl);
}
}
async createStreamProcessor(stream) {
if (!this.isInitialized || !this.audioContext) {
throw new Error('AudioWorkletEngine not initialized');
}
if (!this.workletNode) {
await this.createWorkletNode(); // Now properly await the async function
}
// Create media stream source
const source = this.audioContext.createMediaStreamSource(stream);
// Connect to worklet
source.connect(this.workletNode);
// Connect to destination (for monitoring)
this.workletNode.connect(this.audioContext.destination);
return source;
}
sendToWorklet(message) {
if (!this.workletNode) {
throw new Error('Worklet node not created');
}
this.workletNode.port.postMessage(message);
}
onPerformanceMetrics(callback) {
this.performanceCallback = callback;
}
async createProcessingPipeline(constraints = {}) {
if (!this.isInitialized || !this.audioContext) {
throw new Error('AudioWorkletEngine not initialized');
}
// Get user media
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: constraints.echoCancellation ?? true,
noiseSuppression: false, // We use RNNoise instead
autoGainControl: constraints.autoGainControl ?? true,
...constraints
}
});
// Create nodes
const input = this.audioContext.createMediaStreamSource(stream);
const destination = this.audioContext.createMediaStreamDestination();
if (!this.workletNode) {
await this.createWorkletNode(); // Now properly await the async function
}
// Connect pipeline
input.connect(this.workletNode);
this.workletNode.connect(destination);
return {
input,
output: destination.stream,
workletNode: this.workletNode
};
}
getSupportedFeatures() {
return {
audioWorklet: this.isAudioWorkletSupported(),
offlineProcessing: typeof OfflineAudioContext !== 'undefined',
realtimeProcessing: true,
performanceMetrics: true,
wasmSupport: typeof WebAssembly !== 'undefined'
};
}
cleanup() {
if (this.workletNode) {
this.workletNode.disconnect();
this.workletNode = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.isInitialized = false;
}
}