From 196d4fc28a54ce818c00ddfb38dbb8216863cf13 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 5 Nov 2025 16:31:29 +0000 Subject: [PATCH 1/5] Specify exact for deviceId --- src/webrtc/mediaHandler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/webrtc/mediaHandler.ts b/src/webrtc/mediaHandler.ts index dfdde3f6b06..7c42e4c2fa1 100644 --- a/src/webrtc/mediaHandler.ts +++ b/src/webrtc/mediaHandler.ts @@ -441,7 +441,8 @@ export class MediaHandler extends TypedEventEmitter< return { audio: audio ? { - deviceId: this.audioInput ? { ideal: this.audioInput } : undefined, + /* Not specifying exact for deviceId means switching devices does not always work */ + deviceId: this.audioInput ? { exact: this.audioInput } : undefined, autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined, echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined, noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined, @@ -449,7 +450,8 @@ export class MediaHandler extends TypedEventEmitter< : false, video: video ? { - deviceId: this.videoInput ? { ideal: this.videoInput } : undefined, + /* Not specifying exact for deviceId means switching devices does not always work */ + deviceId: this.videoInput ? { exact: this.videoInput } : undefined, /* We want 640x360. Chrome will give it only if we ask exactly, FF refuses entirely if we ask exactly, so have to ask for ideal instead From 9d36d92fcccd34f75aed8637f1337e543285b722 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 5 Nov 2025 16:38:42 +0000 Subject: [PATCH 2/5] Update mediaHandler.spec.ts --- spec/unit/webrtc/mediaHandler.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/unit/webrtc/mediaHandler.spec.ts b/spec/unit/webrtc/mediaHandler.spec.ts index 3e34f7dd419..4559ee634b6 100644 --- a/spec/unit/webrtc/mediaHandler.spec.ts +++ b/spec/unit/webrtc/mediaHandler.spec.ts @@ -61,10 +61,10 @@ describe("Media Handler", function () { expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( expect.objectContaining({ audio: expect.objectContaining({ - deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + deviceId: { exact: FAKE_AUDIO_INPUT_ID }, }), video: expect.objectContaining({ - deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + deviceId: { exact: FAKE_VIDEO_INPUT_ID }, }), }), ); @@ -77,7 +77,7 @@ describe("Media Handler", function () { expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( expect.objectContaining({ audio: expect.objectContaining({ - deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + deviceId: { exact: FAKE_AUDIO_INPUT_ID }, }), }), ); @@ -109,7 +109,7 @@ describe("Media Handler", function () { expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( expect.objectContaining({ video: expect.objectContaining({ - deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + deviceId: { exact: FAKE_VIDEO_INPUT_ID }, }), }), ); @@ -122,10 +122,10 @@ describe("Media Handler", function () { expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( expect.objectContaining({ audio: expect.objectContaining({ - deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + deviceId: { exact: FAKE_AUDIO_INPUT_ID }, }), video: expect.objectContaining({ - deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + deviceId: { exact: FAKE_VIDEO_INPUT_ID }, }), }), ); From 2f3a93b4252e6e14374b871f0508f02a2b21dd03 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 5 Nov 2025 17:42:21 +0000 Subject: [PATCH 3/5] fallback to ideal if exact fails --- src/webrtc/mediaHandler.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/webrtc/mediaHandler.ts b/src/webrtc/mediaHandler.ts index 7c42e4c2fa1..8841970e540 100644 --- a/src/webrtc/mediaHandler.ts +++ b/src/webrtc/mediaHandler.ts @@ -264,8 +264,19 @@ export class MediaHandler extends TypedEventEmitter< } if (!canReuseStream) { - const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo); - stream = await navigator.mediaDevices.getUserMedia(constraints); + let constraints: MediaStreamConstraints; + try { + // Not specifying exact for deviceId means switching devices does not always work, + // try with exact and fallback to ideal if it fails + constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, true); + stream = await navigator.mediaDevices.getUserMedia(constraints); + } catch (e) { + logger.warn( + `MediaHandler getUserMediaStreamInternal() error (e=${e}), retrying without exact deviceId`, + ); + constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, false); + stream = await navigator.mediaDevices.getUserMedia(constraints); + } logger.log( `MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${ stream.id @@ -435,14 +446,13 @@ export class MediaHandler extends TypedEventEmitter< this.emit(MediaHandlerEvent.LocalStreamsChanged); } - private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints { + private getUserMediaContraints(audio: boolean, video: boolean, exactDeviceId?: boolean): MediaStreamConstraints { const isWebkit = !!navigator.webkitGetUserMedia; return { audio: audio ? { - /* Not specifying exact for deviceId means switching devices does not always work */ - deviceId: this.audioInput ? { exact: this.audioInput } : undefined, + deviceId: this.audioInput ? { [exactDeviceId ? "exact" : "ideal"]: this.audioInput } : undefined, autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined, echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined, noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined, @@ -450,8 +460,7 @@ export class MediaHandler extends TypedEventEmitter< : false, video: video ? { - /* Not specifying exact for deviceId means switching devices does not always work */ - deviceId: this.videoInput ? { exact: this.videoInput } : undefined, + deviceId: this.videoInput ? { [exactDeviceId ? "exact" : "ideal"]: this.videoInput } : undefined, /* We want 640x360. Chrome will give it only if we ask exactly, FF refuses entirely if we ask exactly, so have to ask for ideal instead From aa0be43edf83cbb4e8f64337ff15b1b36146df92 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 6 Nov 2025 14:45:46 +0000 Subject: [PATCH 4/5] Reduce cognitive complexity for sonar --- src/webrtc/mediaHandler.ts | 46 +++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/webrtc/mediaHandler.ts b/src/webrtc/mediaHandler.ts index 8841970e540..fac28d1e412 100644 --- a/src/webrtc/mediaHandler.ts +++ b/src/webrtc/mediaHandler.ts @@ -448,28 +448,34 @@ export class MediaHandler extends TypedEventEmitter< private getUserMediaContraints(audio: boolean, video: boolean, exactDeviceId?: boolean): MediaStreamConstraints { const isWebkit = !!navigator.webkitGetUserMedia; + const deviceIdKey = exactDeviceId ? "exact" : "ideal"; + + const audioConstraints: MediaTrackConstraints = {}; + if (this.audioInput) { + audioConstraints.deviceId = { [deviceIdKey]: this.audioInput }; + } + if (this.audioSettings) { + audioConstraints.autoGainControl = { ideal: this.audioSettings.autoGainControl }; + audioConstraints.echoCancellation = { ideal: this.audioSettings.echoCancellation }; + audioConstraints.noiseSuppression = { ideal: this.audioSettings.noiseSuppression }; + } + + const videoConstraints: MediaTrackConstraints = { + /* We want 640x360. Chrome will give it only if we ask exactly, + FF refuses entirely if we ask exactly, so have to ask for ideal + instead + XXX: Is this still true? + */ + width: isWebkit ? { exact: 640 } : { ideal: 640 }, + height: isWebkit ? { exact: 360 } : { ideal: 360 }, + }; + if (this.videoInput) { + videoConstraints.deviceId = { [deviceIdKey]: this.videoInput }; + } return { - audio: audio - ? { - deviceId: this.audioInput ? { [exactDeviceId ? "exact" : "ideal"]: this.audioInput } : undefined, - autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined, - echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined, - noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined, - } - : false, - video: video - ? { - deviceId: this.videoInput ? { [exactDeviceId ? "exact" : "ideal"]: this.videoInput } : undefined, - /* We want 640x360. Chrome will give it only if we ask exactly, - FF refuses entirely if we ask exactly, so have to ask for ideal - instead - XXX: Is this still true? - */ - width: isWebkit ? { exact: 640 } : { ideal: 640 }, - height: isWebkit ? { exact: 360 } : { ideal: 360 }, - } - : false, + audio: audio ? audioConstraints : false, + video: video ? videoConstraints : false, }; } From 6e9cb1ae47b8175f136dc6caa65a0655ba01f643 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 6 Nov 2025 14:57:00 +0000 Subject: [PATCH 5/5] Add tests --- spec/unit/webrtc/mediaHandler.spec.ts | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/spec/unit/webrtc/mediaHandler.spec.ts b/spec/unit/webrtc/mediaHandler.spec.ts index 4559ee634b6..0f7561c5f38 100644 --- a/spec/unit/webrtc/mediaHandler.spec.ts +++ b/spec/unit/webrtc/mediaHandler.spec.ts @@ -331,6 +331,80 @@ describe("Media Handler", function () { expect(stream.getVideoTracks().length).toEqual(0); }); + + it("falls back to ideal deviceId when exact deviceId fails", async () => { + // First call with exact should fail + mockMediaDevices.getUserMedia + .mockRejectedValueOnce(new Error("OverconstrainedError")) + .mockImplementation((constraints: MediaStreamConstraints) => { + const stream = new MockMediaStream("local_stream"); + if (constraints.audio) { + const track = new MockMediaStreamTrack("audio_track", "audio"); + track.settings = { deviceId: FAKE_AUDIO_INPUT_ID }; + stream.addTrack(track); + } + return Promise.resolve(stream.typed()); + }); + + const stream = await mediaHandler.getUserMediaStream(true, false); + + // Should have been called twice: once with exact, once with ideal + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2); + expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + audio: expect.objectContaining({ + deviceId: { exact: FAKE_AUDIO_INPUT_ID }, + }), + }), + ); + expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + audio: expect.objectContaining({ + deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + }), + }), + ); + expect(stream).toBeTruthy(); + }); + + it("falls back to ideal deviceId for video when exact fails", async () => { + // First call with exact should fail + mockMediaDevices.getUserMedia + .mockRejectedValueOnce(new Error("OverconstrainedError")) + .mockImplementation((constraints: MediaStreamConstraints) => { + const stream = new MockMediaStream("local_stream"); + if (constraints.video) { + const track = new MockMediaStreamTrack("video_track", "video"); + track.settings = { deviceId: FAKE_VIDEO_INPUT_ID }; + stream.addTrack(track); + } + return Promise.resolve(stream.typed()); + }); + + const stream = await mediaHandler.getUserMediaStream(false, true); + + // Should have been called twice: once with exact, once with ideal + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2); + expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + video: expect.objectContaining({ + deviceId: { exact: FAKE_VIDEO_INPUT_ID }, + }), + }), + ); + expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + video: expect.objectContaining({ + deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + }), + }), + ); + expect(stream).toBeTruthy(); + }); }); describe("getScreensharingStream", () => {