Fix mic processing toggles after LiveKit voiceIsolation default

This commit is contained in:
2026-07-13 02:25:04 +10:00
parent bd6d753f47
commit b711646b58
2 changed files with 106 additions and 40 deletions

View File

@@ -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<void> {
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();

View File

@@ -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 (
<Page>