diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts index fd1aaae..6a1b28f 100644 --- a/src/app/features/call/CallService.ts +++ b/src/app/features/call/CallService.ts @@ -9,6 +9,7 @@ import { ParticipantEvent, Participant, LocalTrackPublication, + LocalAudioTrack, Track, } from 'livekit-client'; import { MatrixClient } from 'matrix-js-sdk'; @@ -31,7 +32,7 @@ import { getLiveKitHomeserverPriority, } from './useRtcConfig'; import { getActiveCallMembers } from './useRoomCallMembers'; -import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio'; +import { getAudioCaptureOptions, getAudioSettings, AUDIO_SETTINGS_CHANGED_EVENT, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio'; import { getCallSounds, CallSoundType } from './CallSounds'; type CallEventListener = (event: CallEvent) => void; @@ -102,6 +103,10 @@ export class CallService { private wasMutedBeforeDeafen = false; + private readonly onAudioSettingsChanged = () => { + void this.applyAudioCaptureSettings(); + }; + /** * Creates a new CallService instance * @param config - Service configuration including homeserver and LiveKit URLs @@ -110,6 +115,7 @@ export class CallService { constructor(config: CallServiceConfig, matrixClient: MatrixClient) { this.config = config; this.matrixClient = matrixClient; + window.addEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged); } /** @@ -386,9 +392,13 @@ export class CallService { } // Step 4: Create and connect to LiveKit room + // Pin audioCaptureDefaults so mute/unmute republish doesn't re-enable LiveKit's + // voiceIsolation:true default (which ignores noiseSuppression). + const audioCaptureOptions = getAudioCaptureOptions(); this.livekitRoom = new Room({ adaptiveStream: true, dynacast: true, + audioCaptureDefaults: audioCaptureOptions, }); this.setupLiveKitEventHandlers(); @@ -400,19 +410,7 @@ export class CallService { console.log('Local participant identity:', this.livekitRoom.localParticipant.identity); console.log('Remote participants:', this.livekitRoom.remoteParticipants.size); - // Get user's audio device preferences - const audioSettings = getAudioSettings(); - console.log('Using audio settings:', audioSettings); - - // Build audio capture options with processing settings - const audioCaptureOptions = { - deviceId: audioSettings.microphoneId || undefined, - noiseSuppression: audioSettings.noiseSuppression, - echoCancellation: audioSettings.echoCancellation, - autoGainControl: audioSettings.autoGainControl, - }; - - console.log('Audio capture options:', audioCaptureOptions); + console.log('Using audio capture options:', audioCaptureOptions); // Step 5: Publish local tracks based on call type with selected devices and processing if (callType === CallType.Video) { @@ -881,7 +879,10 @@ export class CallService { getCallSounds().playSound(CallSoundType.Undeafen); } - this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState); + this.livekitRoom.localParticipant.setMicrophoneEnabled( + !newMuteState, + getAudioCaptureOptions() + ); this.activeCall.isMuted = newMuteState; // Play mute/unmute sound @@ -929,7 +930,7 @@ export class CallService { } else { // Undeafening - only unmute if user wasn't muted before deafening if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) { - this.livekitRoom.localParticipant.setMicrophoneEnabled(true); + this.livekitRoom.localParticipant.setMicrophoneEnabled(true, getAudioCaptureOptions()); this.activeCall.isMuted = false; } this.wasMutedBeforeDeafen = false; @@ -1129,10 +1130,39 @@ export class CallService { return this.activeCall?.isScreenSharing ?? false; } + /** + * Re-apply stored mic processing settings to the live published track. + * Needed because LiveKit merges voiceIsolation:true by default, and because + * settings changes mid-call previously only wrote localStorage. + */ + async applyAudioCaptureSettings(): Promise { + if (!this.livekitRoom || !this.activeCall) return; + + const options = getAudioCaptureOptions(); + this.livekitRoom.options.audioCaptureDefaults = { + ...this.livekitRoom.options.audioCaptureDefaults, + ...options, + }; + + const publication = this.livekitRoom.localParticipant.getTrackPublication( + Track.Source.Microphone + ); + const track = publication?.track; + if (!(track instanceof LocalAudioTrack)) return; + + try { + await track.restartTrack(options); + console.log('Reapplied audio capture settings:', options); + } catch (error) { + console.error('Failed to reapply audio capture settings:', error); + } + } + /** * Cleans up resources */ dispose(): void { + window.removeEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged); this.stopMembershipRefresh(); this.endCall(); this.listeners.clear(); diff --git a/src/app/features/settings/audio/Audio.tsx b/src/app/features/settings/audio/Audio.tsx index d9cf2dd..19a2ed3 100644 --- a/src/app/features/settings/audio/Audio.tsx +++ b/src/app/features/settings/audio/Audio.tsx @@ -83,6 +83,9 @@ function loadAudioSettings(): AudioSettings { return DEFAULT_AUDIO_SETTINGS; } +/** Dispatched on window after audio settings are persisted. */ +export const AUDIO_SETTINGS_CHANGED_EVENT = 'paarrot-audio-settings-changed'; + /** * Saves audio settings to localStorage * @param settings - The audio settings to save @@ -90,6 +93,7 @@ function loadAudioSettings(): AudioSettings { function saveAudioSettings(settings: AudioSettings): void { try { localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings)); + window.dispatchEvent(new CustomEvent(AUDIO_SETTINGS_CHANGED_EVENT)); } catch (e) { console.error('Failed to save audio settings:', e); } @@ -103,6 +107,40 @@ export function getAudioSettings(): AudioSettings { return loadAudioSettings(); } +/** + * LiveKit / getUserMedia capture options from stored settings. + * Always sets voiceIsolation explicitly — LiveKit 2.20 defaults it to true, + * which overrides noiseSuppression when left unset. + */ +export function getAudioCaptureOptions(): { + deviceId?: string; + noiseSuppression: boolean; + echoCancellation: boolean; + autoGainControl: boolean; + voiceIsolation: boolean; +} { + const settings = getAudioSettings(); + return { + deviceId: settings.microphoneId || undefined, + noiseSuppression: settings.noiseSuppression, + echoCancellation: settings.echoCancellation, + autoGainControl: settings.autoGainControl, + voiceIsolation: settings.noiseSuppression, + }; +} + +/** MediaTrackConstraints for getUserMedia / applyConstraints from UI settings. */ +function getMicTrackConstraints(settings: AudioSettings): MediaTrackConstraints { + return { + ...(settings.microphoneId ? { deviceId: { exact: settings.microphoneId } } : {}), + noiseSuppression: settings.noiseSuppression, + echoCancellation: settings.echoCancellation, + autoGainControl: settings.autoGainControl, + // Experimental; Chromium / Electron. Keep in sync with noise suppression. + voiceIsolation: settings.noiseSuppression, + } as MediaTrackConstraints; +} + type DeviceSelectorProps = { devices: AudioDevice[]; selectedId: string | null; @@ -635,22 +673,9 @@ export function Audio({ requestClose }: AudioProps) { } else { // Start monitoring try { - const constraints: MediaStreamConstraints = { - audio: settings.microphoneId - ? { - deviceId: { exact: settings.microphoneId }, - noiseSuppression: settings.noiseSuppression, - echoCancellation: settings.echoCancellation, - autoGainControl: settings.autoGainControl, - } - : { - noiseSuppression: settings.noiseSuppression, - echoCancellation: settings.echoCancellation, - autoGainControl: settings.autoGainControl, - }, - }; - - const stream = await navigator.mediaDevices.getUserMedia(constraints); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: getMicTrackConstraints(settings), + }); const audioContext = new AudioContext(); const analyser = audioContext.createAnalyser(); analyser.fftSize = 256; @@ -685,7 +710,7 @@ export function Audio({ requestClose }: AudioProps) { } }, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]); - // Cleanup monitoring on unmount or when settings change + // Cleanup monitoring on unmount useEffect(() => { return () => { if (micAnimFrameRef.current !== null) { @@ -700,14 +725,25 @@ export function Audio({ requestClose }: AudioProps) { }; }, []); - // Restart monitoring when mic settings change + // Apply processing toggles to the live monitor track without tearing it down. useEffect(() => { - if (micMonitoring) { - handleMicMonitoringToggle(); // Stop - setTimeout(() => handleMicMonitoringToggle(), 100); // Restart - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]); + if (!micMonitoring) return; + const track = micMonitorStreamRef.current?.getAudioTracks()[0]; + if (!track) return; + track + .applyConstraints({ + noiseSuppression: settings.noiseSuppression, + echoCancellation: settings.echoCancellation, + autoGainControl: settings.autoGainControl, + voiceIsolation: settings.noiseSuppression, + } as MediaTrackConstraints) + .catch((e) => console.error('Failed to apply mic processing constraints:', e)); + }, [ + micMonitoring, + settings.noiseSuppression, + settings.echoCancellation, + settings.autoGainControl, + ]); return (