fix: address sender-side voice ducking with voice processing controls

Disable AGC by default to prevent Chrome from crushing mic sensitivity
when stream audio is playing. Add user-facing toggles for echo
cancellation, noise suppression, and auto gain control. Decouple voice
and stream audio by routing through ctx.destination instead of shared
compressor. Track mic stream generation to re-publish when settings change.
This commit is contained in:
Jannis Braun
2026-02-20 15:00:08 +01:00
parent 5c38f076d3
commit 25f72aef3b
8 changed files with 189 additions and 26 deletions
+31 -4
View File
@@ -12,6 +12,10 @@ export class AudioManager {
isInitialized = false;
listeners = new Set();
soundBuffers = new Map();
voiceEchoCancellation = true;
voiceNoiseSuppression = true;
voiceAutoGainControl = false;
streamGeneration = 0;
constructor() { }
static getInstance() {
if (!AudioManager.instance) {
@@ -121,13 +125,14 @@ export class AudioManager {
const constraints = {
audio: {
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
echoCancellation: this.voiceEchoCancellation,
noiseSuppression: this.voiceNoiseSuppression,
autoGainControl: this.voiceAutoGainControl,
}
};
this.currentStream = await navigator.mediaDevices.getUserMedia(constraints);
this.currentInputDeviceId = deviceId;
this.streamGeneration++;
if (this.ctx && this.inputGain) {
if (this.inputSource) {
this.inputSource.disconnect();
@@ -150,6 +155,28 @@ export class AudioManager {
this.inputGain.gain.setTargetAtTime(gainValue, this.ctx.currentTime, 0.1);
}
}
setVoiceProcessing(opts) {
let changed = false;
if (opts.echoCancellation !== undefined && opts.echoCancellation !== this.voiceEchoCancellation) {
this.voiceEchoCancellation = opts.echoCancellation;
changed = true;
}
if (opts.noiseSuppression !== undefined && opts.noiseSuppression !== this.voiceNoiseSuppression) {
this.voiceNoiseSuppression = opts.noiseSuppression;
changed = true;
}
if (opts.autoGainControl !== undefined && opts.autoGainControl !== this.voiceAutoGainControl) {
this.voiceAutoGainControl = opts.autoGainControl;
changed = true;
}
if (changed && this.currentStream) {
this.currentStream.getTracks().forEach(t => t.stop());
this.currentStream = null;
}
}
getStreamGeneration() {
return this.streamGeneration;
}
/**
* CRITICAL: Always returns a CLONE of the destination track.
* This prevents LiveKit's cleanup from killing the main singleton track
@@ -190,7 +217,7 @@ export class AudioManager {
getMasterOutput() {
if (!this.ctx)
this.initContext();
return this.masterCompressor;
return this.ctx.destination;
}
getContext() {
return this.ctx;
+33 -4
View File
@@ -14,6 +14,10 @@ export class AudioManager {
private listeners: Set<() => void> = new Set();
private soundBuffers: Map<string, AudioBuffer> = new Map();
private voiceEchoCancellation = true;
private voiceNoiseSuppression = true;
private voiceAutoGainControl = false;
private streamGeneration = 0;
private constructor() {}
@@ -140,14 +144,15 @@ export class AudioManager {
const constraints = {
audio: {
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
echoCancellation: this.voiceEchoCancellation,
noiseSuppression: this.voiceNoiseSuppression,
autoGainControl: this.voiceAutoGainControl,
}
};
this.currentStream = await navigator.mediaDevices.getUserMedia(constraints);
this.currentInputDeviceId = deviceId;
this.streamGeneration++;
if (this.ctx && this.inputGain) {
if (this.inputSource) {
@@ -172,6 +177,30 @@ export class AudioManager {
}
}
setVoiceProcessing(opts: { echoCancellation?: boolean; noiseSuppression?: boolean; autoGainControl?: boolean }) {
let changed = false;
if (opts.echoCancellation !== undefined && opts.echoCancellation !== this.voiceEchoCancellation) {
this.voiceEchoCancellation = opts.echoCancellation;
changed = true;
}
if (opts.noiseSuppression !== undefined && opts.noiseSuppression !== this.voiceNoiseSuppression) {
this.voiceNoiseSuppression = opts.noiseSuppression;
changed = true;
}
if (opts.autoGainControl !== undefined && opts.autoGainControl !== this.voiceAutoGainControl) {
this.voiceAutoGainControl = opts.autoGainControl;
changed = true;
}
if (changed && this.currentStream) {
this.currentStream.getTracks().forEach(t => t.stop());
this.currentStream = null;
}
}
getStreamGeneration(): number {
return this.streamGeneration;
}
/**
* CRITICAL: Always returns a CLONE of the destination track.
* This prevents LiveKit's cleanup from killing the main singleton track
@@ -210,7 +239,7 @@ export class AudioManager {
*/
getMasterOutput(): AudioNode {
if (!this.ctx) this.initContext();
return this.masterCompressor!;
return this.ctx!.destination;
}
getContext(): AudioContext | null {
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
export function UserSettingsModal() {
@@ -18,6 +19,13 @@ export function UserSettingsModal() {
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
const setEchoCancellation = useVoiceStore((s) => s.setEchoCancellation);
const toggleNoiseSuppression = useVoiceStore((s) => s.toggleNoiseSuppression);
const setAutoGainControl = useVoiceStore((s) => s.setAutoGainControl);
const isOpen = activeModal === 'userSettings';
const handleSave = async () => {
@@ -113,6 +121,55 @@ export function UserSettingsModal() {
/>
</div>
{/* Voice Processing */}
<div className="border-t border-white/[0.06] pt-4">
<h3 className="text-xs font-bold text-discord-text-secondary uppercase mb-3">
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.
</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>
<button
onClick={() => setEchoCancellation(!echoCancellation)}
className={`relative w-10 h-5 rounded-full transition-colors ${echoCancellation ? 'bg-discord-green' : 'bg-discord-bg-tertiary'}`}
>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${echoCancellation ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-discord-text-primary">Noise Suppression</div>
<div className="text-xs text-discord-text-muted">Filters background noise from your mic</div>
</div>
<button
onClick={toggleNoiseSuppression}
className={`relative w-10 h-5 rounded-full transition-colors ${noiseSuppression ? 'bg-discord-green' : 'bg-discord-bg-tertiary'}`}
>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${noiseSuppression ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-discord-text-primary">Auto Gain Control</div>
<div className="text-xs text-discord-text-muted">Auto-adjusts mic volume can cause voice ducking during streams</div>
</div>
<button
onClick={() => setAutoGainControl(!autoGainControl)}
className={`relative w-10 h-5 rounded-full transition-colors ${autoGainControl ? 'bg-discord-green' : 'bg-discord-bg-tertiary'}`}
>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${autoGainControl ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<button
onClick={handleLogout}
+13 -5
View File
@@ -102,6 +102,10 @@ export function useLiveKit() {
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
const lastMicGenRef = useRef(0);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
if (!r)
@@ -192,6 +196,8 @@ export function useLiveKit() {
const syncMic = async () => {
try {
const audioManager = AudioManager.getInstance();
// Sync voice processing settings to AudioManager
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
// If muted or deafened, unpublish mic
if (isMuted || isDeafened) {
const pub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
@@ -203,25 +209,27 @@ export function useLiveKit() {
// Ensure device is set and volume is sync'd
await audioManager.setInputDevice(inputDeviceId);
audioManager.setInputVolume(inputVolume);
const currentGen = audioManager.getStreamGeneration();
// Check if already published
const existingPub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
if (existingPub && existingPub.track) {
// If track is alive, we are good.
if (existingPub.track.mediaStreamTrack?.readyState === 'live') {
// If track is alive AND settings haven't changed, we are good.
if (existingPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) {
return;
}
// If track died, unpublish so we can republish
// Settings changed or track died unpublish so we can republish
await r.localParticipant.unpublishTrack(existingPub.track);
}
// Get a FRESH track (clone) for this specific publication
const audioTrack = audioManager.getFreshTrack();
if (!audioTrack)
return;
console.log('[LiveKit] Publishing fresh microphone track');
console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')');
await r.localParticipant.publishTrack(audioTrack, {
name: 'microphone',
source: Track.Source.Microphone,
});
lastMicGenRef.current = currentGen;
}
catch (err) {
console.error('[LiveKit] Failed to sync mic state:', err);
@@ -235,7 +243,7 @@ export function useLiveKit() {
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected]);
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl]);
const connect = useCallback(async (channelId) => {
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected)
return;
+21 -10
View File
@@ -158,6 +158,11 @@ export function useLiveKit() {
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
const lastMicGenRef = useRef(0);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
@@ -243,7 +248,10 @@ export function useLiveKit() {
const syncMic = async () => {
try {
const audioManager = AudioManager.getInstance();
// Sync voice processing settings to AudioManager
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
// If muted or deafened, unpublish mic
if (isMuted || isDeafened) {
const pub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
@@ -256,16 +264,18 @@ export function useLiveKit() {
// Ensure device is set and volume is sync'd
await audioManager.setInputDevice(inputDeviceId);
audioManager.setInputVolume(inputVolume);
const currentGen = audioManager.getStreamGeneration();
// Check if already published
const existingPub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
if (existingPub && existingPub.track) {
// If track is alive, we are good.
if (existingPub.track.mediaStreamTrack?.readyState === 'live') {
// If track is alive AND settings haven't changed, we are good.
if (existingPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) {
return;
}
// If track died, unpublish so we can republish
// Settings changed or track died unpublish so we can republish
await r.localParticipant.unpublishTrack(existingPub.track as LocalAudioTrack);
}
@@ -273,12 +283,13 @@ export function useLiveKit() {
const audioTrack = audioManager.getFreshTrack();
if (!audioTrack) return;
console.log('[LiveKit] Publishing fresh microphone track');
console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')');
await r.localParticipant.publishTrack(audioTrack, {
name: 'microphone',
source: Track.Source.Microphone,
});
lastMicGenRef.current = currentGen;
} catch (err) {
console.error('[LiveKit] Failed to sync mic state:', err);
}
@@ -290,11 +301,11 @@ export function useLiveKit() {
const unsubscribe = AudioManager.getInstance().onResumed(() => {
syncMic();
});
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected]);
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl]);
const connect = useCallback(async (channelId: string) => {
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return;
+11 -1
View File
@@ -131,7 +131,11 @@ export const useVoiceStore = create()(persist((set, get) => ({
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
setVideoQuality: (quality) => set({ videoQuality: quality }),
noiseSuppression: true,
echoCancellation: true,
autoGainControl: false,
toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })),
setEchoCancellation: (enabled) => set({ echoCancellation: enabled }),
setAutoGainControl: (enabled) => set({ autoGainControl: enabled }),
deafenedUserIds: new Set(),
setUserDeafened: (userId, deafened) => {
set((state) => {
@@ -203,11 +207,15 @@ export const useVoiceStore = create()(persist((set, get) => ({
}),
}), {
name: 'opencord-voice-settings',
version: 1,
version: 2,
migrate: (persistedState, version) => {
if (version === 0) {
persistedState.streamAttenuationEnabled = false;
}
if (version < 2) {
persistedState.echoCancellation = true;
persistedState.autoGainControl = false;
}
return persistedState;
},
storage: createJSONStorage(() => localStorage),
@@ -222,6 +230,8 @@ export const useVoiceStore = create()(persist((set, get) => ({
outputDeviceId: state.outputDeviceId,
videoQuality: state.videoQuality,
noiseSuppression: state.noiseSuppression,
echoCancellation: state.echoCancellation,
autoGainControl: state.autoGainControl,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
}),
+15 -1
View File
@@ -62,7 +62,11 @@ interface VoiceState {
setFocusedParticipant: (id: string | null) => void;
setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void;
noiseSuppression: boolean;
echoCancellation: boolean;
autoGainControl: boolean;
toggleNoiseSuppression: () => void;
setEchoCancellation: (enabled: boolean) => void;
setAutoGainControl: (enabled: boolean) => void;
deafenedUserIds: Set<string>;
setUserDeafened: (userId: string, deafened: boolean) => void;
// WebSocket-based voice user status (visible without joining LiveKit)
@@ -221,7 +225,11 @@ export const useVoiceStore = create<VoiceState>()(
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
setVideoQuality: (quality) => set({ videoQuality: quality }),
noiseSuppression: true,
echoCancellation: true,
autoGainControl: false,
toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })),
setEchoCancellation: (enabled) => set({ echoCancellation: enabled }),
setAutoGainControl: (enabled) => set({ autoGainControl: enabled }),
deafenedUserIds: new Set(),
setUserDeafened: (userId, deafened) => {
set((state) => {
@@ -296,11 +304,15 @@ export const useVoiceStore = create<VoiceState>()(
}),
{
name: 'opencord-voice-settings',
version: 1,
version: 2,
migrate: (persistedState: any, version: number) => {
if (version === 0) {
persistedState.streamAttenuationEnabled = false;
}
if (version < 2) {
persistedState.echoCancellation = true;
persistedState.autoGainControl = false;
}
return persistedState;
},
storage: createJSONStorage(() => localStorage),
@@ -315,6 +327,8 @@ export const useVoiceStore = create<VoiceState>()(
outputDeviceId: state.outputDeviceId,
videoQuality: state.videoQuality,
noiseSuppression: state.noiseSuppression,
echoCancellation: state.echoCancellation,
autoGainControl: state.autoGainControl,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
}),