murmuraba
Version:
Real-time audio noise reduction with advanced chunked processing for web applications
230 lines (229 loc) • 10.6 kB
JavaScript
import { useRef, useState } from 'react';
import { createAudioEngine } from '../engines';
export const useAudioEngine = (config = { engineType: 'rnnoise' }) => {
console.warn('[Murmuraba] useAudioEngine is deprecated. Please use useMurmubaraEngine instead for better React 19 compatibility.');
const [isInitialized, setIsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const audioContextRef = useRef(null);
const processorRef = useRef(null);
const engineRef = useRef(null);
const engineDataRef = useRef(null);
const metricsRef = useRef({
inputSamples: 0,
outputSamples: 0,
silenceFrames: 0,
activeFrames: 0,
totalInputEnergy: 0,
totalOutputEnergy: 0,
peakInput: 0,
peakOutput: 0,
startTime: 0,
totalFrames: 0
});
const initializeAudioEngine = async () => {
if (isInitialized || isLoading)
return;
setIsLoading(true);
setError(null);
try {
console.log('[AudioEngine] Creating audio engine with config:', config);
// Create engine instance
const engine = createAudioEngine(config);
await engine.initialize();
engineRef.current = engine;
// Initialize engine-specific data
engineDataRef.current = {
inputBuffer: [],
outputBuffer: [],
energyHistory: new Array(20).fill(0),
energyIndex: 0
};
console.log('[AudioEngine] Engine ready for processing');
// Create audio context
audioContextRef.current = new AudioContext({ sampleRate: 48000 });
// Create processor
const processor = audioContextRef.current.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0);
const output = e.outputBuffer.getChannelData(0);
if (!engineRef.current || !engineDataRef.current) {
output.set(input);
return;
}
// Track input metrics
metricsRef.current.inputSamples += input.length;
// Add to input buffer
for (let i = 0; i < input.length; i++) {
engineDataRef.current.inputBuffer.push(input[i]);
metricsRef.current.peakInput = Math.max(metricsRef.current.peakInput, Math.abs(input[i]));
}
// Process chunks of 480 samples
while (engineDataRef.current.inputBuffer.length >= 480) {
const frame = engineDataRef.current.inputBuffer.splice(0, 480);
const floatFrame = new Float32Array(frame);
// Process with engine
const outputData = engineRef.current.process(floatFrame);
// Calculate frame energy for gating
const frameEnergy = calculateRMS(floatFrame);
const outputEnergy = calculateRMS(outputData);
// Track frame metrics
metricsRef.current.totalFrames++;
metricsRef.current.totalInputEnergy += frameEnergy;
metricsRef.current.totalOutputEnergy += outputEnergy;
// Update energy history
engineDataRef.current.energyHistory[engineDataRef.current.energyIndex] = frameEnergy;
engineDataRef.current.energyIndex = (engineDataRef.current.energyIndex + 1) % 20;
// Calculate average energy
const avgEnergy = engineDataRef.current.energyHistory.reduce((a, b) => a + b) / 20;
// Simple energy-based gating
let processedFrame = outputData;
const silenceThreshold = 0.001;
const speechThreshold = 0.005;
let wasSilenced = false;
if (avgEnergy < silenceThreshold) {
// Very quiet - attenuate heavily
processedFrame = processedFrame.map(s => s * 0.1);
wasSilenced = true;
metricsRef.current.silenceFrames++;
}
else if (avgEnergy < speechThreshold) {
// Quiet - moderate attenuation
const factor = (avgEnergy - silenceThreshold) / (speechThreshold - silenceThreshold);
const attenuation = 0.1 + 0.9 * factor;
processedFrame = processedFrame.map(s => s * attenuation);
metricsRef.current.activeFrames++;
}
else {
metricsRef.current.activeFrames++;
}
// Additional noise gate based on RNNoise output vs input ratio
const reductionRatio = outputEnergy / (frameEnergy + 0.0001);
if (reductionRatio < 0.3 && avgEnergy < speechThreshold) {
// RNNoise reduced significantly - likely noise
processedFrame = processedFrame.map(s => s * reductionRatio);
if (!wasSilenced)
metricsRef.current.silenceFrames++;
}
// Log occasionally
if (Math.random() < 0.02) {
const gateStatus = avgEnergy < silenceThreshold ? 'SILENCE' :
avgEnergy < speechThreshold ? 'TRANSITION' : 'SPEECH';
console.log('[AudioEngine]', '\n Status:', gateStatus, '\n Avg Energy:', avgEnergy.toFixed(6), '\n Frame Energy:', frameEnergy.toFixed(6), '\n Engine Reduction:', ((1 - reductionRatio) * 100).toFixed(1) + '%', '\n Gate Applied:', avgEnergy < speechThreshold ? 'Yes' : 'No');
}
// Add to output buffer
for (let i = 0; i < 480; i++) {
engineDataRef.current.outputBuffer.push(processedFrame[i]);
}
}
// Output
for (let i = 0; i < output.length; i++) {
if (engineDataRef.current.outputBuffer.length > 0) {
const sample = engineDataRef.current.outputBuffer.shift();
output[i] = sample;
metricsRef.current.outputSamples++;
metricsRef.current.peakOutput = Math.max(metricsRef.current.peakOutput, Math.abs(sample));
}
else {
output[i] = 0;
}
}
};
processorRef.current = processor;
setIsInitialized(true);
console.log('[AudioEngine] Initialization complete!');
}
catch (err) {
console.error('[AudioEngine] Error:', err);
setError(err instanceof Error ? err.message : String(err));
throw err;
}
finally {
setIsLoading(false);
}
};
const resetMetrics = () => {
metricsRef.current = {
inputSamples: 0,
outputSamples: 0,
silenceFrames: 0,
activeFrames: 0,
totalInputEnergy: 0,
totalOutputEnergy: 0,
peakInput: 0,
peakOutput: 0,
startTime: Date.now(),
totalFrames: 0
};
};
const getMetrics = () => {
const metrics = metricsRef.current;
const processingTime = Date.now() - metrics.startTime;
const avgInputEnergy = metrics.totalFrames > 0 ? metrics.totalInputEnergy / metrics.totalFrames : 0;
const avgOutputEnergy = metrics.totalFrames > 0 ? metrics.totalOutputEnergy / metrics.totalFrames : 0;
// Calculate noise reduction differently - compare silence frames to total frames
// and consider the energy reduction ratio
const energyReduction = avgInputEnergy > 0 ? Math.abs(avgInputEnergy - avgOutputEnergy) / avgInputEnergy : 0;
const silenceRatio = metrics.totalFrames > 0 ? metrics.silenceFrames / metrics.totalFrames : 0;
// Combine both metrics for a more accurate noise reduction estimate
const noiseReduction = ((energyReduction * 0.5) + (silenceRatio * 0.5)) * 100;
return {
inputSamples: metrics.inputSamples,
outputSamples: metrics.outputSamples,
noiseReductionLevel: Math.max(0, Math.min(100, noiseReduction)),
silenceFrames: metrics.silenceFrames,
activeFrames: metrics.activeFrames,
averageInputEnergy: avgInputEnergy,
averageOutputEnergy: avgOutputEnergy,
peakInputLevel: metrics.peakInput,
peakOutputLevel: metrics.peakOutput,
processingTimeMs: processingTime,
chunkOffset: 0,
totalFramesProcessed: metrics.totalFrames
};
};
const processStream = async (stream) => {
if (!isInitialized) {
await initializeAudioEngine();
}
if (!audioContextRef.current || !processorRef.current) {
throw new Error('Not initialized');
}
// Reset metrics when starting new stream
resetMetrics();
const source = audioContextRef.current.createMediaStreamSource(stream);
const destination = audioContextRef.current.createMediaStreamDestination();
source.connect(processorRef.current);
processorRef.current.connect(destination);
return destination.stream;
};
const cleanup = () => {
if (processorRef.current) {
processorRef.current.disconnect();
}
if (engineRef.current) {
engineRef.current.cleanup();
engineRef.current = null;
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
}
};
return {
isInitialized,
isLoading,
error,
processStream,
cleanup,
initializeAudioEngine,
getMetrics,
resetMetrics
};
};
function calculateRMS(frame) {
let sum = 0;
for (let i = 0; i < frame.length; i++) {
sum += frame[i] * frame[i];
}
return Math.sqrt(sum / frame.length);
}