Fix mic processing toggles after LiveKit voiceIsolation default
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
Participant,
|
Participant,
|
||||||
LocalTrackPublication,
|
LocalTrackPublication,
|
||||||
|
LocalAudioTrack,
|
||||||
Track,
|
Track,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
getLiveKitHomeserverPriority,
|
getLiveKitHomeserverPriority,
|
||||||
} from './useRtcConfig';
|
} from './useRtcConfig';
|
||||||
import { getActiveCallMembers } from './useRoomCallMembers';
|
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';
|
import { getCallSounds, CallSoundType } from './CallSounds';
|
||||||
|
|
||||||
type CallEventListener = (event: CallEvent) => void;
|
type CallEventListener = (event: CallEvent) => void;
|
||||||
@@ -102,6 +103,10 @@ export class CallService {
|
|||||||
|
|
||||||
private wasMutedBeforeDeafen = false;
|
private wasMutedBeforeDeafen = false;
|
||||||
|
|
||||||
|
private readonly onAudioSettingsChanged = () => {
|
||||||
|
void this.applyAudioCaptureSettings();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new CallService instance
|
* Creates a new CallService instance
|
||||||
* @param config - Service configuration including homeserver and LiveKit URLs
|
* @param config - Service configuration including homeserver and LiveKit URLs
|
||||||
@@ -110,6 +115,7 @@ export class CallService {
|
|||||||
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
|
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.matrixClient = matrixClient;
|
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
|
// 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({
|
this.livekitRoom = new Room({
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
|
audioCaptureDefaults: audioCaptureOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setupLiveKitEventHandlers();
|
this.setupLiveKitEventHandlers();
|
||||||
@@ -400,19 +410,7 @@ export class CallService {
|
|||||||
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
|
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
|
||||||
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
|
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
|
||||||
|
|
||||||
// Get user's audio device preferences
|
console.log('Using audio capture options:', audioCaptureOptions);
|
||||||
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);
|
|
||||||
|
|
||||||
// Step 5: Publish local tracks based on call type with selected devices and processing
|
// Step 5: Publish local tracks based on call type with selected devices and processing
|
||||||
if (callType === CallType.Video) {
|
if (callType === CallType.Video) {
|
||||||
@@ -881,7 +879,10 @@ export class CallService {
|
|||||||
getCallSounds().playSound(CallSoundType.Undeafen);
|
getCallSounds().playSound(CallSoundType.Undeafen);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(
|
||||||
|
!newMuteState,
|
||||||
|
getAudioCaptureOptions()
|
||||||
|
);
|
||||||
this.activeCall.isMuted = newMuteState;
|
this.activeCall.isMuted = newMuteState;
|
||||||
|
|
||||||
// Play mute/unmute sound
|
// Play mute/unmute sound
|
||||||
@@ -929,7 +930,7 @@ export class CallService {
|
|||||||
} else {
|
} else {
|
||||||
// Undeafening - only unmute if user wasn't muted before deafening
|
// Undeafening - only unmute if user wasn't muted before deafening
|
||||||
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
|
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(true, getAudioCaptureOptions());
|
||||||
this.activeCall.isMuted = false;
|
this.activeCall.isMuted = false;
|
||||||
}
|
}
|
||||||
this.wasMutedBeforeDeafen = false;
|
this.wasMutedBeforeDeafen = false;
|
||||||
@@ -1129,10 +1130,39 @@ export class CallService {
|
|||||||
return this.activeCall?.isScreenSharing ?? false;
|
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
|
* Cleans up resources
|
||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
window.removeEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
|
||||||
this.stopMembershipRefresh();
|
this.stopMembershipRefresh();
|
||||||
this.endCall();
|
this.endCall();
|
||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ function loadAudioSettings(): AudioSettings {
|
|||||||
return DEFAULT_AUDIO_SETTINGS;
|
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
|
* Saves audio settings to localStorage
|
||||||
* @param settings - The audio settings to save
|
* @param settings - The audio settings to save
|
||||||
@@ -90,6 +93,7 @@ function loadAudioSettings(): AudioSettings {
|
|||||||
function saveAudioSettings(settings: AudioSettings): void {
|
function saveAudioSettings(settings: AudioSettings): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings));
|
localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
window.dispatchEvent(new CustomEvent(AUDIO_SETTINGS_CHANGED_EVENT));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save audio settings:', e);
|
console.error('Failed to save audio settings:', e);
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,40 @@ export function getAudioSettings(): AudioSettings {
|
|||||||
return loadAudioSettings();
|
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 = {
|
type DeviceSelectorProps = {
|
||||||
devices: AudioDevice[];
|
devices: AudioDevice[];
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
@@ -635,22 +673,9 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
} else {
|
} else {
|
||||||
// Start monitoring
|
// Start monitoring
|
||||||
try {
|
try {
|
||||||
const constraints: MediaStreamConstraints = {
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: settings.microphoneId
|
audio: getMicTrackConstraints(settings),
|
||||||
? {
|
});
|
||||||
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 audioContext = new AudioContext();
|
const audioContext = new AudioContext();
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 256;
|
||||||
@@ -685,7 +710,7 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
}
|
}
|
||||||
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
||||||
|
|
||||||
// Cleanup monitoring on unmount or when settings change
|
// Cleanup monitoring on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (micAnimFrameRef.current !== null) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (micMonitoring) {
|
if (!micMonitoring) return;
|
||||||
handleMicMonitoringToggle(); // Stop
|
const track = micMonitorStreamRef.current?.getAudioTracks()[0];
|
||||||
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
|
if (!track) return;
|
||||||
}
|
track
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
.applyConstraints({
|
||||||
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
|||||||
Reference in New Issue
Block a user