/* eslint-disable @typescript-eslint/no-non-null-assertion */ /** * Manages RNNoise-based noise reduction for microphone audio. * * Uses the `@timephy/rnnoise-wasm` AudioWorklet to process the raw * microphone stream through a neural-network noise gate, producing * a clean output stream that can be sent to peers instead. * * Architecture: * raw mic -> AudioContext.createMediaStreamSource * -> NoiseSuppressorWorklet (AudioWorkletNode) * -> MediaStreamDestination -> clean MediaStream * * The manager is intentionally stateless w.r.t. Angular signals; * the owning MediaManager / WebRTCService drives signals. */ import { WebRTCLogger } from '../logging/webrtc-logger'; /** Name used to register / instantiate the AudioWorklet processor. */ const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet'; /** RNNoise is trained on 48 kHz audio - the AudioContext must match. */ const RNNOISE_SAMPLE_RATE = 48_000; /** * Relative path (from the served application root) to the **bundled** * worklet script placed in `public/` and served as a static asset. */ const WORKLET_MODULE_PATH = 'rnnoise-worklet.js'; export class NoiseReductionManager { /** The AudioContext used for the noise-reduction graph. */ private audioContext: AudioContext | null = null; /** Source node wrapping the raw microphone stream. */ private sourceNode: MediaStreamAudioSourceNode | null = null; /** The RNNoise AudioWorklet node. */ private workletNode: AudioWorkletNode | null = null; /** Destination node that exposes the cleaned stream. */ private destinationNode: MediaStreamAudioDestinationNode | null = null; /** Whether the worklet module has been loaded into the AudioContext. */ private workletLoaded = false; /** Whether noise reduction is currently active. */ private _isEnabled = false; constructor(private readonly logger: WebRTCLogger) {} /** Whether noise reduction is currently active. */ get isEnabled(): boolean { return this._isEnabled; } /** * Enable noise reduction on a raw microphone stream. * * Builds the AudioWorklet processing graph and returns a new * {@link MediaStream} whose audio has been denoised. * * If the worklet cannot be loaded (e.g. unsupported browser), * the original stream is returned unchanged and an error is logged. * * @param rawStream - The raw `getUserMedia` microphone stream. * @returns A denoised {@link MediaStream}, or the original if setup fails. */ async enable(rawStream: MediaStream): Promise { if (this._isEnabled && this.destinationNode) { this.logger.info('Noise reduction already enabled, returning existing clean stream'); return this.destinationNode.stream; } try { await this.buildProcessingGraph(rawStream); this._isEnabled = true; this.logger.info('Noise reduction enabled'); return this.destinationNode!.stream; } catch (err) { this.logger.error('Failed to enable noise reduction, returning raw stream', err); this.teardownGraph(); return rawStream; } } /** * Disable noise reduction and tear down the processing graph. * * After calling this, the original raw microphone stream should be * used again (the caller is responsible for re-binding tracks). */ disable(): void { if (!this._isEnabled) return; this.teardownGraph(); this._isEnabled = false; this.logger.info('Noise reduction disabled'); } /** * Re-pipe a new raw stream through the existing noise-reduction graph. * * Useful when the microphone device changes but noise reduction * should stay active. * * @param rawStream - The new raw microphone stream. * @returns The denoised stream, or the raw stream on failure. */ async replaceInputStream(rawStream: MediaStream): Promise { if (!this._isEnabled) return rawStream; try { // Disconnect old source but keep the rest of the graph alive this.sourceNode?.disconnect(); if (!this.audioContext || !this.workletNode || !this.destinationNode) { throw new Error('Processing graph not initialised'); } this.sourceNode = this.audioContext.createMediaStreamSource(rawStream); this.sourceNode.connect(this.workletNode); this.logger.info('Noise reduction input stream replaced'); return this.destinationNode.stream; } catch (err) { this.logger.error('Failed to replace noise reduction input', err); return rawStream; } } /** Clean up all resources. Safe to call multiple times. */ destroy(): void { this.disable(); this.audioContext = null; this.workletLoaded = false; } /** * Build the AudioWorklet processing graph: * rawStream -> source -> workletNode -> destination */ private async buildProcessingGraph(rawStream: MediaStream): Promise { // Reuse or create the AudioContext (must be 48 kHz for RNNoise) if (!this.audioContext || this.audioContext.state === 'closed') { this.audioContext = new AudioContext({ sampleRate: RNNOISE_SAMPLE_RATE }); this.workletLoaded = false; } // Resume if suspended (browsers auto-suspend until user gesture) if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); } // Load the worklet module once per AudioContext lifetime if (!this.workletLoaded) { await this.audioContext.audioWorklet.addModule(WORKLET_MODULE_PATH); this.workletLoaded = true; this.logger.info('RNNoise worklet module loaded'); } // Build the node graph this.sourceNode = this.audioContext.createMediaStreamSource(rawStream); this.workletNode = new AudioWorkletNode(this.audioContext, WORKLET_PROCESSOR_NAME); this.destinationNode = this.audioContext.createMediaStreamDestination(); this.sourceNode.connect(this.workletNode).connect(this.destinationNode); } /** Disconnect and release all graph nodes. */ private teardownGraph(): void { try { this.sourceNode?.disconnect(); } catch (error) { this.logger.warn('Noise reduction source node already disconnected', error); } try { this.workletNode?.disconnect(); } catch (error) { this.logger.warn('Noise reduction worklet node already disconnected', error); } try { this.destinationNode?.disconnect(); } catch (error) { this.logger.warn('Noise reduction destination node already disconnected', error); } this.sourceNode = null; this.workletNode = null; this.destinationNode = null; // Close the context to free hardware resources if (this.audioContext && this.audioContext.state !== 'closed') { this.audioContext.close().catch((error) => { this.logger.warn('Failed to close RNNoise audio context', error); }); } this.audioContext = null; this.workletLoaded = false; } }