From 25f72aef3b43b8b5953d81027863a432add6142c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:00:08 +0100 Subject: [PATCH] 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. --- packages/web/src/audio/AudioManager.js | 35 ++++++++++-- packages/web/src/audio/AudioManager.ts | 37 ++++++++++-- .../web/src/components/modals/UserSettings.js | 9 ++- .../src/components/modals/UserSettings.tsx | 57 +++++++++++++++++++ packages/web/src/hooks/useLiveKit.js | 18 ++++-- packages/web/src/hooks/useLiveKit.ts | 31 ++++++---- packages/web/src/stores/voiceStore.js | 12 +++- packages/web/src/stores/voiceStore.ts | 16 +++++- 8 files changed, 189 insertions(+), 26 deletions(-) diff --git a/packages/web/src/audio/AudioManager.js b/packages/web/src/audio/AudioManager.js index e3d9913e..62d6e403 100644 --- a/packages/web/src/audio/AudioManager.js +++ b/packages/web/src/audio/AudioManager.js @@ -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; diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index cd1aac69..4756ebfa 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -14,6 +14,10 @@ export class AudioManager { private listeners: Set<() => void> = new Set(); private soundBuffers: Map = 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 { diff --git a/packages/web/src/components/modals/UserSettings.js b/packages/web/src/components/modals/UserSettings.js index a6a5043c..f14f2d8b 100644 --- a/packages/web/src/components/modals/UserSettings.js +++ b/packages/web/src/components/modals/UserSettings.js @@ -3,6 +3,7 @@ import { 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() { const activeModal = useUIStore((s) => s.activeModal); @@ -16,6 +17,12 @@ export function UserSettingsModal() { const [error, setError] = useState(''); 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 () => { setError(''); @@ -43,5 +50,5 @@ export function UserSettingsModal() { }; if (!user) return null; - return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "User Settings", maxWidth: "max-w-lg", children: _jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 64, status: user.status }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-lg", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-discord-text-muted text-sm", children: ["@", user.username] }), user.customStatus && (_jsx("div", { className: "text-discord-text-secondary text-sm mt-1", children: user.customStatus }))] })] }), error && (_jsx("div", { className: "p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-text-positive text-sm", children: success })), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Status" }), _jsxs("select", { value: status, onChange: (e) => setStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple appearance-none", children: [_jsx("option", { value: "online", children: "Online" }), _jsx("option", { value: "idle", children: "Idle" }), _jsx("option", { value: "dnd", children: "Do Not Disturb" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Custom Status" }), _jsx("input", { type: "text", value: customStatus, onChange: (e) => setCustomStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What are you up to?" })] }), _jsxs("div", { className: "flex items-center justify-between pt-2", children: [_jsx("button", { onClick: handleLogout, className: "px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Log Out" }), _jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' })] })] }) })); + return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "User Settings", maxWidth: "max-w-lg", children: _jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 64, status: user.status }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-lg", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-discord-text-muted text-sm", children: ["@", user.username] }), user.customStatus && (_jsx("div", { className: "text-discord-text-secondary text-sm mt-1", children: user.customStatus }))] })] }), error && (_jsx("div", { className: "p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-text-positive text-sm", children: success })), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Status" }), _jsxs("select", { value: status, onChange: (e) => setStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple appearance-none", children: [_jsx("option", { value: "online", children: "Online" }), _jsx("option", { value: "idle", children: "Idle" }), _jsx("option", { value: "dnd", children: "Do Not Disturb" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Custom Status" }), _jsx("input", { type: "text", value: customStatus, onChange: (e) => setCustomStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What are you up to?" })] }), _jsxs("div", { className: "border-t border-white/[0.06] pt-4", children: [_jsx("h3", { className: "text-xs font-bold text-discord-text-secondary uppercase mb-3", children: "Voice Processing" }), _jsx("p", { className: "text-xs text-discord-text-muted mb-3", children: "Disable Auto Gain Control when streaming to prevent your browser from ducking your microphone." }), _jsxs("div", { className: "flex items-center justify-between py-2", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm text-discord-text-primary", children: "Echo Cancellation" }), _jsx("div", { className: "text-xs text-discord-text-muted", children: "Removes echo when using speakers" })] }), _jsx("button", { onClick: () => setEchoCancellation(!echoCancellation), className: `relative w-10 h-5 rounded-full transition-colors ${echoCancellation ? 'bg-discord-green' : 'bg-discord-bg-tertiary'}`, children: _jsx("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'}` }) })] }), _jsxs("div", { className: "flex items-center justify-between py-2", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm text-discord-text-primary", children: "Noise Suppression" }), _jsx("div", { className: "text-xs text-discord-text-muted", children: "Filters background noise from your mic" })] }), _jsx("button", { onClick: toggleNoiseSuppression, className: `relative w-10 h-5 rounded-full transition-colors ${noiseSuppression ? 'bg-discord-green' : 'bg-discord-bg-tertiary'}`, children: _jsx("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'}` }) })] }), _jsxs("div", { className: "flex items-center justify-between py-2", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm text-discord-text-primary", children: "Auto Gain Control" }), _jsx("div", { className: "text-xs text-discord-text-muted", children: "Auto-adjusts mic volume \u2014 can cause voice ducking during streams" })] }), _jsx("button", { onClick: () => setAutoGainControl(!autoGainControl), className: `relative w-10 h-5 rounded-full transition-colors ${autoGainControl ? 'bg-discord-green' : 'bg-discord-bg-tertiary'}`, children: _jsx("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'}` }) })] })] }), _jsxs("div", { className: "flex items-center justify-between pt-2", children: [_jsx("button", { onClick: handleLogout, className: "px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Log Out" }), _jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' })] })] }) })); } diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index a5df9f61..df972876 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -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() { /> + {/* Voice Processing */} +
+

+ Voice Processing +

+

+ Disable Auto Gain Control when streaming to prevent your browser from ducking your microphone. +

+ +
+
+
Echo Cancellation
+
Removes echo when using speakers
+
+ +
+ +
+
+
Noise Suppression
+
Filters background noise from your mic
+
+ +
+ +
+
+
Auto Gain Control
+
Auto-adjusts mic volume — can cause voice ducking during streams
+
+ +
+
+