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:
@@ -17,6 +17,7 @@ export class AudioManager {
|
|||||||
private voiceEchoCancellation = true;
|
private voiceEchoCancellation = true;
|
||||||
private voiceNoiseSuppression = true;
|
private voiceNoiseSuppression = true;
|
||||||
private voiceAutoGainControl = false;
|
private voiceAutoGainControl = false;
|
||||||
|
private screenShareActive = false;
|
||||||
private streamGeneration = 0;
|
private streamGeneration = 0;
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
@@ -141,13 +142,25 @@ export class AudioManager {
|
|||||||
this.currentStream.getTracks().forEach(t => t.stop());
|
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 = {
|
const constraints = {
|
||||||
audio: {
|
audio: {
|
||||||
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
|
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
|
||||||
echoCancellation: this.voiceEchoCancellation,
|
echoCancellation: effectiveEchoCancellation,
|
||||||
noiseSuppression: this.voiceNoiseSuppression,
|
noiseSuppression: this.voiceNoiseSuppression,
|
||||||
autoGainControl: this.voiceAutoGainControl,
|
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);
|
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 {
|
getStreamGeneration(): number {
|
||||||
return this.streamGeneration;
|
return this.streamGeneration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,13 @@ export function UserSettingsModal() {
|
|||||||
Voice Processing
|
Voice Processing
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-discord-text-muted mb-3">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-discord-text-primary">Echo Cancellation</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEchoCancellation(!echoCancellation)}
|
onClick={() => setEchoCancellation(!echoCancellation)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useServerStore } from '../../stores/serverStore';
|
|||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
import { VideoPresets, VideoPreset } from 'livekit-client';
|
import { VideoPresets, VideoPreset } from 'livekit-client';
|
||||||
|
|
||||||
const QUALITY_MAP: Record<string, any> = {
|
const QUALITY_MAP: Record<string, any> = {
|
||||||
@@ -94,9 +95,11 @@ export function DmCallView() {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
|
AudioManager.getInstance().setScreenShareActive(true);
|
||||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
||||||
} else {
|
} else {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
}
|
}
|
||||||
toggleScreenShare();
|
toggleScreenShare();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||||
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
if (room) {
|
if (room) {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
useVoiceStore.getState().toggleScreenShare();
|
useVoiceStore.getState().toggleScreenShare();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
import { VideoPresets, VideoPreset } from 'livekit-client';
|
import { VideoPresets, VideoPreset } from 'livekit-client';
|
||||||
|
|
||||||
@@ -97,9 +98,11 @@ export function VoiceControlBar() {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
|
AudioManager.getInstance().setScreenShareActive(true);
|
||||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
||||||
} else {
|
} else {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
}
|
}
|
||||||
toggleScreenShare();
|
toggleScreenShare();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,9 +44,11 @@ export function VoiceControls() {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
|
AudioManager.getInstance().setScreenShareActive(true);
|
||||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
||||||
} else {
|
} else {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
}
|
}
|
||||||
toggleScreenShare();
|
toggleScreenShare();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ export function useLiveKit() {
|
|||||||
|
|
||||||
// Sync voice processing settings to AudioManager
|
// Sync voice processing settings to AudioManager
|
||||||
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
|
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 muted or deafened, unpublish mic
|
||||||
if (isMuted || isDeafened) {
|
if (isMuted || isDeafened) {
|
||||||
@@ -305,7 +307,7 @@ export function useLiveKit() {
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl]);
|
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, isScreenSharing]);
|
||||||
|
|
||||||
const connect = useCallback(async (channelId: string) => {
|
const connect = useCallback(async (channelId: string) => {
|
||||||
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return;
|
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return;
|
||||||
@@ -643,6 +645,10 @@ export function useLiveKit() {
|
|||||||
const toggleScreenShare = useCallback(async () => {
|
const toggleScreenShare = useCallback(async () => {
|
||||||
if (roomRef.current) {
|
if (roomRef.current) {
|
||||||
if (!isScreenSharing) {
|
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 preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||||
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
|
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
|
||||||
audio: true,
|
audio: true,
|
||||||
@@ -669,7 +675,11 @@ export function useLiveKit() {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
setTimeout(() => applyOverdriveHammer(roomRef.current!, Track.Source.ScreenShare, preset), 5000);
|
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();
|
updateParticipants();
|
||||||
}
|
}
|
||||||
}, [isScreenSharing, videoQuality, updateParticipants]);
|
}, [isScreenSharing, videoQuality, updateParticipants]);
|
||||||
|
|||||||
Reference in New Issue
Block a user