From 1ddf562024609dcb1ecb094b8305fecc26973bf7 Mon Sep 17 00:00:00 2001 From: jacobwtyler Date: Sun, 15 Mar 2026 17:28:43 -0500 Subject: [PATCH] Fix echo feedback loop during two-way audio (talkback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using two-way audio, the camera mic picks up speaker audio and sends it back through RTSP → go2rtc → WebRTC → phone speaker, creating an echo feedback loop. Two changes working together: 1. Half-duplex echo suppression: mute receive audio while mic is active. This is the standard approach used by camera talkback implementations. 2. Obtain mic track once, reuse across toggles: instead of rebuilding the PeerConnection on every mic toggle, the mic track is obtained once on first activation and reused. Subsequent toggles just flip track.enabled. This is critical for iOS where getUserMedia triggers audio routing mode switches that cannot be reversed without closing the PeerConnection. --- web/src/components/player/WebRTCPlayer.tsx | 84 ++++++++++++++++++++-- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/web/src/components/player/WebRTCPlayer.tsx b/web/src/components/player/WebRTCPlayer.tsx index 147af43ea..f79952db1 100644 --- a/web/src/components/player/WebRTCPlayer.tsx +++ b/web/src/components/player/WebRTCPlayer.tsx @@ -56,8 +56,15 @@ export default function WebRtcPlayer({ const [bufferTimeout, setBufferTimeout] = useState(); const videoLoadTimeoutRef = useRef(undefined); + // microphone state: track is obtained once and reused across connection rebuilds + const micTrackRef = useRef(null); + const [micArmed, setMicArmed] = useState(false); + const PeerConnection = useCallback( - async (media: string) => { + async ( + media: string, + existingMicTrack?: MediaStreamTrack | null, + ) => { if (!videoRef.current) { return; } @@ -69,7 +76,10 @@ export default function WebRtcPlayer({ const localTracks = []; - if (/camera|microphone/.test(media)) { + if (existingMicTrack && /microphone/.test(media)) { + // Reuse existing mic track (no new getUserMedia call) + pc.addTransceiver(existingMicTrack, { direction: "sendonly" }); + } else if (/camera|microphone/.test(media)) { const tracks = await getMediaTracks("user", { video: media.indexOf("camera") >= 0, audio: media.indexOf("microphone") >= 0, @@ -168,6 +178,9 @@ export default function WebRtcPlayer({ [wsURL], ); + // Main connection effect. + // Depends on micArmed (not microphoneEnabled) so the connection only rebuilds + // once when the mic is first obtained, not on every mic toggle. useEffect(() => { if (!videoRef.current) { return; @@ -177,9 +190,8 @@ export default function WebRtcPlayer({ return; } - const aPc = PeerConnection( - microphoneEnabled ? "video+audio+microphone" : "video+audio", - ); + const media = micArmed ? "video+audio+microphone" : "video+audio"; + const aPc = PeerConnection(media, micTrackRef.current); connect(aPc); return () => { @@ -195,9 +207,61 @@ export default function WebRtcPlayer({ pcRef, videoRef, playbackEnabled, - microphoneEnabled, + micArmed, ]); + // Microphone handling: obtain track once, then toggle enabled/muted. + // This avoids rebuilding the PeerConnection on every mic toggle, which + // prevents iOS from switching audio routing and causing echo feedback. + useEffect(() => { + if (!microphoneEnabled) { + // Mic toggled off: disable send track + restore receive audio + if (micTrackRef.current) { + micTrackRef.current.enabled = false; + } + return; + } + + if (!micArmed) { + // First mic activation: request permission (requires user gesture, + // which the mic button click provides) and arm the mic track. + navigator.mediaDevices + .getUserMedia({ audio: true }) + .then((stream) => { + const track = stream.getAudioTracks()[0]; + track.enabled = true; + micTrackRef.current = track; + // Listen for track ending (e.g., OS revokes permission) + track.addEventListener("ended", () => { + micTrackRef.current = null; + setMicArmed(false); + }); + // This state change triggers the main connection effect to + // rebuild once with the mic track included in the SDP offer. + setMicArmed(true); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error("Microphone permission denied:", e); + }); + } else { + // Already armed: just enable the existing track (no rebuild) + if (micTrackRef.current) { + micTrackRef.current.enabled = true; + } + } + }, [microphoneEnabled, micArmed]); + + // Clean up mic track on unmount + useEffect(() => { + return () => { + if (micTrackRef.current) { + micTrackRef.current.stop(); + micTrackRef.current = null; + } + }; + }, []); + // ios compat const [iOSCompatControls, setiOSCompatControls] = useState(false); @@ -312,6 +376,12 @@ export default function WebRtcPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [pcRef, pcRef.current, getStats]); + // Echo suppression: mute receive audio while mic is active. + // Camera mic picks up speaker audio → RTSP → go2rtc → WebRTC → phone + // speaker, creating a feedback loop. Half-duplex (mute during talk) is + // the standard approach used by camera talkback implementations. + const echoMuted = microphoneEnabled && micArmed; + return (