Skip to content

Commit 12997a4

Browse files
authored
Merge pull request #1940 from danog/fix-video-retransmissions
Group calls: receive the retransmissions of the remote videos
2 parents ad2bc5a + c1a1fa7 commit 12997a4

9 files changed

Lines changed: 180 additions & 7 deletions

File tree

‎composer.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@
6767
"symfony/polyfill-php83": "^1.41",
6868
"danog/php-rtc-av": "^1.1.11",
6969
"danog/php-rtc-codecs": "^1.1.20",
70-
"danog/php-rtc-dtls": "^2.0.19",
70+
"danog/php-rtc-dtls": "^2.0.21",
7171
"danog/php-rtc-ice": "^2.0.18",
7272
"danog/php-rtc-ntp": "^1.1.14",
7373
"danog/php-rtc-rtcp": "^1.1.11",
74-
"danog/php-rtc-rtp": "^2.0.25",
74+
"danog/php-rtc-rtp": "^2.0.26",
7575
"danog/php-rtc-sctp": "^2.0.14",
7676
"danog/php-rtc-srtp": "^1.1.16",
7777
"danog/php-rtc-stun": "^2.0.18",

‎src/API.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ final class API extends AbstractAPI
5454
*
5555
* @var string
5656
*/
57-
public const RELEASE = '8.15.1';
57+
public const RELEASE = '8.15.2';
5858
/**
5959
* We're not logged in.
6060
*

‎src/EventHandler/Calls/AbstractGroupCallParticipant.php‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ public function __construct(
7171
public readonly ?int $raiseHandRating,
7272
/** Whether the participant's volume was set by an admin. */
7373
public readonly bool $volumeByAdmin,
74+
/**
75+
* The retransmission (RTX) SSRC of each of the {@see self::$videoSources} and {@see self::$presentationSources} that
76+
* has one (the second source of its FID group), signed, by SSRC: the SFU resends the lost packets of the stream on it.
77+
*
78+
* @var array<int, int>
79+
*/
80+
public readonly array $retransmissionSources = [],
7481
) {
7582
parent::__construct($peerId);
7683
}
@@ -96,14 +103,20 @@ final protected static function commonArgs(array $participant, int $peerId, ?sel
96103
$presentation = self::videoSources($participant['presentation'] ?? null);
97104
$videoEndpoint = self::endpoint($participant['video'] ?? null);
98105
$presentationEndpoint = self::endpoint($participant['presentation'] ?? null);
106+
$videoRtx = self::retransmissionSources($participant['video'] ?? null);
107+
$presentationRtx = self::retransmissionSources($participant['presentation'] ?? null);
99108
if ($min && $cached !== null) {
109+
// Participants saved by versions that didn't keep the retransmission sources have none.
110+
$cachedRtx = $cached->retransmissionSources ?? [];
100111
if (!\is_array($participant['video'] ?? null)) {
101112
$video = $cached->videoSources;
102113
$videoEndpoint = $cached->videoEndpoint;
114+
$videoRtx = array_intersect_key($cachedRtx, array_flip($video));
103115
}
104116
if (!\is_array($participant['presentation'] ?? null)) {
105117
$presentation = $cached->presentationSources;
106118
$presentationEndpoint = $cached->presentationEndpoint;
119+
$presentationRtx = array_intersect_key($cachedRtx, array_flip($presentation));
107120
}
108121
}
109122
return [
@@ -125,14 +138,15 @@ final protected static function commonArgs(array $participant, int $peerId, ?sel
125138
'about' => $participant['about'] ?? null,
126139
'raiseHandRating' => $participant['raise_hand_rating'] ?? null,
127140
'volumeByAdmin' => $participant['volume_by_admin'],
141+
'retransmissionSources' => $videoRtx + $presentationRtx,
128142
];
129143
}
130144

131145
/**
132146
* Flatten the source SSRCs of a
133147
* [groupCallParticipantVideo](https://core.telegram.org/constructor/groupCallParticipantVideo),
134148
* taking each source group's primary (first) source and every simulcast layer, deduplicated.
135-
* Retransmission (FID) pairs contribute only their main source, since we do not record rtx.
149+
* Retransmission (FID) pairs contribute only their main source: the other is in {@see self::retransmissionSources()}.
136150
*
137151
* @param mixed $video The `video` or `presentation` field of a groupCallParticipant, if present.
138152
*
@@ -157,6 +171,30 @@ private static function videoSources(mixed $video): array
157171
return array_values(array_keys($sources));
158172
}
159173

174+
/**
175+
* The retransmission SSRC of each SSRC of a groupCallParticipantVideo that has one: the second source of its FID
176+
* group (tgcalls declares one per simulcast layer).
177+
*
178+
* @param mixed $video The `video` or `presentation` field of a groupCallParticipant, if present.
179+
*
180+
* @return array<int, int> Signed SSRCs, by signed SSRC.
181+
*
182+
* @psalm-pure
183+
*/
184+
private static function retransmissionSources(mixed $video): array
185+
{
186+
if (!\is_array($video) || ($video['paused'] ?? false)) {
187+
return [];
188+
}
189+
$sources = [];
190+
foreach ($video['source_groups'] ?? [] as $group) {
191+
if (($group['semantics'] ?? '') === 'FID' && isset($group['sources'][0], $group['sources'][1])) {
192+
$sources[$group['sources'][0]] = $group['sources'][1];
193+
}
194+
}
195+
return $sources;
196+
}
197+
160198
/**
161199
* The `endpoint` of a groupCallParticipantVideo, the key used to subscribe to that stream over
162200
* the colibri data channel.

‎src/EventHandler/Calls/LiveStoryParticipant.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ final class LiveStoryParticipant extends AbstractGroupCallParticipant
2828
*
2929
* @param list<int> $videoSources
3030
* @param list<int> $presentationSources
31+
* @param array<int, int> $retransmissionSources
3132
* @param int<1, 20000> $volume Playback volume, from 1 to 20000 where 10000 is 100%.
3233
*
3334
* @psalm-mutation-free
@@ -53,6 +54,7 @@ public function __construct(
5354
bool $volumeByAdmin,
5455
/** Total Telegram Stars donated by this participant, if any. */
5556
public readonly ?int $paidStarsTotal,
57+
array $retransmissionSources = [],
5658
) {
5759
parent::__construct(
5860
$peerId,
@@ -73,6 +75,7 @@ public function __construct(
7375
$about,
7476
$raiseHandRating,
7577
$volumeByAdmin,
78+
$retransmissionSources,
7679
);
7780
}
7881

‎src/GroupCall/GroupCallController.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,8 @@ private function syncSources(): void
659659
'presentation' => $participant->presentationSources,
660660
'videoEndpoint' => $participant->videoEndpoint,
661661
'presentationEndpoint' => $participant->presentationEndpoint,
662+
// Participants saved by versions that didn't keep the retransmission sources have none.
663+
'rtx' => $participant->retransmissionSources ?? [],
662664
];
663665
}
664666
}

