/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars,, id-length */ /** * Manages local voice media: getUserMedia, mute, deafen, * attaching/detaching audio tracks to peer connections, bitrate tuning, * and optional RNNoise-based noise reduction. */ import { Subject } from 'rxjs'; import { ChatEvent } from '../../models'; import { WebRTCLogger } from './webrtc-logger'; import { PeerData } from './webrtc.types'; import { NoiseReductionManager } from './noise-reduction.manager'; import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO, TRANSCEIVER_SEND_RECV, TRANSCEIVER_RECV_ONLY, TRANSCEIVER_INACTIVE, AUDIO_BITRATE_MIN_BPS, AUDIO_BITRATE_MAX_BPS, KBPS_TO_BPS, LATENCY_PROFILE_BITRATES, VOLUME_MIN, VOLUME_MAX, VOICE_HEARTBEAT_INTERVAL_MS, DEFAULT_DISPLAY_NAME, P2P_TYPE_VOICE_STATE, LatencyProfile } from './webrtc.constants'; /** * Callbacks the MediaManager needs from the owning service / peer manager. */ export interface MediaManagerCallbacks { /** All active peer connections (for attaching tracks). */ getActivePeers(): Map; /** Trigger SDP renegotiation for a specific peer. */ renegotiate(peerId: string): Promise; /** Broadcast a message to all peers. */ broadcastMessage(event: ChatEvent): void; /** Get identify credentials (for broadcasting). */ getIdentifyOderId(): string; getIdentifyDisplayName(): string; } export class MediaManager { /** The stream sent to peers (may be raw or denoised). */ private localMediaStream: MediaStream | null = null; /** * The raw microphone stream from `getUserMedia`. * Kept separately so noise reduction can be toggled * without re-acquiring the mic. */ private rawMicStream: MediaStream | null = null; /** Remote audio output volume (0-1). */ private remoteAudioVolume = VOLUME_MAX; // -- Input gain pipeline (mic volume) -- /** The stream BEFORE gain is applied (for identity checks). */ private preGainStream: MediaStream | null = null; private inputGainCtx: AudioContext | null = null; private inputGainSourceNode: MediaStreamAudioSourceNode | null = null; private inputGainNode: GainNode | null = null; private inputGainDest: MediaStreamAudioDestinationNode | null = null; /** Normalised 0-1 input gain (1 = 100%). */ private inputGainVolume = 1.0; /** Voice-presence heartbeat timer. */ private voicePresenceTimer: ReturnType | null = null; /** Emitted when voice is successfully connected. */ readonly voiceConnected$ = new Subject(); /** RNNoise noise-reduction processor. */ private readonly noiseReduction: NoiseReductionManager; /** * Tracks the user's *desired* noise-reduction state, independent of * whether the worklet is actually running. This lets us honour the * preference even when it is set before the mic stream is acquired. */ private _noiseReductionDesired = true; // State tracked locally (the service exposes these via signals) private isVoiceActive = false; private isMicMuted = false; private isSelfDeafened = false; /** Current voice channel room ID (set when joining voice). */ private currentVoiceRoomId: string | undefined; /** Current voice channel server ID (set when joining voice). */ private currentVoiceServerId: string | undefined; constructor( private readonly logger: WebRTCLogger, private callbacks: MediaManagerCallbacks ) { this.noiseReduction = new NoiseReductionManager(logger); } /** * Replace the callback set at runtime. * Needed because of circular initialisation between managers. * * @param nextCallbacks - The new callback interface to wire into this manager. */ setCallbacks(nextCallbacks: MediaManagerCallbacks): void { this.callbacks = nextCallbacks; } /** Returns the current local media stream, or `null` if voice is disabled. */ getLocalStream(): MediaStream | null { return this.localMediaStream; } /** Returns the raw microphone stream before processing, if available. */ getRawMicStream(): MediaStream | null { return this.rawMicStream; } /** Whether voice is currently active (mic captured). */ getIsVoiceActive(): boolean { return this.isVoiceActive; } /** Whether the local microphone is muted. */ getIsMicMuted(): boolean { return this.isMicMuted; } /** Whether the user has self-deafened. */ getIsSelfDeafened(): boolean { return this.isSelfDeafened; } /** Current remote audio output volume (normalised 0-1). */ getRemoteAudioVolume(): number { return this.remoteAudioVolume; } /** The voice channel room ID, if currently in voice. */ getCurrentVoiceRoomId(): string | undefined { return this.currentVoiceRoomId; } /** The voice channel server ID, if currently in voice. */ getCurrentVoiceServerId(): string | undefined { return this.currentVoiceServerId; } /** Whether the user wants noise reduction (may or may not be running yet). */ getIsNoiseReductionEnabled(): boolean { return this._noiseReductionDesired; } /** * Request microphone access via `getUserMedia` and bind the resulting * audio track to every active peer connection. * * If a local stream already exists it is stopped first. * * @returns The captured {@link MediaStream}. * @throws If `getUserMedia` is unavailable (non-secure context) or the user denies access. */ async enableVoice(): Promise { try { // Stop any existing stream first if (this.localMediaStream) { this.logger.info('Stopping existing local stream before enabling voice'); this.localMediaStream.getTracks().forEach((track) => track.stop()); this.localMediaStream = null; } const mediaConstraints: MediaStreamConstraints = { audio: { echoCancellation: true, noiseSuppression: !this._noiseReductionDesired, autoGainControl: true }, video: false }; this.logger.info('getUserMedia constraints', mediaConstraints); if (!navigator.mediaDevices?.getUserMedia) { throw new Error( 'navigator.mediaDevices is not available. ' + 'This requires a secure context (HTTPS or localhost). ' + 'If accessing from an external device, use HTTPS.' ); } const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints); this.rawMicStream = stream; // If the user wants noise reduction, pipe through the denoiser this.localMediaStream = this._noiseReductionDesired ? await this.noiseReduction.enable(stream) : stream; // Apply input gain (mic volume) before sending to peers this.applyInputGainToCurrentStream(); this.logger.logStream('localVoice', this.localMediaStream); this.bindLocalTracksToAllPeers(); this.isVoiceActive = true; this.voiceConnected$.next(); return this.localMediaStream; } catch (error) { this.logger.error('Failed to getUserMedia', error); throw error; } } /** * Stop all local media tracks and remove audio senders from peers. * The peer connections themselves are kept alive. */ disableVoice(): void { this.noiseReduction.disable(); this.teardownInputGain(); // Stop the raw mic tracks (the denoised stream's tracks are // derived nodes and will stop once their source is gone). if (this.rawMicStream) { this.rawMicStream.getTracks().forEach((track) => track.stop()); this.rawMicStream = null; } this.localMediaStream = null; // Remove audio senders but keep connections alive this.callbacks.getActivePeers().forEach((peerData) => { const senders = peerData.connection.getSenders(); senders.forEach((sender) => { if (sender.track?.kind === TRACK_KIND_AUDIO) { peerData.connection.removeTrack(sender); } }); }); this.isVoiceActive = false; this.currentVoiceRoomId = undefined; this.currentVoiceServerId = undefined; } /** * Set the local stream from an external source (e.g. voice-controls component). * * The raw stream is saved so noise reduction can be toggled on/off later. * If noise reduction is already enabled the stream is piped through the * denoiser before being sent to peers. */ async setLocalStream(stream: MediaStream): Promise { this.rawMicStream = stream; this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired); // Pipe through the denoiser when the user wants noise reduction if (this._noiseReductionDesired) { this.logger.info('Piping new stream through noise reduction'); this.localMediaStream = await this.noiseReduction.enable(stream); } else { this.localMediaStream = stream; } // Apply input gain (mic volume) before sending to peers this.applyInputGainToCurrentStream(); this.bindLocalTracksToAllPeers(); this.isVoiceActive = true; this.voiceConnected$.next(); } /** * Toggle the local microphone mute state. * * @param muted - Explicit state; if omitted, the current state is toggled. */ toggleMute(muted?: boolean): void { const newMutedState = muted !== undefined ? muted : !this.isMicMuted; this.isMicMuted = newMutedState; this.applyCurrentMuteState(); } /** * Toggle self-deafen (suppress all incoming audio playback). * * @param deafened - Explicit state; if omitted, the current state is toggled. */ toggleDeafen(deafened?: boolean): void { this.isSelfDeafened = deafened !== undefined ? deafened : !this.isSelfDeafened; } /** * Toggle RNNoise noise reduction on the local microphone. * * When enabled the raw mic stream is routed through the RNNoise * AudioWorklet and peer senders are updated with the denoised track. * When disabled the original raw mic track is restored. * * @param enabled - Explicit state; if omitted, the current state is toggled. */ async toggleNoiseReduction(enabled?: boolean): Promise { const shouldEnable = enabled !== undefined ? enabled : !this._noiseReductionDesired; // Always persist the preference this._noiseReductionDesired = shouldEnable; this.logger.info( 'Noise reduction desired =', shouldEnable, '| worklet active =', this.noiseReduction.isEnabled ); // Do not update the browser's built-in noiseSuppression constraint on the // live mic track here. Chromium may share the underlying capture source, // which can leak the constraint change into other active streams. We only // apply the browser constraint when the microphone stream is acquired. if (shouldEnable === this.noiseReduction.isEnabled) return; if (shouldEnable) { if (!this.rawMicStream) { this.logger.warn( 'Cannot enable noise reduction - no mic stream yet (will apply on connect)' ); return; } this.logger.info('Enabling noise reduction on raw mic stream'); const cleanStream = await this.noiseReduction.enable(this.rawMicStream); this.localMediaStream = cleanStream; } else { this.noiseReduction.disable(); if (this.rawMicStream) { this.localMediaStream = this.rawMicStream; } } // Re-apply input gain to the (possibly new) stream this.applyInputGainToCurrentStream(); // Propagate the new audio track to every peer connection this.bindLocalTracksToAllPeers(); } /** * Set the output volume for remote audio. * * @param volume - A value between {@link VOLUME_MIN} (0) and {@link VOLUME_MAX} (1). */ setOutputVolume(volume: number): void { this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume)); } /** * Set the input (microphone) volume. * * If a local stream is active the gain node is updated in real time. * If no stream exists yet the value is stored and applied on connect. * * @param volume - Normalised 0-1 (0 = silent, 1 = 100%). */ setInputVolume(volume: number): void { this.inputGainVolume = Math.max(0, Math.min(1, volume)); if (this.inputGainNode) { // Pipeline already exists - just update the gain value this.inputGainNode.gain.value = this.inputGainVolume; } else if (this.localMediaStream) { // Stream is active but gain pipeline hasn't been created yet this.applyInputGainToCurrentStream(); this.bindLocalTracksToAllPeers(); } } /** Get current input gain value (0-1). */ getInputVolume(): number { return this.inputGainVolume; } /** * Set the maximum audio bitrate on every active peer's audio sender. * * The value is clamped between {@link AUDIO_BITRATE_MIN_BPS} and * {@link AUDIO_BITRATE_MAX_BPS}. * * @param kbps - Target bitrate in kilobits per second. */ async setAudioBitrate(kbps: number): Promise { const targetBps = Math.max( AUDIO_BITRATE_MIN_BPS, Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS)) ); this.callbacks.getActivePeers().forEach(async (peerData) => { const sender = peerData.audioSender || peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO); if (!sender?.track) return; if (peerData.connection.signalingState !== 'stable') return; let params: RTCRtpSendParameters; try { params = sender.getParameters(); } catch (error) { this.logger.warn('getParameters failed; skipping bitrate apply', error); return; } params.encodings = params.encodings || [{}]; params.encodings[0].maxBitrate = targetBps; try { await sender.setParameters(params); this.logger.info('Applied audio bitrate', { targetBps }); } catch (error) { this.logger.warn('Failed to set audio bitrate', error); } }); } /** * Apply a named latency profile that maps to a predefined bitrate. * * @param profile - One of `'low'`, `'balanced'`, or `'high'`. */ async setLatencyProfile(profile: LatencyProfile): Promise { await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]); } /** * Start periodically broadcasting voice presence to all peers. * * Optionally records the voice room/server so heartbeats include them. * * @param roomId - The voice channel room ID. * @param serverId - The voice channel server ID. */ startVoiceHeartbeat(roomId?: string, serverId?: string): void { this.stopVoiceHeartbeat(); // Persist voice channel context so heartbeats and state snapshots include it if (roomId !== undefined) this.currentVoiceRoomId = roomId; if (serverId !== undefined) this.currentVoiceServerId = serverId; this.voicePresenceTimer = setInterval(() => { if (this.isVoiceActive) { this.broadcastVoicePresence(); } }, VOICE_HEARTBEAT_INTERVAL_MS); // Also send an immediate heartbeat if (this.isVoiceActive) { this.broadcastVoicePresence(); } } /** Stop the voice-presence heartbeat timer. */ stopVoiceHeartbeat(): void { if (this.voicePresenceTimer) { clearInterval(this.voicePresenceTimer); this.voicePresenceTimer = null; } } /** * Bind local audio/video tracks to all existing peer transceivers. * Restores transceiver direction to sendrecv if previously set to recvonly * (which happens when disableVoice calls removeTrack). */ private bindLocalTracksToAllPeers(): void { const peers = this.callbacks.getActivePeers(); if (!this.localMediaStream) return; const localStream = this.localMediaStream; const localAudioTrack = localStream.getAudioTracks()[0] || null; const localVideoTrack = localStream.getVideoTracks()[0] || null; peers.forEach((peerData, peerId) => { if (localAudioTrack) { const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, { preferredSender: peerData.audioSender, excludedSenders: [peerData.screenAudioSender] }); const audioSender = audioTransceiver.sender; peerData.audioSender = audioSender; // Restore direction after removeTrack (which sets it to recvonly) if ( audioTransceiver && (audioTransceiver.direction === TRANSCEIVER_RECV_ONLY || audioTransceiver.direction === TRANSCEIVER_INACTIVE) ) { audioTransceiver.direction = TRANSCEIVER_SEND_RECV; } if (typeof audioSender.setStreams === 'function') { audioSender.setStreams(localStream); } audioSender .replaceTrack(localAudioTrack) .then(() => this.logger.info('audio replaceTrack ok', { peerId })) .catch((error) => this.logger.error('audio replaceTrack failed', error)); } if (localVideoTrack) { const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, { preferredSender: peerData.videoSender, excludedSenders: [peerData.screenVideoSender] }); const videoSender = videoTransceiver.sender; peerData.videoSender = videoSender; if ( videoTransceiver && (videoTransceiver.direction === TRANSCEIVER_RECV_ONLY || videoTransceiver.direction === TRANSCEIVER_INACTIVE) ) { videoTransceiver.direction = TRANSCEIVER_SEND_RECV; } if (typeof videoSender.setStreams === 'function') { videoSender.setStreams(localStream); } videoSender .replaceTrack(localVideoTrack) .then(() => this.logger.info('video replaceTrack ok', { peerId })) .catch((error) => this.logger.error('video replaceTrack failed', error)); } this.callbacks.renegotiate(peerId); }); } private getOrCreateReusableTransceiver( peerData: PeerData, kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO, options: { preferredSender?: RTCRtpSender; excludedSenders?: (RTCRtpSender | undefined)[]; } ): RTCRtpTransceiver { const excludedSenders = new Set( (options.excludedSenders ?? []).filter((sender): sender is RTCRtpSender => !!sender) ); const existingTransceivers = peerData.connection.getTransceivers(); const preferredTransceiver = options.preferredSender ? existingTransceivers.find((transceiver) => transceiver.sender === options.preferredSender) : null; if (preferredTransceiver) { return preferredTransceiver; } const attachedSenderTransceiver = existingTransceivers.find((transceiver) => !excludedSenders.has(transceiver.sender) && transceiver.sender.track?.kind === kind ); if (attachedSenderTransceiver) { return attachedSenderTransceiver; } const reusableReceiverTransceiver = existingTransceivers.find((transceiver) => !excludedSenders.has(transceiver.sender) && !transceiver.sender.track && transceiver.receiver.track?.kind === kind ); if (reusableReceiverTransceiver) { return reusableReceiverTransceiver; } return peerData.connection.addTransceiver(kind, { direction: TRANSCEIVER_SEND_RECV }); } /** Broadcast a voice-presence state event to all connected peers. */ private broadcastVoicePresence(): void { const oderId = this.callbacks.getIdentifyOderId(); const displayName = this.callbacks.getIdentifyDisplayName(); this.callbacks.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState: { isConnected: this.isVoiceActive, isMuted: this.isMicMuted, isDeafened: this.isSelfDeafened, roomId: this.currentVoiceRoomId, serverId: this.currentVoiceServerId } }); } // -- Input gain helpers -- /** * Route the current `localMediaStream` through a Web Audio GainNode so * the microphone level can be adjusted without renegotiating peers. * * If a gain pipeline already exists for the same source stream the gain * value is simply updated. Otherwise a new pipeline is created. */ private applyInputGainToCurrentStream(): void { const stream = this.localMediaStream; if (!stream) return; // If the source stream hasn't changed, just update gain if (this.preGainStream === stream && this.inputGainNode && this.inputGainCtx) { this.inputGainNode.gain.value = this.inputGainVolume; return; } // Tear down the old pipeline (if any) this.teardownInputGain(); // Build new pipeline: source → gain → destination this.preGainStream = stream; this.inputGainCtx = new AudioContext(); this.inputGainSourceNode = this.inputGainCtx.createMediaStreamSource(stream); this.inputGainNode = this.inputGainCtx.createGain(); this.inputGainNode.gain.value = this.inputGainVolume; this.inputGainDest = this.inputGainCtx.createMediaStreamDestination(); this.inputGainSourceNode.connect(this.inputGainNode); this.inputGainNode.connect(this.inputGainDest); // Replace localMediaStream with the gained stream this.localMediaStream = this.inputGainDest.stream; this.applyCurrentMuteState(); } /** Keep the active outbound track aligned with the stored mute state. */ private applyCurrentMuteState(): void { if (!this.localMediaStream) return; const enabled = !this.isMicMuted; this.localMediaStream.getAudioTracks().forEach((track) => { track.enabled = enabled; }); } /** Disconnect and close the input-gain AudioContext. */ private teardownInputGain(): void { try { this.inputGainSourceNode?.disconnect(); this.inputGainNode?.disconnect(); } catch (error) { this.logger.warn('Input gain nodes were already disconnected during teardown', error); } if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') { this.inputGainCtx.close().catch((error) => { this.logger.warn('Failed to close input gain audio context', error); }); } this.inputGainCtx = null; this.inputGainSourceNode = null; this.inputGainNode = null; this.inputGainDest = null; this.preGainStream = null; } /** Clean up all resources. */ destroy(): void { this.teardownInputGain(); this.disableVoice(); this.stopVoiceHeartbeat(); this.noiseReduction.destroy(); this.voiceConnected$.complete(); } }