From dbebd38576a40d6bef102024342a16d5951f8093 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:17:16 +0100 Subject: [PATCH] refactor: replace unpublishTrack mute with setMicrophoneEnabled (Phase 1 RNNoise prep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncMic now uses setMicrophoneEnabled(false/true) to mute/unmute the mic track in-place instead of tearing down and re-publishing via unpublishTrack(). This eliminates WebRTC renegotiation on every mute cycle and preserves the Web Audio pipeline for future AudioWorklet injection (RNNoise WASM). - Unify DmCallView mute/deafen with the primary syncMic path — removed direct setMicrophoneEnabled calls, added missing auto-mute/unmute on deafen toggle. - Strip redundant applyConstraints from VoiceControls noise suppression toggle that was hardcoding echoCancellation:true/autoGainControl:true and fighting AudioManager's constraint pipeline. --- .../web/src/components/voice/DmCallView.js | 16 +++------ .../web/src/components/voice/DmCallView.tsx | 19 +++++----- .../web/src/components/voice/VoiceControls.js | 19 +--------- .../src/components/voice/VoiceControls.tsx | 21 ++--------- packages/web/src/hooks/useLiveKit.js | 36 ++++++++++--------- packages/web/src/hooks/useLiveKit.ts | 34 ++++++++++-------- 6 files changed, 55 insertions(+), 90 deletions(-) diff --git a/packages/web/src/components/voice/DmCallView.js b/packages/web/src/components/voice/DmCallView.js index 9d103dc4..880162dd 100644 --- a/packages/web/src/components/voice/DmCallView.js +++ b/packages/web/src/components/voice/DmCallView.js @@ -33,31 +33,23 @@ export function DmCallView() { const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); const otherName = otherUser?.displayName ?? otherUser?.username ?? 'User'; const handleMute = () => { - const room = getActiveRoom(); - if (room) { - room.localParticipant.setMicrophoneEnabled(isMuted); - } toggleMic(); }; const handleDeafen = () => { const room = getActiveRoom(); + const willDeafen = !isDeafened; if (room) { - const newDeafened = !isDeafened; room.remoteParticipants.forEach((p) => { p.audioTrackPublications.forEach((pub) => { if (pub.track) { - pub.track.setVolume?.(newDeafened ? 0 : 1); + pub.track.setVolume?.(willDeafen ? 0 : 1); } }); }); - if (newDeafened) { - room.localParticipant.setMicrophoneEnabled(false); - } - else if (!isMuted) { - room.localParticipant.setMicrophoneEnabled(true); - } } toggleDeafen(); + if (willDeafen && !isMuted) toggleMic(); + if (!willDeafen && isMuted) toggleMic(); }; const handleCamera = async () => { const room = getActiveRoom(); diff --git a/packages/web/src/components/voice/DmCallView.tsx b/packages/web/src/components/voice/DmCallView.tsx index 53c8a8a0..da30fc7d 100644 --- a/packages/web/src/components/voice/DmCallView.tsx +++ b/packages/web/src/components/voice/DmCallView.tsx @@ -37,32 +37,29 @@ export function DmCallView() { const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); const otherName = otherUser?.displayName ?? otherUser?.username ?? 'User'; + // Mute/deafen toggling is handled by syncMic in useLiveKit via store state. + // DmCallView only needs to toggle store + handle remote audio silencing for deafen. const handleMute = () => { - const room = getActiveRoom(); - if (room) { - room.localParticipant.setMicrophoneEnabled(isMuted); - } toggleMic(); }; const handleDeafen = () => { const room = getActiveRoom(); + const willDeafen = !isDeafened; + // Silence/restore remote participant audio for deafen if (room) { - const newDeafened = !isDeafened; room.remoteParticipants.forEach((p) => { p.audioTrackPublications.forEach((pub) => { if (pub.track) { - (pub.track as any).setVolume?.(newDeafened ? 0 : 1); + (pub.track as any).setVolume?.(willDeafen ? 0 : 1); } }); }); - if (newDeafened) { - room.localParticipant.setMicrophoneEnabled(false); - } else if (!isMuted) { - room.localParticipant.setMicrophoneEnabled(true); - } } toggleDeafen(); + // Auto-mute on deafen, auto-unmute on undeafen + if (willDeafen && !isMuted) toggleMic(); + if (!willDeafen && isMuted) toggleMic(); }; const handleCamera = async () => { diff --git a/packages/web/src/components/voice/VoiceControls.js b/packages/web/src/components/voice/VoiceControls.js index 0f52473a..27f96177 100644 --- a/packages/web/src/components/voice/VoiceControls.js +++ b/packages/web/src/components/voice/VoiceControls.js @@ -54,24 +54,7 @@ export function VoiceControls() { console.error('[VoiceControls] Failed to toggle screen share:', err); } }; - const handleNoiseSuppression = async () => { - const room = getActiveRoom(); - if (room) { - try { - const micPub = room.localParticipant.getTrackPublications().find(p => p.source === 'microphone'); - const mediaTrack = micPub?.track?.mediaStreamTrack; - if (mediaTrack) { - await mediaTrack.applyConstraints({ - noiseSuppression: !noiseSuppression, - echoCancellation: true, - autoGainControl: true, - }); - } - } - catch (err) { - console.error('[VoiceControls] Failed to toggle noise suppression:', err); - } - } + const handleNoiseSuppression = () => { toggleNoiseSuppression(); }; const handleDisconnect = () => { diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 92ba991d..d8286bc1 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -56,25 +56,8 @@ export function VoiceControls() { } }; - const handleNoiseSuppression = async () => { - const room = getActiveRoom(); - if (room) { - try { - const micPub = room.localParticipant.getTrackPublications().find( - p => p.source === 'microphone' - ); - const mediaTrack = micPub?.track?.mediaStreamTrack; - if (mediaTrack) { - await mediaTrack.applyConstraints({ - noiseSuppression: !noiseSuppression, - echoCancellation: true, - autoGainControl: true, - }); - } - } catch (err) { - console.error('[VoiceControls] Failed to toggle noise suppression:', err); - } - } + const handleNoiseSuppression = () => { + // Store toggle triggers syncMic → AudioManager re-acquires stream with correct constraints toggleNoiseSuppression(); }; diff --git a/packages/web/src/hooks/useLiveKit.js b/packages/web/src/hooks/useLiveKit.js index 5f91f177..910f58b1 100644 --- a/packages/web/src/hooks/useLiveKit.js +++ b/packages/web/src/hooks/useLiveKit.js @@ -189,6 +189,8 @@ export function useLiveKit() { catch { } }, [updateParticipants]); // Handle Input Device & Mute Logic via AudioManager + // Mute uses setMicrophoneEnabled(false) to keep the track published (silence frames) + // instead of unpublishTrack() which tears down the WebRTC transport. useEffect(() => { const r = roomRef.current; if (!r || !isConnected) @@ -196,31 +198,34 @@ 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 + audioManager.setScreenShareActive(isScreenSharing); + const micPub = r.localParticipant.getTrackPublications() + .find(p => p.source === Track.Source.Microphone); + // If muted or deafened, mute the track in-place (keep it published) if (isMuted || isDeafened) { - const pub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone); - if (pub) { - await r.localParticipant.unpublishTrack(pub.track); + if (micPub?.track && !micPub.isMuted) { + await r.localParticipant.setMicrophoneEnabled(false); } return; } - // Ensure device is set and volume is sync'd + // Not muted — ensure mic is published and live 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 AND settings haven't changed, we are good. - if (existingPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { + if (micPub?.track) { + // Track already published — check if it's still current + if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { + // Current and live — just unmute if needed + if (micPub.isMuted) { + await r.localParticipant.setMicrophoneEnabled(true); + } return; } - // Settings changed or track died — unpublish so we can republish - await r.localParticipant.unpublishTrack(existingPub.track); + // Track is stale (device or constraint change) — replace it + await r.localParticipant.unpublishTrack(micPub.track); } - // Get a FRESH track (clone) for this specific publication + // Publish fresh track from AudioManager pipeline const audioTrack = audioManager.getFreshTrack(); if (!audioTrack) return; @@ -236,14 +241,13 @@ export function useLiveKit() { } }; syncMic(); - // Re-sync when AudioManager resumes const unsubscribe = AudioManager.getInstance().onResumed(() => { syncMic(); }); return () => { unsubscribe(); }; - }, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl]); + }, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, isScreenSharing]); const connect = useCallback(async (channelId) => { if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return; diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index b21ab651..c54d3db2 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -241,6 +241,9 @@ export function useLiveKit() { }, [updateParticipants]); // Handle Input Device & Mute Logic via AudioManager + // Mute uses setMicrophoneEnabled(false) to keep the track published (silence frames) + // instead of unpublishTrack() which tears down the WebRTC transport. + // This preserves the Web Audio pipeline for future AudioWorklet nodes (e.g. RNNoise). useEffect(() => { const r = roomRef.current; if (!r || !isConnected) return; @@ -254,34 +257,37 @@ export function useLiveKit() { // Keep screen share state in sync (handles edge cases like remounts) audioManager.setScreenShareActive(isScreenSharing); - // If muted or deafened, unpublish mic + const micPub = r.localParticipant.getTrackPublications() + .find(p => p.source === Track.Source.Microphone); + + // If muted or deafened, mute the track in-place (keep it published) if (isMuted || isDeafened) { - const pub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone); - if (pub) { - await r.localParticipant.unpublishTrack(pub.track as LocalAudioTrack); + if (micPub?.track && !micPub.isMuted) { + await r.localParticipant.setMicrophoneEnabled(false); } return; } - // Ensure device is set and volume is sync'd + // Not muted — ensure mic is published and live 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 AND settings haven't changed, we are good. - if (existingPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { + if (micPub?.track) { + // Track already published — check if it's still current + if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { + // Current and live — just unmute if needed + if (micPub.isMuted) { + await r.localParticipant.setMicrophoneEnabled(true); + } return; } - // Settings changed or track died — unpublish so we can republish - await r.localParticipant.unpublishTrack(existingPub.track as LocalAudioTrack); + // Track is stale (device or constraint change) — replace it + await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack); } - // Get a FRESH track (clone) for this specific publication + // Publish fresh track from AudioManager pipeline const audioTrack = audioManager.getFreshTrack(); if (!audioTrack) return;