‎src/Tgcalls/GroupConnection.php‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ final class GroupConnection implements VideoCodecObserver, PeerConnectionTrackLi
114114
* @var list<int>
115115
*/
116116
private array $orderedSources = [];
117+
/**
118+
* The retransmission (RTX) SSRC of each remote video SSRC that has one, both unsigned: declared with it in the answer
119+
* (as its FID group), so that the packets the SFU resends on it, when we report them lost, are received.
120+
*
121+
* @var array<int, int>
122+
*/
123+
private array $retransmission = [];
117124
/**
118125
* Which participant (by unsigned audio SSRC) each remote camera-video SSRC belongs to, so an
119126
* incoming video track is muxed into that participant's recorder.
@@ -451,8 +458,8 @@ public function setTransport(array $transport, ?array $video = null): void
451458
* transceiver of the matching kind is created for every new SSRC; the SFU forwards that
452459
* participant's media on it, demultiplexed by SSRC.
453460
*
454-
* @param list<array{audio: int, muted?: bool, video: list<int>, presentation?: list<int>, videoEndpoint?: ?string, presentationEndpoint?: ?string}> $participants
455-
* Signed SSRCs and SFU endpoints per participant.
461+
* @param list<array{audio: int, muted?: bool, video: list<int>, presentation?: list<int>, videoEndpoint?: ?string, presentationEndpoint?: ?string, rtx?: array<int, int>}> $participants
462+
* Signed SSRCs and SFU endpoints per participant, and the retransmission SSRC of each video SSRC that has one.
456463
*/
457464
public function setRemoteSources(array $participants): void
458465
{
@@ -484,6 +491,7 @@ public function setRemoteSources(array $participants): void
484491
$videoSsrcs[] = $unsigned;
485492
$this->videoOwner[$unsigned] = $audio;
486493
$added = $this->wireReceiver($unsigned, $known) || $added;
494+
$added = $this->setRetransmission($unsigned, $participant['rtx'][$video] ?? null) || $added;
487495
}
488496
foreach ($participant['presentation'] ?? [] as $presentation) {
489497
$unsigned = GroupSdp::toUnsignedSsrc($presentation);
@@ -492,6 +500,7 @@ public function setRemoteSources(array $participants): void
492500
}
493501
$this->presentationOwner[$unsigned] = $audio;
494502
$added = $this->wireReceiver($unsigned, $known) || $added;
503+
$added = $this->setRetransmission($unsigned, $participant['rtx'][$presentation] ?? null) || $added;
495504
}
496505
// Collect the endpoints to subscribe to over the colibri data channel: the SFU forwards
497506
// a stream only for endpoints we ask for. We record at full quality.
@@ -591,6 +600,28 @@ public function onDataChannelMessage(string $data): void
591600
$this->call->log("Colibri message on {$this->call}: $data", Logger::VERBOSE);
592601
}
593602

