298 lines
10 KiB
JavaScript
298 lines
10 KiB
JavaScript
/**
|
|
* 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<NoiseSuppressorController>} 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);
|
|
}
|
|
}
|