Skip to content

Commit de2c2b4

Browse files
committed
Fix
1 parent 672feea commit de2c2b4

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

‎src/Tgcalls/Controller.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,10 @@ private function sendV2Offer(): void
421421
try {
422422
$this->sendV2InitialSetup();
423423
$offer = $this->peerConnection->createOffer();
424+
// tgcalls routes each channel by an SSRC-derived mid, and demultiplexes the unsignaled
425+
// incoming video purely by the sdes:mid RTP extension, so our senders must stamp the
426+
// SSRC as the mid rather than the plain m-line index.
427+
$offer = new RTCSessionDescription(V2Sdp::useSsrcAsMid($offer->getSdp()), $offer->getType());
424428
$this->peerConnection->setLocalDescription($offer);
425429
$this->pendingV2ExchangeId = (string) random_int(1, 0x7FFFFFFF);
426430
$this->renegotiatePending = false;

‎src/Tgcalls/V2Sdp.php‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,67 @@
3636
*/
3737
final 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

Comments
 (0)