sls/resources/js/noise-suppressor.js

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);
}
}