From d91b217da3c7c56be57ec039496686688ede77da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Mill=C3=A1n?= Date: Wed, 21 Oct 2026 15:03:17 +0200 Subject: [PATCH] Fix extractRtpCapabilities() mixing up audio and video RTP header extensions `headerExtensionMap` was indexed by preferred id alone. Since all m= sections are bundled, the browser gives the same id to a given extension in every m= section that supports it (sdes:mid, abs-send-time, transport-cc or abs-capture-time typically share their id between the audio and the video m= section), so audio and video fought for the same map slot and the last m= section in the SDP won. The other kind's extension simply disappeared from the extracted capabilities (`matchHeaderExtensions()` requires both kinds to be equal) and hence from the answer we generate. Among other things, this made Chrome stop the mandatory sdes:mid extension in transceivers of the losing kind, eventually making `transport.produce()` fail with "Failed to execute 'setHeaderExtensionsToNegotiate' on 'RTCRtpTransceiver': Attempted to stop a mandatory extension". Index `headerExtensionMap` by kind and preferred id so that each kind keeps its own entry. Both kinds now always get their full set of extensions, every answer carries all its extmaps (sdes:mid included), and the m= section order no longer has any effect. Added a regression test. --- src/handlers/sdp/commonUtils.ts | 16 +++++++-- src/test/test.ts | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/handlers/sdp/commonUtils.ts b/src/handlers/sdp/commonUtils.ts index 70c38bc5..6d0cc201 100644 --- a/src/handlers/sdp/commonUtils.ts +++ b/src/handlers/sdp/commonUtils.ts @@ -27,8 +27,15 @@ export function extractRtpCapabilities({ }): RtpCapabilities { // Map of RtpCodecParameters indexed by payload type. const codecsMap: Map = new Map(); - // Map of RtpHeaderExtensions indexed by preferred id. - const headerExtensionMap: Map = new Map(); + // Map of RtpHeaderExtensions indexed by kind and preferred id. + // + // NOTE: It must be indexed by kind and id rather than just by id. Since all + // m= sections are bundled, the browser gives the same id to a given extension + // in every m= section that supports it, so indexing just by id would collapse + // the audio and the video entry of such an extension into a single one with + // the kind of the last processed m= section, making the extension vanish for + // the other kind. + const headerExtensionMap: Map = new Map(); for (const m of sdpObject.media) { const kind = m.type; @@ -122,7 +129,10 @@ export function extractRtpCapabilities({ preferredId: ext.value, }; - headerExtensionMap.set(headerExtension.preferredId, headerExtension); + headerExtensionMap.set( + `${kind}:${headerExtension.preferredId}`, + headerExtension + ); } } diff --git a/src/test/test.ts b/src/test/test.ts index 6b508dee..8b978722 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -4,7 +4,9 @@ */ import { FakeMediaStreamTrack } from 'fake-mediastreamtrack'; +import * as sdpTransform from 'sdp-transform'; import * as mediasoupClient from '../index'; +import * as sdpCommonUtils from '../handlers/sdp/commonUtils'; import { UnsupportedError, InvalidStateError } from '../errors'; import * as utils from '../utils'; import type { RtpCapabilities } from '../RtpParameters'; @@ -1762,3 +1764,62 @@ describe('detectDevice() and detectDeviceAsync() assign proper handler based on ); } }); + +test('sdpCommonUtils.extractRtpCapabilities() does not mix up audio and video extensions sharing the same id', () => { + // NOTE: Since all m= sections are bundled, the browser gives the same id to + // a given RTP header extension in every m= section that supports it (here + // sdes:mid has id 4 in both the audio and the video m= section). + const sdp = [ + 'v=0', + 'o=- 0 0 IN IP4 127.0.0.1', + 's=-', + 't=0 0', + 'a=group:BUNDLE 0 1', + 'm=audio 9 UDP/TLS/RTP/SAVPF 111', + 'c=IN IP4 0.0.0.0', + 'a=mid:0', + 'a=sendonly', + 'a=rtpmap:111 opus/48000/2', + 'a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level', + 'a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid', + 'm=video 9 UDP/TLS/RTP/SAVPF 96', + 'c=IN IP4 0.0.0.0', + 'a=mid:1', + 'a=sendonly', + 'a=rtpmap:96 VP8/90000', + 'a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid', + 'a=extmap:13 urn:3gpp:video-orientation', + '', + ].join('\r\n'); + + const rtpCapabilities = sdpCommonUtils.extractRtpCapabilities({ + sdpObject: sdpTransform.parse(sdp), + }); + + expect(rtpCapabilities.headerExtensions).toEqual( + expect.arrayContaining([ + { + kind: 'audio', + uri: 'urn:ietf:params:rtp-hdrext:ssrc-audio-level', + preferredId: 1, + }, + { + kind: 'audio', + uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', + preferredId: 4, + }, + { + kind: 'video', + uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', + preferredId: 4, + }, + { + kind: 'video', + uri: 'urn:3gpp:video-orientation', + preferredId: 13, + }, + ]) + ); + + expect(rtpCapabilities.headerExtensions!.length).toBe(4); +});