Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

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