603+
/**
604+
* Remember the retransmission SSRC (signed, if any) of a remote video SSRC.
605+
*
606+
* @return bool Whether it changed (so a renegotiation is needed).
607+
*/
608+
private function setRetransmission(int $ssrc, ?int $rtx): bool
609+
{
610+
$rtx = $rtx === null ? 0 : GroupSdp::toUnsignedSsrc($rtx);
611+
if ($rtx === 0) {
612+
if (!isset($this->retransmission[$ssrc])) {
613+
return false;
614+
}
615+
unset($this->retransmission[$ssrc]);
616+
return true;
617+
}
618+
if (($this->retransmission[$ssrc] ?? null) === $rtx) {
619+
return false;
620+
}
621+
$this->retransmission[$ssrc] = $rtx;
622+
return true;
623+
}
624+
594625
/**
595626
* Add a receive-only video transceiver for a remote video SSRC if we do not already have one.
596627
*
@@ -813,6 +844,7 @@ private function renegotiate(): void
813844
$this->sources,
814845
$this->video,
815846
$this->outgoingVideoCodec,
847+
$this->retransmission,
816848
);
817849
$this->peerConnection->setRemoteDescription(new RTCSessionDescription($answer, 'answer'));
818850
} while ($this->renegotiatePending && !$this->closed);

‎src/Tgcalls/GroupSdp.php‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,17 @@ private static function parseVideoInformation(array $video): array
323323
* The video codec table the SFU announced, if any.
324324
* @param string $outgoingVideoCodec Encoding name of the video we transmit, which our own
325325
* m-line is pinned to. See {@see \danog\MadelineProto\Loop\VoIP\DjLoop::VIDEO_CODECS}.
326+
* @param array<int, int> $retransmission The retransmission SSRC of each remote video SSRC that has one, declared
327+
* after it in its m-line with their FID group: the second SSRC of an
328+
* m-line is taken as the RTX stream of the first.
326329
*/
327330
public static function buildAnswer(
328331
string $offer,
329332
array $transport,
330333
array $sources,
331334
?array $video = null,
332-
string $outgoingVideoCodec = 'VP8'
335+
string $outgoingVideoCodec = 'VP8',
336+
array $retransmission = [],
333337
): string {
334338
$ufrag = (string) ($transport['ufrag'] ?? '');
335339
$pwd = (string) ($transport['pwd'] ?? '');
@@ -404,6 +408,12 @@ public static function buildAnswer(
404408
if ($ssrc !== null) {
405409
$result[] = 'a=ssrc:'.$ssrc.' cname:tgcalls'.$ssrc;
406410
$result[] = 'a=ssrc:'.$ssrc.' label:'.$kind.$ssrc;
411+
$rtx = $kind === 'video' ? ($retransmission[$ssrc] ?? null) : null;
412+
if ($rtx !== null) {
413+
$result[] = 'a=ssrc:'.$rtx.' cname:tgcalls'.$ssrc;
414+
$result[] = 'a=ssrc:'.$rtx.' label:'.$kind.$ssrc;
415+
$result[] = 'a=ssrc-group:FID '.$ssrc.' '.$rtx;
416+
}
407417
}
408418
}
409419

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* This file is part of MadelineProto.
5+
* MadelineProto is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
6+
* MadelineProto is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7+
* See the GNU Affero General Public License for more details.
8+
* You should have received a copy of the GNU General Public License along with MadelineProto.
9+
* If not, see <http://www.gnu.org/licenses/>.
10+
*
11+
* @author Daniil Gentili <daniil@daniil.it>
12+
* @copyright 2016-2025 Daniil Gentili <daniil@daniil.it>
13+
* @license https://opensource.org/licenses/AGPL-3.0 AGPLv3
14+
* @link https://docs.madelineproto.xyz MadelineProto documentation
15+
*/
16+
17+
namespace danog\MadelineProto\Test;
18+
19+
use danog\MadelineProto\EventHandler\Calls\GroupCallParticipant;
20+
use PHPUnit\Framework\TestCase;
21+
22+
/**
23+
* The video sources of a participant, from the source groups of its groupCallParticipantVideo.
24+
*/
25+
final class GroupCallParticipantTest extends TestCase
26+
{
27+
private static function raw(bool $min, ?array $video): array
28+
{
29+
return [
30+
'min' => $min,
31+
'source' => 1,
32+
'date' => 0,
33+
'muted' => false,
34+
'can_self_unmute' => true,
35+
'muted_by_you' => false,
36+
'self' => false,
37+
'just_joined' => false,
38+
'video_joined' => $video !== null,
39+
'volume_by_admin' => false,
40+
] + ($video === null ? [] : ['video' => $video]);
41+
}
42+
43+
public function testTheRetransmissionSourceOfEachVideoSourceIsKept(): void
44+
{
45+
$participant = GroupCallParticipant::fromRaw(self::raw(false, [
46+
'endpoint' => 'e',
47+
'source_groups' => [
48+
['semantics' => 'SIM', 'sources' => [10, 20]],
49+
['semantics' => 'FID', 'sources' => [10, 11]],
50+
['semantics' => 'FID', 'sources' => [20, -21]],
51+
],
52+
]), 1);
53+
$this->assertSame([10, 20], $participant->videoSources);
54+
$this->assertSame([10 => 11, 20 => -21], $participant->retransmissionSources);
55+
56+
// A min update without the video keeps the known one, with its retransmission sources.
57+
$min = GroupCallParticipant::fromRaw(self::raw(true, null), 1, $participant);
58+
$this->assertSame([10, 20], $min->videoSources);
59+
$this->assertSame([10 => 11, 20 => -21], $min->retransmissionSources);
60+
61+
// The video stopping takes them away.
62+
$stopped = GroupCallParticipant::fromRaw(self::raw(false, ['endpoint' => 'e', 'paused' => true, 'source_groups' => [['semantics' => 'FID', 'sources' => [10, 11]]]]), 1, $participant);
63+
$this->assertSame([], $stopped->videoSources);
64+
$this->assertSame([], $stopped->retransmissionSources);
65+
}
66+
}

‎tests/danog/MadelineProto/GroupSdpTest.php‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
use danog\MadelineProto\Tgcalls\GroupSdp;
2020
use PHPUnit\Framework\TestCase;
21+
use Webrtc\SDP\SessionDescription;
2122

2223
/**
2324
* Tests that the SDP we synthesize for the group call SFU covers everything a group call carries.
@@ -72,6 +73,27 @@ private static function mLines(string $answer): array
7273
));
7374
}
7475

76+
/**
77+
* The retransmission SSRC of a remote video comes after it in its m-line, with their FID group: php-rtc takes the
78+
* second SSRC of an m-line as the RTX stream of the first, so that the packets the SFU resends are received.
79+
*/
80+
public function testARemoteVideoDeclaresItsRetransmissionSsrc(): void
81+
{
82+
$offer = substr(self::offer(), 0, -2)."\r\n".implode("\r\n", [
83+
'm=video 9 UDP/TLS/RTP/SAVPF 100 101',
84+
'a=mid:3',
85+
'a=recvonly',
86+
])."\r\n";
87+
$offer = str_replace('a=group:BUNDLE 0 1 2', 'a=group:BUNDLE 0 1 2 3', $offer);
88+
$answer = GroupSdp::buildAnswer($offer, self::TRANSPORT, ['2' => 12345, '3' => 2000], retransmission: [2000 => 2001, 12345 => 9]);
89+
90+
$media = SessionDescription::decode($answer)->getMedia();
91+
$this->assertSame([2000, 2001], array_map(static fn ($ssrc) => $ssrc->ssrc, $media[3]->getSsrc()));
92+
$this->assertStringContainsString("a=ssrc-group:FID 2000 2001\r\n", $answer);
93+
// Only video has retransmission streams.
94+
$this->assertSame([12345], array_map(static fn ($ssrc) => $ssrc->ssrc, $media[2]->getSsrc()));
95+
}
96+
7597
public function testEveryGroupCallCodecIsOffered(): void
7698
{
7799
$answer = GroupSdp::buildAnswer(self::offer(), self::TRANSPORT, ['2' => 12345]);

0 commit comments

Comments
 (0)