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 voiceEchoCancellation = true;
private voiceNoiseSuppression = true; private voiceNoiseSuppression = true;
private voiceAutoGainControl = false; private voiceAutoGainControl = false;
private screenShareActive = false;
private streamGeneration = 0; private streamGeneration = 0;
private inputSwitchChain: Promise<MediaStream | null> = Promise.resolve(null); private inputSwitchChain: Promise<MediaStream | null> = Promise.resolve(null);
private rnnoiseNode: AudioWorkletNode | null = null; private rnnoiseNode: AudioWorkletNode | null = null;
@@ -203,10 +202,8 @@ 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 // Chrome AEC stays on during screen share — headphone users unaffected,
// audio as a reference signal and ducks the mic — even with headphones. // speaker users get proper echo cancellation.
// Force AEC off during screen share to prevent this.
const effectiveEchoCancellation = this.screenShareActive ? false : this.voiceEchoCancellation;
// When RNNoise is active, force browser NS off — running both degrades quality. // 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. // The user's noiseSuppression preference is preserved in the store for when RNNoise is disabled.
@@ -215,12 +212,12 @@ export class AudioManager {
const constraints = { const constraints = {
audio: { audio: {
deviceId: deviceId === 'default' ? undefined : { exact: deviceId }, deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
echoCancellation: effectiveEchoCancellation, echoCancellation: this.voiceEchoCancellation,
noiseSuppression: effectiveNoiseSuppression, noiseSuppression: effectiveNoiseSuppression,
autoGainControl: this.voiceAutoGainControl, autoGainControl: this.voiceAutoGainControl,
// Chromium-specific constraints — belt-and-suspenders to ensure // Chromium-specific constraints — belt-and-suspenders to ensure
// Chrome's internal audio engine respects the standard constraints. // Chrome's internal audio engine respects the standard constraints.
googEchoCancellation: effectiveEchoCancellation, googEchoCancellation: this.voiceEchoCancellation,
googAutoGainControl: this.voiceAutoGainControl, googAutoGainControl: this.voiceAutoGainControl,
googNoiseSuppression: effectiveNoiseSuppression, googNoiseSuppression: effectiveNoiseSuppression,
googHighpassFilter: false, 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 { getStreamGeneration(): number {
return this.streamGeneration; return this.streamGeneration;
} }
@@ -145,7 +145,7 @@ export function UserSettingsModal() {
<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-txt-primary">Echo Cancellation</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> </div>
<button <button
onClick={() => setEchoCancellation(!echoCancellation)} onClick={() => setEchoCancellation(!echoCancellation)}
+1 -3
View File
@@ -245,8 +245,6 @@ 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 });
await audioManager.setRnnoiseEnabled(rnnoiseEnabled); await audioManager.setRnnoiseEnabled(rnnoiseEnabled);
// Keep screen share state in sync (handles edge cases like remounts)
audioManager.setScreenShareActive(isScreenSharing);
const micPub = r.localParticipant.getTrackPublications() const micPub = r.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.Microphone); .find(p => p.source === Track.Source.Microphone);
@@ -304,7 +302,7 @@ export function useLiveKit() {
return () => { return () => {
unsubscribe(); 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 connect = useCallback(async (channelId: string, isDm?: boolean) => {
const storedId = isDm ? `dm-${channelId}` : channelId; 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 { useVoiceStore } from '../stores/voiceStore';
import type { ScreenShareConfig } from '../stores/voiceStore'; import type { ScreenShareConfig } from '../stores/voiceStore';
import { getStreamingLimits } from '../stores/settingsStore'; import { getStreamingLimits } from '../stores/settingsStore';
import { AudioManager } from '../audio/AudioManager';
import { wsSend } from '../hooks/useWebSocket'; import { wsSend } from '../hooks/useWebSocket';
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals'; import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
@@ -152,9 +151,6 @@ export async function startScreenShare(room: Room): Promise<boolean> {
return false; 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) // Set content hint from builder (motion for gaming, detail for text)
const screenPub = room.localParticipant.getTrackPublications() const screenPub = room.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.ScreenShare); .find(p => p.source === Track.Source.ScreenShare);
@@ -194,7 +190,6 @@ export async function startScreenShare(room: Room): Promise<boolean> {
return true; return true;
} catch (err) { } catch (err) {
console.error('[ScreenShare] Failed to start screen share:', err); console.error('[ScreenShare] Failed to start screen share:', err);
AudioManager.getInstance().setScreenShareActive(false);
return false; return false;
} }
} }
@@ -209,7 +204,6 @@ export async function stopScreenShare(room: Room): Promise<void> {
} catch (err) { } catch (err) {
console.error('[ScreenShare] Failed to stop screen share:', err); console.error('[ScreenShare] Failed to stop screen share:', err);
} }
AudioManager.getInstance().setScreenShareActive(false);
useVoiceStore.setState({ isScreenSharing: false }); useVoiceStore.setState({ isScreenSharing: false });
} }
@@ -229,7 +223,6 @@ export async function changeScreenShare(room: Room): Promise<void> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function handleScreenShareUnpublished(): void { export function handleScreenShareUnpublished(): void {
AudioManager.getInstance().setScreenShareActive(false);
useVoiceStore.setState({ isScreenSharing: false }); useVoiceStore.setState({ isScreenSharing: false });
const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState(); const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false }); wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false });