fix: auto-disable AEC during screen share to prevent Chrome voice ducking

Chrome's AEC uses getDisplayMedia audio as a reference signal and
aggressively ducks the microphone even when headphones are used.
This adds a screenShareActive flag to AudioManager that forces
echoCancellation off during screen share, plus Chromium-specific
goog* constraints as belt-and-suspenders.
This commit is contained in:
Jannis Braun
2026-02-20 15:22:20 +01:00
parent 25f72aef3b
commit 708bdf5468
7 changed files with 56 additions and 6 deletions
+31 -2
View File
@@ -17,6 +17,7 @@ export class AudioManager {
private voiceEchoCancellation = true;
private voiceNoiseSuppression = true;
private voiceAutoGainControl = false;
private screenShareActive = false;
private streamGeneration = 0;
private constructor() {}
@@ -141,13 +142,25 @@ export class AudioManager {
this.currentStream.getTracks().forEach(t => t.stop());
}
// When screen sharing with audio, Chrome's AEC uses the getDisplayMedia
// audio as a reference signal and ducks the mic — even with headphones.
// Force AEC off during screen share to prevent this.
const effectiveEchoCancellation = this.screenShareActive ? false : this.voiceEchoCancellation;
const constraints = {
audio: {
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
echoCancellation: this.voiceEchoCancellation,
echoCancellation: effectiveEchoCancellation,
noiseSuppression: this.voiceNoiseSuppression,
autoGainControl: this.voiceAutoGainControl,
}
// Chromium-specific constraints — belt-and-suspenders to ensure
// Chrome's internal audio engine respects the standard constraints.
googEchoCancellation: effectiveEchoCancellation,
googAutoGainControl: this.voiceAutoGainControl,
googNoiseSuppression: this.voiceNoiseSuppression,
googHighpassFilter: false,
googTypingNoiseDetection: false,
} as any
};
this.currentStream = await navigator.mediaDevices.getUserMedia(constraints);
@@ -197,6 +210,22 @@ export class AudioManager {
}
}
/**
* When screen sharing with audio is active, Chrome's AEC uses the screen
* share audio as a reference signal and aggressively ducks the microphone.
* Setting this flag forces echoCancellation OFF regardless of user preference,
* severing the software link that causes the ducking.
*/
setScreenShareActive(active: boolean) {
if (this.screenShareActive === active) return;
this.screenShareActive = active;
console.log(`[AudioManager] Screen share active: ${active}${active ? 'forcing AEC off' : 'restoring user AEC preference'}`);
if (this.currentStream) {
this.currentStream.getTracks().forEach(t => t.stop());
this.currentStream = null;
}
}
getStreamGeneration(): number {
return this.streamGeneration;
}
@@ -127,13 +127,13 @@ export function UserSettingsModal() {
Voice Processing
</h3>
<p className="text-xs text-discord-text-muted mb-3">
Disable Auto Gain Control when streaming to prevent your browser from ducking your microphone.
Echo Cancellation is automatically disabled while you screen share to prevent Chrome from ducking your mic volume.
</p>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-discord-text-primary">Echo Cancellation</div>
<div className="text-xs text-discord-text-muted">Removes echo when using speakers</div>
<div className="text-xs text-discord-text-muted">Removes echo when using speakers (auto-disabled during screen share)</div>
</div>
<button
onClick={() => setEchoCancellation(!echoCancellation)}
@@ -4,6 +4,7 @@ import { useServerStore } from '../../stores/serverStore';
import { useAuthStore } from '../../stores/authStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { AudioManager } from '../../audio/AudioManager';
import { VideoPresets, VideoPreset } from 'livekit-client';
const QUALITY_MAP: Record<string, any> = {
@@ -94,9 +95,11 @@ export function DmCallView() {
if (!room) return;
try {
if (!isScreenSharing) {
AudioManager.getInstance().setScreenShareActive(true);
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
} else {
await room.localParticipant.setScreenShareEnabled(false);
AudioManager.getInstance().setScreenShareActive(false);
}
toggleScreenShare();
} catch (err) {
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
import { AudioManager } from '../../audio/AudioManager';
import { VideoQualityPopover } from './VideoQualityPopover';
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
@@ -107,6 +108,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const room = getActiveRoom();
if (room) {
await room.localParticipant.setScreenShareEnabled(false);
AudioManager.getInstance().setScreenShareActive(false);
useVoiceStore.getState().toggleScreenShare();
}
}, []);
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { AudioManager } from '../../audio/AudioManager';
import { VideoQualityPopover } from './VideoQualityPopover';
import { VideoPresets, VideoPreset } from 'livekit-client';
@@ -97,9 +98,11 @@ export function VoiceControlBar() {
if (!room) return;
try {
if (!isScreenSharing) {
AudioManager.getInstance().setScreenShareActive(true);
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
} else {
await room.localParticipant.setScreenShareEnabled(false);
AudioManager.getInstance().setScreenShareActive(false);
}
toggleScreenShare();
} catch (err) {
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { AudioManager } from '../../audio/AudioManager';
import { VideoQualityPopover } from './VideoQualityPopover';
/**
@@ -43,9 +44,11 @@ export function VoiceControls() {
if (!room) return;
try {
if (!isScreenSharing) {
AudioManager.getInstance().setScreenShareActive(true);
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
} else {
await room.localParticipant.setScreenShareEnabled(false);
AudioManager.getInstance().setScreenShareActive(false);
}
toggleScreenShare();
} catch (err) {
+12 -2
View File
@@ -251,6 +251,8 @@ export function useLiveKit() {
// Sync voice processing settings to AudioManager
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
// Keep screen share state in sync (handles edge cases like remounts)
audioManager.setScreenShareActive(isScreenSharing);
// If muted or deafened, unpublish mic
if (isMuted || isDeafened) {
@@ -305,7 +307,7 @@ export function useLiveKit() {
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl]);
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, isScreenSharing]);
const connect = useCallback(async (channelId: string) => {
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return;
@@ -643,6 +645,10 @@ export function useLiveKit() {
const toggleScreenShare = useCallback(async () => {
if (roomRef.current) {
if (!isScreenSharing) {
// Notify AudioManager BEFORE enabling screen share so the mic track
// gets republished with AEC off, preventing Chrome's ducking.
AudioManager.getInstance().setScreenShareActive(true);
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
audio: true,
@@ -669,7 +675,11 @@ export function useLiveKit() {
}, 2000);
setTimeout(() => applyOverdriveHammer(roomRef.current!, Track.Source.ScreenShare, preset), 5000);
}
} else { await roomRef.current.localParticipant.setScreenShareEnabled(false); }
} else {
await roomRef.current.localParticipant.setScreenShareEnabled(false);
// Restore user's AEC preference after screen share ends
AudioManager.getInstance().setScreenShareActive(false);
}
updateParticipants();
}
}, [isScreenSharing, videoQuality, updateParticipants]);