Move toju-app into own its folder
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/* 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<MediaStream> {
|
||||
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<MediaStream> {
|
||||
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<void> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user