fix: revert echo gate, restore Chrome AEC during screen share

The echo gate architecture bypassed the serialized mic management chain
and introduced race conditions. Chrome's AEC handles echo properly for
both headphone and speaker users without custom intervention.
This commit is contained in:
Jannis Braun
2026-03-05 00:09:03 +01:00
parent dd02c1ed8d
commit d0b0441f81
4 changed files with 6 additions and 34 deletions
+4 -23
View File
@@ -23,7 +23,6 @@ export class AudioManager {
private voiceEchoCancellation = true;
private voiceNoiseSuppression = true;
private voiceAutoGainControl = false;
private screenShareActive = false;
private streamGeneration = 0;
private inputSwitchChain: Promise<MediaStream | null> = Promise.resolve(null);
private rnnoiseNode: AudioWorkletNode | null = null;
@@ -203,10 +202,8 @@ 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;
// Chrome AEC stays on during screen share — headphone users unaffected,
// speaker users get proper echo cancellation.
// When RNNoise is active, force browser NS off — running both degrades quality.
// The user's noiseSuppression preference is preserved in the store for when RNNoise is disabled.
@@ -215,12 +212,12 @@ export class AudioManager {
const constraints = {
audio: {
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
echoCancellation: effectiveEchoCancellation,
echoCancellation: this.voiceEchoCancellation,
noiseSuppression: effectiveNoiseSuppression,
autoGainControl: this.voiceAutoGainControl,
// Chromium-specific constraints — belt-and-suspenders to ensure
// Chrome's internal audio engine respects the standard constraints.
googEchoCancellation: effectiveEchoCancellation,
googEchoCancellation: this.voiceEchoCancellation,
googAutoGainControl: this.voiceAutoGainControl,
googNoiseSuppression: effectiveNoiseSuppression,
googHighpassFilter: false,
@@ -341,22 +338,6 @@ 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;
}
@@ -145,7 +145,7 @@ export function UserSettingsModal() {
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Echo Cancellation</div>
<div className="text-xs text-txt-tertiary">Removes echo when using speakers (auto-disabled during screen share)</div>
<div className="text-xs text-txt-tertiary">Removes echo when using speakers</div>
</div>
<button
onClick={() => setEchoCancellation(!echoCancellation)}
+1 -3
View File
@@ -245,8 +245,6 @@ export function useLiveKit() {
// Sync voice processing settings to AudioManager
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
await audioManager.setRnnoiseEnabled(rnnoiseEnabled);
// Keep screen share state in sync (handles edge cases like remounts)
audioManager.setScreenShareActive(isScreenSharing);
const micPub = r.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.Microphone);
@@ -304,7 +302,7 @@ export function useLiveKit() {
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, isScreenSharing]);
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
const connect = useCallback(async (channelId: string, isDm?: boolean) => {
const storedId = isDm ? `dm-${channelId}` : channelId;
-7
View File
@@ -2,7 +2,6 @@ import { Room, Track } from 'livekit-client';
import { useVoiceStore } from '../stores/voiceStore';
import type { ScreenShareConfig } from '../stores/voiceStore';
import { getStreamingLimits } from '../stores/settingsStore';
import { AudioManager } from '../audio/AudioManager';
import { wsSend } from '../hooks/useWebSocket';
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
@@ -152,9 +151,6 @@ export async function startScreenShare(room: Room): Promise<boolean> {
return false;
}
// Rebuild mic without AEC now that screen share is acquired
AudioManager.getInstance().setScreenShareActive(true);
// Set content hint from builder (motion for gaming, detail for text)
const screenPub = room.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.ScreenShare);
@@ -194,7 +190,6 @@ export async function startScreenShare(room: Room): Promise<boolean> {
return true;
} catch (err) {
console.error('[ScreenShare] Failed to start screen share:', err);
AudioManager.getInstance().setScreenShareActive(false);
return false;
}
}
@@ -209,7 +204,6 @@ export async function stopScreenShare(room: Room): Promise<void> {
} catch (err) {
console.error('[ScreenShare] Failed to stop screen share:', err);
}
AudioManager.getInstance().setScreenShareActive(false);
useVoiceStore.setState({ isScreenSharing: false });
}
@@ -229,7 +223,6 @@ export async function changeScreenShare(room: Room): Promise<void> {
// ---------------------------------------------------------------------------
export function handleScreenShareUnpublished(): void {
AudioManager.getInstance().setScreenShareActive(false);
useVoiceStore.setState({ isScreenSharing: false });
const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false });