3636 */
3737final class V2Sdp
3838{
39+ /** The `sdes:mid` header extension URI tgcalls uses to route bundled channels. */
40+ private const MID_EXTENSION_URI = 'urn:ietf:params:rtp-hdrext:sdes:mid ' ;
41+
42+ /**
43+ * Rewrite each media section's `a=mid` to the SSRC it carries.
44+ *
45+ * tgcalls' [InstanceV2Impl](https://github.com/TelegramMessenger/tgcalls) identifies every
46+ * channel by `contentIdBySsrc()`, i.e. the decimal SSRC string, and uses that as the m-line
47+ * `mid` both in the SDP it builds internally and — crucially — as the value it expects in the
48+ * `sdes:mid` RTP header extension of incoming packets. It leaves the primary incoming video
49+ * SSRC unsignaled and demultiplexes it purely by that MID, so a sender that stamps the plain
50+ * m-line index (`0`, `1`, …) instead has all of its video dropped, while audio survives only
51+ * because its SSRC happens to be bound. Aligning our mids with the SSRCs makes the MID our
52+ * senders write match what the peer routes by, so the video is finally accepted.
53+ *
54+ * Sections without an SSRC (a rejected m-line, or the data channel) keep their original mid.
55+ */
56+ public static function useSsrcAsMid (string $ sdp ): string
57+ {
58+ // First pass: map each section's current mid to the SSRC it advertises.
59+ $ ssrcByMid = [];
60+ $ currentMid = null ;
61+ foreach (self ::lines ($ sdp ) as $ line ) {
62+ if (str_starts_with ($ line , 'm= ' )) {
63+ $ currentMid = null ;
64+ } elseif (str_starts_with ($ line , 'a=mid: ' )) {
65+ $ currentMid = substr ($ line , 6 );
66+ } elseif ($ currentMid !== null
67+ && !isset ($ ssrcByMid [$ currentMid ])
68+ && str_starts_with ($ line , 'a=ssrc: ' )
69+ ) {
70+ $ ssrc = explode (' ' , substr ($ line , 7 ))[0 ];
71+ if ($ ssrc !== '' ) {
72+ $ ssrcByMid [$ currentMid ] = $ ssrc ;
73+ }
74+ }
75+ }
76+ if ($ ssrcByMid === []) {
77+ return $ sdp ;
78+ }
79+
80+ // Second pass: rewrite the a=mid lines and the BUNDLE group that lists them.
81+ $ out = [];
82+ foreach (self ::lines ($ sdp ) as $ line ) {
83+ if (str_starts_with ($ line , 'a=mid: ' )) {
84+ $ mid = substr ($ line , 6 );
85+ $ out [] = 'a=mid: ' .($ ssrcByMid [$ mid ] ?? $ mid );
86+ } elseif (str_starts_with ($ line , 'a=group:BUNDLE ' )) {
87+ $ mids = array_map (
88+ static fn (string $ mid ): string => $ ssrcByMid [$ mid ] ?? $ mid ,
89+ array_filter (explode (' ' , substr ($ line , strlen ('a=group:BUNDLE ' ))))
90+ );
91+ $ out [] = 'a=group:BUNDLE ' .implode (' ' , $ mids );
92+ } else {
93+ $ out [] = $ line ;
94+ }
95+ }
96+
97+ return implode ("\r\n" , $ out )."\r\n" ;
98+ }
99+
39100 /**
40101 * Describe our local media in the form `NegotiateChannels` expects.
41102 *
@@ -62,6 +123,7 @@ public static function contentsFromOffer(string $offer, bool $outgoingOnly = fal
62123 'ssrc ' => '0 ' ,
63124 'payloadTypes ' => [],
64125 'rtpExtensions ' => [],
126+ 'ssrcGroups ' => [],
65127 '_port ' => (int ) ($ parts [1 ] ?? 0 ),
66128 '_direction ' => 'sendrecv ' ,
67129 ];
@@ -80,6 +142,26 @@ public static function contentsFromOffer(string $offer, bool $outgoingOnly = fal
80142 $ current ['ssrc ' ] = (string ) GroupSdp::toUnsignedSsrc ($ ssrc );
81143 continue ;
82144 }
145+ if (str_starts_with ($ line , 'a=ssrc-group: ' )) {
146+ // tgcalls' InstanceV2Impl binds the incoming video receive stream from the SSRCs it
147+ // finds in the content's ssrcGroups (see IncomingV2VideoChannel); a primary SSRC that
148+ // appears in no group is left unsignaled and demultiplexed purely by MID, a path that
149+ // silently drops the stream here. Echoing the FID group (primary + RTX) makes the peer
150+ // latch our SSRC and route the video by it, exactly like a real tgcalls sender does.
151+ $ groupParts = array_values (array_filter (explode (' ' , substr ($ line , strlen ('a=ssrc-group: ' )))));
152+ $ semantics = array_shift ($ groupParts );
153+ $ ssrcs = [];
154+ foreach ($ groupParts as $ groupSsrc ) {
155+ $ ssrcs [] = (string ) GroupSdp::toUnsignedSsrc ((int ) $ groupSsrc );
156+ }
157+ if ($ semantics !== null && $ semantics !== '' && $ ssrcs !== []) {
158+ /** @var list<array{semantics: string, ssrcs: list<string>}> $groups */
159+ $ groups = $ current ['ssrcGroups ' ];
160+ $ groups [] = ['semantics ' => $ semantics , 'ssrcs ' => $ ssrcs ];
161+ $ current ['ssrcGroups ' ] = $ groups ;
162+ }
163+ continue ;
164+ }
83165 if (str_starts_with ($ line , 'a=rtpmap: ' )) {
84166 /** @var list<array<string, mixed>> $payloadTypes */
85167 $ payloadTypes = $ current ['payloadTypes ' ];
@@ -116,6 +198,10 @@ public static function contentsFromOffer(string $offer, bool $outgoingOnly = fal
116198 && \in_array ($ content ['_direction ' ], ['sendrecv ' , 'sendonly ' ], true )
117199 );
118200 unset($ content ['_port ' ], $ content ['_direction ' ]);
201+ // Only carry ssrcGroups when there actually are any, matching tgcalls' own messages.
202+ if (($ content ['ssrcGroups ' ] ?? []) === []) {
203+ unset($ content ['ssrcGroups ' ]);
204+ }
119205 if ($ include ) {
120206 $ result [] = $ content ;
121207 }
@@ -257,7 +343,19 @@ public static function buildRemoteDescription(
257343 $ mappedContents [$ mediaIndex ] = null ;
258344 }
259345 $ appendRtp = static function (array $ content ) use (&$ result ): void {
260- foreach ($ content ['rtpExtensions ' ] ?? [] as $ extension ) {
346+ // tgcalls demultiplexes the unsignaled incoming video purely by the sdes:mid RTP
347+ // extension, yet its answers never echo that extension back. Re-advertising it here (at
348+ // tgcalls' fixed id 1) keeps it in the mutual set so our senders actually stamp the mid;
349+ // without it the offer/answer intersection drops it and all video is silently discarded.
350+ $ extensions = $ content ['rtpExtensions ' ] ?? [];
351+ $ hasMid = array_any (
352+ $ extensions ,
353+ static fn (array $ extension ): bool => ($ extension ['uri ' ] ?? '' ) === self ::MID_EXTENSION_URI
354+ );
355+ if (!$ hasMid ) {
356+ $ result [] = 'a=extmap:1 ' .self ::MID_EXTENSION_URI ;
357+ }
358+ foreach ($ extensions as $ extension ) {
261359 $ result [] = 'a=extmap: ' .$ extension ['id ' ].' ' .$ extension ['uri ' ];
262360 }
263361 foreach ($ content ['payloadTypes ' ] ?? [] as $ payloadType ) {
0 commit comments