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 masterBoost: GainNode | 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 = true; private streamGeneration = 0; private inputSwitchChain: Promise = Promise.resolve(null); private rnnoiseNode: AudioWorkletNode | null = null; private stereoMerger: ChannelMergerNode | null = null; private rnnoiseEnabled = false; private rnnoiseReady = false; private keepAliveOscillator: OscillatorNode | null = null; // Mic test (settings → Voice). See startMicTest(). private micTestGain: GainNode | null = null; private micTestStream: MediaStream | null = null; // Cached `getUserMedia` denial. After a NotAllowedError, subsequent // `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's // tap on a denial prompt) re-throw the cached error WITHOUT issuing a // second `getUserMedia` — iOS Safari otherwise queues a second permission // prompt that has lost its user-gesture activation, which on iOS PWA // standalone leads to a permanently hung silent prompt. The cache is // cleared by `clearInputDenial()` (called from the user-gesture-driven // `requestMicPermission` retry path in `utils/voice.ts`) so a fresh user // gesture can re-attempt cleanly. private inputDenialError: Error | null = null; // Subscribers notified when the *upstream* getUserMedia track ends unexpectedly // (hardware unplug, OS-level revoke, system audio service crash). Distinct from // the published mic track's `onended` — the published track is a clone of the // WebAudio destination node, which never ends on upstream loss. private inputTrackEndedListeners: Set<(reason: 'unplug' | 'revoke' | 'unknown') => void> = new Set(); 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(); 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 // +3dB boost before the limiter — drives a hotter signal into the // compressor, raising perceived loudness while peaks are still caught. this.masterBoost = this.ctx.createGain(); this.masterBoost.gain.value = 1.41; // +3dB this.masterBoost.connect(this.masterCompressor); 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); // Safari suspends the AudioContext when it detects no audible output, // even while WebRTC audio is flowing through the pipeline. A sub-bass // oscillator at near-zero gain keeps the rendering thread alive without // producing audible sound. this.keepAliveOscillator = this.ctx.createOscillator(); this.keepAliveOscillator.frequency.value = 20; const keepAliveGain = this.ctx.createGain(); keepAliveGain.gain.value = 0.00001; this.keepAliveOscillator.connect(keepAliveGain); keepAliveGain.connect(this.ctx.destination); this.keepAliveOscillator.start(); 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