From 37c4bda0c7c5d4ba266fb31efa2083bd4cea3b3c Mon Sep 17 00:00:00 2001 From: Aymenbenpakiss Date: Thu, 18 Jun 2026 08:17:14 +0100 Subject: [PATCH] fix(ui): release microphone stream on call teardown so a second test call works (#446) * fix(ui): release microphone stream on call teardown The browser "Call Voice Agent" test call worked only once per page load. A second attempt failed (mic stuck / "Could not acquire media") until the user reloaded the page or cleared site data and re-granted mic permission. Root cause: the MediaStream from getUserMedia() was added to the peer connection but never retained or explicitly stopped on teardown. On hangup only sender.track.stop() (via pc.getSenders()) ran; browsers can keep the microphone device held through the original MediaStream reference, so the next getUserMedia() is blocked. Fix: keep the stream in localStreamRef and stop all of its tracks in cleanupConnection() (the shared teardown path) and in the unmount cleanup, so the device is fully released between calls. Co-Authored-By: Claude Opus 4.8 * fix(ui): harden microphone release in webrtc hook and embed widget Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: AYMENPAKISS2 Co-authored-by: Claude Opus 4.8 Co-authored-by: Abhishek Kumar --- ui/public/embed/dograh-widget.js | 30 +++++++++++++++++++ .../run/[runId]/hooks/useWebSocketRTC.tsx | 22 ++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/ui/public/embed/dograh-widget.js b/ui/public/embed/dograh-widget.js index 741cf189..65614cf7 100644 --- a/ui/public/embed/dograh-widget.js +++ b/ui/public/embed/dograh-widget.js @@ -633,6 +633,11 @@ // Request microphone permission try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // Release any stream still held from a prior attempt before retaining + // the new one, so a re-entrant start can't leak the microphone. + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + } state.stream = stream; } catch (micError) { // Handle specific microphone permission errors @@ -660,6 +665,31 @@ } catch (error) { console.error('Dograh Widget: Failed to start call', error); + + // Release anything acquired before the failure so a retry starts clean. + // getUserMedia may have succeeded before a later step (WebSocket / + // negotiation) threw, which would otherwise leave the mic held and block + // the next getUserMedia(). Null the refs before close() so the peer/ws + // state handlers short-circuit instead of re-entering teardown. + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } + if (state.pc) { + const pc = state.pc; + state.pc = null; + if (pc.signalingState !== 'closed') { + pc.close(); + } + } + if (state.ws) { + const ws = state.ws; + state.ws = null; + if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + ws.close(); + } + } + updateStatus('failed', 'Connection failed', error.message || 'Please check your microphone and try again'); // Trigger error callback diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx index 3a7ee3ee..f93e5f1b 100644 --- a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx +++ b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx @@ -70,6 +70,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia const audioRef = useRef(null); const pcRef = useRef(null); const wsRef = useRef(null); + const localStreamRef = useRef(null); const timeStartRef = useRef(null); const onNodeTransitionRef = useRef(onNodeTransition); const connectionActiveRef = useRef(connectionActive); @@ -153,6 +154,16 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia } }, []); + const stopLocalStream = useCallback(() => { + // Release the microphone so the device is freed for a subsequent call. + // Stopping the sender tracks via pc.getSenders() alone can leave the + // browser holding the mic, blocking the next getUserMedia(). + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()); + localStreamRef.current = null; + } + }, []); + const cleanupConnection = useCallback((options: CleanupConnectionOptions = {}) => { const graceful = options.graceful ?? true; const status = options.status ?? (graceful ? 'idle' : 'failed'); @@ -173,10 +184,12 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia wsRef.current = null; } + stopLocalStream(); + if (options.closePeerConnection !== false) { closePeerConnection(pcRef.current, options.delayPeerClose ?? false); } - }, [closePeerConnection]); + }, [closePeerConnection, stopLocalStream]); const createPeerConnection = () => { // Build ICE servers list @@ -743,6 +756,10 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia if (constraints.audio) { try { const stream = await navigator.mediaDevices.getUserMedia(constraints); + // Release any stream still held from a prior attempt before + // retaining the new one, so re-entry can't leak a device. + stopLocalStream(); + localStreamRef.current = stream; stream.getTracks().forEach((track) => { pc.addTrack(track, stream); }); @@ -770,6 +787,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia // Cleanup on unmount useEffect(() => { return () => { + stopLocalStream(); if (wsRef.current) { wsRef.current.close(); } @@ -777,7 +795,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia pcRef.current.close(); } }; - }, []); + }, [stopLocalStream]); return { audioRef,