/** * RNNoise WebAssembly & AudioWorklet Real-Time Noise Suppression Engine * * Provides neural-network-powered speech enhancement and background noise suppression * for WebRTC audio streams (candidate and interviewer calls) using Xiph's RNNoise. */ import { RnnoiseWorkletNode, loadRnnoise } from '@sapphi-red/web-noise-suppressor'; import rnnoiseWorkletUrl from '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url'; import rnnoiseWasmUrl from '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url'; import rnnoiseSimdWasmUrl from '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url'; // Cache loaded WASM binary and worklet registration across audio contexts let cachedWasmBinaryPromise = null; const registeredAudioContexts = new WeakSet(); /** * Check if the browser environment supports AudioWorklet & WebAssembly */ export function isRNNoiseSupported() { return typeof window !== 'undefined' && typeof window.AudioContext !== 'undefined' && typeof window.WebAssembly !== 'undefined' && typeof AudioWorkletNode !== 'undefined'; } /** * Get user preference for RNNoise noise suppression (default: enabled) */ export function getRNNoisePreference() { if (typeof localStorage === 'undefined') return true; const pref = localStorage.getItem('sls_rnnoise_enabled'); return pref === null ? true : pref === 'true'; } /** * Save user preference for RNNoise noise suppression */ export function setRNNoisePreference(enabled) { if (typeof localStorage !== 'undefined') { localStorage.setItem('sls_rnnoise_enabled', enabled ? 'true' : 'false'); } } /** * Load the RNNoise WASM binary (with SIMD detection fallback) */ async function getWasmBinary() { if (!cachedWasmBinaryPromise) { cachedWasmBinaryPromise = loadRnnoise({ url: rnnoiseWasmUrl, simdUrl: rnnoiseSimdWasmUrl, }).catch((err) => { console.warn('[RNNoise] Failed to load WASM binary:', err); cachedWasmBinaryPromise = null; throw err; }); } return cachedWasmBinaryPromise; } /** * Register the RNNoise AudioWorklet module in the given AudioContext */ async function registerWorkletModule(audioCtx) { if (!registeredAudioContexts.has(audioCtx)) { await audioCtx.audioWorklet.addModule(rnnoiseWorkletUrl); registeredAudioContexts.add(audioCtx); } } /** * Noise Suppressor Controller for an active MediaStream */ export class NoiseSuppressorController { constructor({ audioCtx, sourceNode, rnnoiseNode, noiseGain, bypassGain, destinationNode, rawStream, processedStream, initialEnabled = true, }) { this.audioCtx = audioCtx; this.sourceNode = sourceNode; this.rnnoiseNode = rnnoiseNode; this.noiseGain = noiseGain; this.bypassGain = bypassGain; this.destinationNode = destinationNode; this.rawStream = rawStream; this.processedStream = processedStream; this._enabled = initialEnabled; this._disposed = false; this.applyState(initialEnabled, true); } /** * Enable or disable noise suppression with smooth audio gain transition */ setEnabled(enabled) { if (this._disposed) return; this._enabled = Boolean(enabled); setRNNoisePreference(this._enabled); this.applyState(this._enabled, false); } applyState(enabled, immediate = false) { if (!this.noiseGain || !this.bypassGain || !this.audioCtx) return; const now = this.audioCtx.currentTime; const transitionDuration = immediate ? 0 : 0.02; // 20ms click-free cross-fade if (enabled) { // Enable RNNoise path, disable raw bypass this.bypassGain.gain.setValueAtTime(this.bypassGain.gain.value, now); this.bypassGain.gain.linearRampToValueAtTime(0.0, now + transitionDuration); this.noiseGain.gain.setValueAtTime(this.noiseGain.gain.value, now); this.noiseGain.gain.linearRampToValueAtTime(1.0, now + transitionDuration); } else { // Disable RNNoise path, enable raw bypass this.noiseGain.gain.setValueAtTime(this.noiseGain.gain.value, now); this.noiseGain.gain.linearRampToValueAtTime(0.0, now + transitionDuration); this.bypassGain.gain.setValueAtTime(this.bypassGain.gain.value, now); this.bypassGain.gain.linearRampToValueAtTime(1.0, now + transitionDuration); } } toggle() { this.setEnabled(!this._enabled); return this._enabled; } isEnabled() { return this._enabled; } getProcessedStream() { return this.processedStream || this.rawStream; } getProcessedAudioTrack() { if (this.processedStream) { const tracks = this.processedStream.getAudioTracks(); if (tracks.length > 0) return tracks[0]; } if (this.rawStream) { const tracks = this.rawStream.getAudioTracks(); if (tracks.length > 0) return tracks[0]; } return null; } getOriginalAudioTrack() { if (this.rawStream) { const tracks = this.rawStream.getAudioTracks(); if (tracks.length > 0) return tracks[0]; } return null; } dispose() { if (this._disposed) return; this._disposed = true; try { if (this.sourceNode) this.sourceNode.disconnect(); if (this.rnnoiseNode) { this.rnnoiseNode.disconnect(); if (typeof this.rnnoiseNode.destroy === 'function') { this.rnnoiseNode.destroy(); } } if (this.noiseGain) this.noiseGain.disconnect(); if (this.bypassGain) this.bypassGain.disconnect(); if (this.destinationNode) this.destinationNode.disconnect(); } catch (e) { console.warn('[RNNoise] Cleanup warning:', e); } if (this.processedStream) { this.processedStream.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} }); } if (this.audioCtx && this.audioCtx.state !== 'closed') { try { this.audioCtx.close(); } catch (e) {} } } } /** * Fallback Controller when RNNoise is unavailable or fails */ class FallbackNoiseSuppressorController { constructor(rawStream) { this.rawStream = rawStream; this._enabled = false; } setEnabled(enabled) { this._enabled = Boolean(enabled); } toggle() { this._enabled = !this._enabled; return this._enabled; } isEnabled() { return this._enabled; } getProcessedStream() { return this.rawStream; } getProcessedAudioTrack() { return this.rawStream?.getAudioTracks()[0] || null; } getOriginalAudioTrack() { return this.rawStream?.getAudioTracks()[0] || null; } dispose() {} } /** * Create a noise-suppressed MediaStream from a raw microphone MediaStream * * @param {MediaStream} rawStream - Input media stream with microphone audio * @param {Object} options - Optional configuration * @returns {Promise} Controller with processed stream */ export async function createNoiseSuppressedStream(rawStream, options = {}) { if (!rawStream || !rawStream.getAudioTracks || rawStream.getAudioTracks().length === 0) { return new FallbackNoiseSuppressorController(rawStream); } if (!isRNNoiseSupported()) { console.warn('[RNNoise] WebAudio AudioWorklet / WASM not supported in this browser, using standard audio'); return new FallbackNoiseSuppressorController(rawStream); } try { const AudioCtxClass = window.AudioContext || window.webkitAudioContext; // RNNoise is trained on 48,000Hz (48kHz) audio. Create a 48kHz audio context. const audioCtx = new AudioCtxClass({ sampleRate: 48000 }); if (audioCtx.state === 'suspended') { await audioCtx.resume().catch(() => {}); } // Parallelize WASM binary loading & worklet module registration with timeout const wasmPromise = getWasmBinary(); const workletPromise = registerWorkletModule(audioCtx); // Fail-safe 3500ms timeout const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('RNNoise init timeout')), 3500) ); const [wasmBinary] = await Promise.race([ Promise.all([wasmPromise, workletPromise]), timeoutPromise, ]); const sourceNode = audioCtx.createMediaStreamSource(rawStream); const rnnoiseNode = new RnnoiseWorkletNode(audioCtx, { maxChannels: 1, wasmBinary, }); const noiseGain = audioCtx.createGain(); const bypassGain = audioCtx.createGain(); const destinationNode = audioCtx.createMediaStreamDestination(); // Connect RNNoise path: Source -> RNNoise -> NoiseGain -> Destination sourceNode.connect(rnnoiseNode); rnnoiseNode.connect(noiseGain); noiseGain.connect(destinationNode); // Connect Bypass path: Source -> BypassGain -> Destination sourceNode.connect(bypassGain); bypassGain.connect(destinationNode); const processedStream = destinationNode.stream; const initialEnabled = options.enabled !== undefined ? options.enabled : getRNNoisePreference(); console.log('[RNNoise] Audio suppressor initialized successfully (enabled:', initialEnabled, ')'); return new NoiseSuppressorController({ audioCtx, sourceNode, rnnoiseNode, noiseGain, bypassGain, destinationNode, rawStream, processedStream, initialEnabled, }); } catch (err) { console.warn('[RNNoise] Failed to initialize noise suppression worklet, falling back to raw audio:', err); return new FallbackNoiseSuppressorController(rawStream); } }