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 <noreply@anthropic.com>

* fix(ui): harden microphone release in webrtc hook and embed widget

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: AYMENPAKISS2 <tech.nomatrade@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Aymenbenpakiss 2026-06-18 08:17:14 +01:00 committed by GitHub
parent 951e73a645
commit 37c4bda0c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 2 deletions

View file

@ -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

View file

@ -70,6 +70,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const audioRef = useRef<HTMLAudioElement>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const timeStartRef = useRef<number | null>(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,