import { RnnoiseWorkletNode, loadRnnoise } from '@sapphi-red/web-noise-suppressor'; import rnnoiseWorkletPath from '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url'; import rnnoiseWasmPath from '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url'; import rnnoiseWasmSimdPath from '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url'; export class AudioManager { private static instance: AudioManager | null = null; private ctx: AudioContext | null = null; private inputGain: GainNode | null = null; private inputSource: MediaStreamAudioSourceNode | null = null; private inputDestination: MediaStreamAudioDestinationNode | null = null; private silentGain: GainNode | null = null; private analyser: AnalyserNode | null = null; private masterCompressor: DynamicsCompressorNode | null = null; private currentInputDeviceId: string = 'default'; private desiredOutputDeviceId: string = 'default'; private currentStream: MediaStream | null = null; private isInitialized = false; private listeners: Set<() => void> = new Set(); private soundBuffers: Map = new Map(); private voiceEchoCancellation = true; private voiceNoiseSuppression = true; private voiceAutoGainControl = false; private screenShareActive = false; private streamGeneration = 0; private inputSwitchChain: Promise = Promise.resolve(null); private rnnoiseNode: AudioWorkletNode | null = null; private rnnoiseEnabled = false; private rnnoiseReady = false; private constructor() {} static getInstance(): AudioManager { if (!AudioManager.instance) { AudioManager.instance = new AudioManager(); } return AudioManager.instance; } private initContext() { if (this.ctx) return; const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; this.ctx = new AudioContextClass({ sampleRate: 48000 }); this.inputGain = this.ctx.createGain(); // Force stereo up-mix so mono sources (e.g. RNNoise worklet output) // are duplicated to both L+R channels instead of left-only. this.inputGain.channelCount = 2; this.inputGain.channelCountMode = 'explicit'; this.inputGain.channelInterpretation = 'speakers'; this.inputDestination = this.ctx.createMediaStreamDestination(); this.analyser = this.ctx.createAnalyser(); this.analyser.fftSize = 256; this.silentGain = this.ctx.createGain(); this.silentGain.gain.value = 0; // Master compressor/limiter — prevents clipping when multiple // audio sources (voice + stream) sum at the output. this.masterCompressor = this.ctx.createDynamicsCompressor(); this.masterCompressor.threshold.value = -1; // only engage near digital clipping this.masterCompressor.knee.value = 0.5; // hard knee — transparent below threshold this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks this.masterCompressor.release.value = 0.01; // 10ms — recover quickly this.masterCompressor.connect(this.ctx.destination); this.inputGain.connect(this.inputDestination); this.inputGain.connect(this.analyser); this.inputGain.connect(this.silentGain); this.silentGain.connect(this.ctx.destination); this.inputGain.gain.setValueAtTime(1, this.ctx.currentTime); this.ctx.onstatechange = () => { console.log(`[AudioManager] Context state: ${this.ctx?.state}`); if (this.ctx?.state === 'running') { this.notifyResumed(); } }; this.isInitialized = true; // Apply pending output device selection (user preference loaded before context creation) this.applyOutputDevice(); } /** * Routes all Web Audio output to the specified device via AudioContext.setSinkId(). * This is the ONLY correct way to switch output devices when using a custom Web Audio * pipeline — LiveKit's switchActiveDevice('audiooutput') targets