Skip to content

Commit 6e0d474

Browse files
danogclaude
andcommitted
Support every codec a group call can carry
The group call answer only ever offered OPUS and VP8, and numbered H.264 at 102 where tgcalls puts VP9, so the payload types of everybody else's video were misread. GroupSdp now builds the full table tgcalls' filterSupportedVideoFormats() and assignPayloadTypes() produce -- opus 111, L16 112, VP8 100/rtx 101, VP9 102/rtx 103, H.264 104/rtx 105 -- with the goog-remb, transport-cc, fir, nack and nack-pli feedback and the audio-level, abs-send-time, transport-cc and video-orientation extensions that go with them. H.265 is deliberately absent: tgcalls whitelists it only on the one-to-one v2 path, never for group calls. The SFU announces its own payload type table in the join response, and that is now parsed and honoured, but only for the payload type numbering and the codec names. Its clock rates and fmtp are not usable as given -- the live SFU announces VP8 as 9000Hz with one channel -- so everything else comes from our own capability list, which is also why tgcalls only reads enableH264 out of it. Which codec we send is no longer fixed either. WebmSource reports the codec of the track it picked, and the answer is rebuilt with that codec first when it turns out not to be VP8, so a VP9 or H.264 file plays without being transcoded. Picking the track also means saying so: a file whose video we cannot send is skipped with a warning instead of being packetized as if it were VP8. H.264 needs framing on top of that, since Matroska stores length-prefixed NALs and RTP wants Annex B. H264Framing converts them and prepends the SPS and PPS from avcC to every keyframe, so a receiver that joins mid-stream can decode. Finally, outgoing audio now reports a level. The extension is negotiated but was never written, so the SFU believed we were permanently silent and relayed our audio to nobody: that is why a group call played video but no sound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9ac8f7e commit 6e0d474

19 files changed

Lines changed: 2348 additions & 137 deletions

‎composer.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,24 +67,24 @@
6767
"evenement/evenement": "^3.0.2",
6868
"ramsey/uuid": "^4.9.2",
6969
"danog/php-rtc-av": "^1.0.1001",
70-
"danog/php-rtc-codecs": "^1.0.1001",
70+
"danog/php-rtc-codecs": "^1.0.1002",
7171
"danog/php-rtc-dtls": "^1.0.1002",
7272
"danog/php-rtc-ice": "^1.0.1003",
7373
"danog/php-rtc-ntp": "^1.0.1001",
7474
"danog/php-rtc-rtcp": "^1.0.1001",
75-
"danog/php-rtc-rtp": "^1.0.1001",
75+
"danog/php-rtc-rtp": "^1.0.1002",
7676
"danog/php-rtc-sctp": "^1.0.1003",
7777
"danog/php-rtc-srtp": "^1.0.1001",
7878
"danog/php-rtc-ssl": "^1.0.1001",
7979
"danog/php-rtc-stun": "^1.0.1001",
80-
"danog/php-rtc-webrtc": "^1.0.1003",
80+
"danog/php-rtc-webrtc": "^1.0.1004",
8181
"danog/php-rtc-datachannel": "^1.0.1001",
8282
"danog/php-rtc-sdp": "^1.0.1001",
8383
"danog/php-rtc-stats": "^1.0.1001",
8484
"danog/php-rtc-turn": "^1.0.1001",
8585
"danog/php-rtc-mdns": "^1.0.1001",
8686
"danog/php-rtc-opus": "^1.0.1001",
87-
"danog/php-rtc-vpx": "^1.0.1001",
87+
"danog/php-rtc-vpx": "^1.0.1002",
8888
"danog/php-rtc-exception": "^1.0.1001",
8989
"danog/php-rtc-mixin": "^1.0.1001",
9090
"danog/php-rtc-rtp-parameter": "^1.0.1001"

‎src/GroupCall/Handler.php‎

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -468,21 +468,12 @@ public function handleGroupCallUpdate(array $update): void
468468
);
469469
break;
470470
case 'updateGroupCallConnection':
471-
if ($update['presentation'] ?? false) {
472-
return;
473-
}
474-
// The connection parameters are not tied to a call ID in the update itself, they
475-
// always refer to the call we just joined.
476-
foreach ($this->groupCalls as $call) {
477-
if ($call->getCallState() === GroupCallState::JOINING) {
478-
try {
479-
$call->applyConnectionParams((string) $update['params']['data']);
480-
} catch (\Throwable $e) {
481-
$this->logger->logger("Could not apply the connection params of $call: $e", Logger::ERROR);
482-
}
483-
return;
484-
}
485-
}
471+
// Deliberately ignored: updateGroupCallConnection carries no call ID, so an update
472+
// reaching us through the update loop cannot be attributed to a call, and guessing
473+
// hands one call's transport parameters (and RTMP keys) to another one whenever two
474+
// joins overlap. The very same update is always part of the result of
475+
// phone.joinGroupCall, where it is unambiguous, and that is where it is applied,
476+
// see GroupCallController::applyJoinUpdates().
486477
break;
487478
}
488479
}

‎src/GroupCallController.php‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,15 @@ public function join(bool $muted = false, mixed $joinAs = null, ?string $inviteH
167167
$this->connection = new GroupConnection($this, $this->diskJockey);
168168
$params = $this->connection->buildJoinPayload();
169169
$this->source = $this->connection->getAudioSource();
170+
$this->log("Join payload of $this: ".json_encode($params), Logger::VERBOSE);
170171

171172
$request = [
172173
'call' => $this->inputCall,
173174
'join_as' => $joinAs ?? ['_' => 'inputPeerSelf'],
174175
'muted' => $muted,
175176
'video_stopped' => true,
176-
'params' => ['_' => 'dataJSON', 'data' => $params],
177+
// DataJSON arguments are encoded by the TL serializer, pass the decoded payload.
178+
'params' => $params,
177179
];
178180
if ($inviteHash !== null) {
179181
$request['invite_hash'] = $inviteHash;
@@ -214,22 +216,27 @@ private function applyJoinUpdates(array $updates): void
214216
{
215217
foreach ($updates['updates'] ?? [] as $update) {
216218
if ($update['_'] === 'updateGroupCallConnection' && !($update['presentation'] ?? false)) {
217-
$this->applyConnectionParams((string) $update['params']['data']);
219+
$this->applyConnectionParams($update['params']);
218220
}
219221
}
220222
}
221223

222224
/**
225+
* Apply the `params` of an
226+
* [updateGroupCallConnection](https://core.telegram.org/constructor/updateGroupCallConnection),
227+
* already decoded by the TL deserializer.
228+
*
223229
* @internal
224230
*/
225-
public function applyConnectionParams(string $params): void
231+
public function applyConnectionParams(array $params): void
226232
{
227233
if ($this->connectionParamsApplied) {
228234
// The same updateGroupCallConnection reaches us both in the result of
229235
// phone.joinGroupCall and through the update loop: only the first one matters.
230236
return;
231237
}
232238
$this->connectionParamsApplied = true;
239+
$this->log("Join response of $this: ".json_encode($params), Logger::VERBOSE);
233240
$parsed = GroupSdp::parseJoinResponse($params);
234241
$this->streamMode = $parsed['stream'];
235242
$this->rtmpMode = $parsed['rtmp'];
@@ -247,7 +254,7 @@ public function applyConnectionParams(string $params): void
247254
if ($parsed['transport'] === null) {
248255
throw new Exception('Missing transport parameters in the group call join response!');
249256
}
250-
$this->connection?->setTransport($parsed['transport']);
257+
$this->connection?->setTransport($parsed['transport'], $parsed['video']);
251258
}
252259

253260
/**
@@ -650,11 +657,40 @@ public function play(LocalFile|RemoteUrl|ReadableStream $file): void
650657
}
651658
public function playVideo(LocalFile|RemoteUrl|ReadableStream $file): void
652659
{
653-
$this->connection?->playVideo($file);
660+
if ($this->connection === null) {
661+
throw new Exception(
662+
"Cannot play video in $this: it has no WebRTC connection".
663+
($this->streamMode
664+
? ', it is in '.($this->rtmpMode ? 'RTMP' : 'stream').' mode, where media is published externally'
665+
: ', it was not joined').'!'
666+
);
667+
}
668+
$this->connection->playVideo($file);
669+
$this->setVideoStopped(false);
654670
}
655671
public function stopVideo(): void
656672
{
657673
$this->connection?->stopVideo();
674+
$this->setVideoStopped(true);
675+
}
676+
/**
677+
* Tell the server whether we are currently publishing video, so that the other participants
678+
* know they should display our video stream.
679+
*/
680+
private function setVideoStopped(bool $stopped): void
681+
{
682+
if ($this->callState !== GroupCallState::JOINED) {
683+
return;
684+
}
685+
try {
686+
$this->API->methodCallAsyncRead('phone.editGroupCallParticipant', [
687+
'call' => $this->inputCall,
688+
'participant' => ['_' => 'inputPeerSelf'],
689+
'video_stopped' => $stopped,
690+
]);
691+
} catch (Throwable $e) {
692+
$this->log("Could not change the video state of $this: $e", Logger::WARNING);
693+
}
658694
}
659695
public function skip(): void
660696
{

‎src/Matroska.php‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ final class Matroska
4343
private const ID_TRACK_NUMBER = 0xD7;
4444
private const ID_TRACK_TYPE = 0x83;
4545
private const ID_CODEC_ID = 0x86;
46+
private const ID_CODEC_PRIVATE = 0x63A2;
4647
private const ID_CLUSTER = 0x1F43B675;
4748
private const ID_TIMESTAMP = 0xE7;
4849
private const ID_SIMPLE_BLOCK = 0xA3;
@@ -75,7 +76,7 @@ final class Matroska
7576
/**
7677
* Track metadata, keyed by track number.
7778
*
78-
* @var array<int, array{codec: string, type: int}>
79+
* @var array<int, array{codec: string, type: int, private: ?string}>
7980
*/
8081
public array $tracks = [];
8182

@@ -176,8 +177,8 @@ private function readVint(bool $keepMarker): ?array
176177
private function read(): Generator
177178
{
178179
$clusterTimestamp = 0;
179-
/** @var array{number: ?int, codec: ?string, type: ?int} $pending */
180-
$pending = ['number' => null, 'codec' => null, 'type' => null];
180+
/** @var array{number: ?int, codec: ?string, type: ?int, private: ?string} $pending */
181+
$pending = ['number' => null, 'codec' => null, 'type' => null, 'private' => null];
181182

182183
while (true) {
183184
$id = $this->readVint(true);
@@ -196,7 +197,7 @@ private function read(): Generator
196197

197198
if (isset(self::MASTER_ELEMENTS[$elementId])) {
198199
if ($elementId === self::ID_TRACK_ENTRY) {
199-
$pending = ['number' => null, 'codec' => null, 'type' => null];
200+
$pending = ['number' => null, 'codec' => null, 'type' => null, 'private' => null];
200201
}
201202
continue;
202203
}
@@ -221,6 +222,12 @@ private function read(): Generator
221222
$pending['codec'] = rtrim($this->consume($elementSize), "\0");
222223
$this->flushTrack($pending);
223224
break;
225+
case self::ID_CODEC_PRIVATE:
226+
// Codec setup data, such as the AVCDecoderConfigurationRecord of an H.264
227+
// track: some codecs cannot be decoded at all without it.
228+
$pending['private'] = $this->consume($elementSize);
229+
$this->flushTrack($pending);
230+
break;
224231
case self::ID_TIMESTAMP:
225232
$clusterTimestamp = self::toInt($this->consume($elementSize));
226233
break;
@@ -243,16 +250,23 @@ private function read(): Generator
243250
}
244251

245252
/**
246-
* Register a track once all three of its interesting fields are known.
253+
* Register a track once all three of its mandatory fields are known.
254+
*
255+
* Called again for every field, so a `CodecPrivate` that arrives after the codec ID simply
256+
* updates the entry that is already there.
247257
*
248-
* @param array{number: ?int, codec: ?string, type: ?int} $pending
258+
* @param array{number: ?int, codec: ?string, type: ?int, private: ?string} $pending
249259
*/
250260
private function flushTrack(array $pending): void
251261
{
252262
if ($pending['number'] === null || $pending['codec'] === null || $pending['type'] === null) {
253263
return;
254264
}
255-
$this->tracks[$pending['number']] = ['codec' => $pending['codec'], 'type' => $pending['type']];
265+
$this->tracks[$pending['number']] = [
266+
'codec' => $pending['codec'],
267+
'type' => $pending['type'],
268+
'private' => $pending['private'],
269+
];
256270
}
257271

258272
/**
@@ -301,7 +315,7 @@ private function parseBlock(string $block, int $clusterTimestamp, bool $simple):
301315
$payload = substr($block, $offset);
302316
$frames = $lacing === 0 ? [$payload] : self::unlace($payload, $lacing);
303317

304-
$track = $this->tracks[$trackNumber] ?? ['codec' => '', 'type' => 0];
318+
$track = $this->tracks[$trackNumber] ?? ['codec' => '', 'type' => 0, 'private' => null];
305319
$timestamp = (int) (($clusterTimestamp + $relative) * $this->timestampScale / 1000000);
306320

307321
$result = [];

‎src/TL/TL.php‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,14 @@ public function serializeMethod(string $method, mixed $arguments)
670670
}
671671
return $tl['id'].$this->serializeParams($tl, $arguments, $method, -1);
672672
}
673+
/**
674+
* Whether a `DataJSON` argument was already passed as a dataJSON constructor, instead of as the
675+
* decoded value we would have to encode ourselves.
676+
*/
677+
private static function isDataJSON(mixed $value): bool
678+
{
679+
return \is_array($value) && ($value['_'] ?? null) === 'dataJSON';
680+
}
673681
/**
674682
* Serialize parameters.
675683
*
@@ -791,12 +799,14 @@ private function serializeParams(array $tl, array|Button $arguments, string|int
791799
} else {
792800
$value = $arguments[$name];
793801
}
794-
if (\in_array($type, ['DataJSON', '%DataJSON'], true)) {
802+
if (\in_array($type, ['DataJSON', '%DataJSON'], true) && !self::isDataJSON($value)) {
795803
$value = ['_' => 'dataJSON', 'data' => json_encode($value)];
796804
}
797805
if (isset($current_argument['subtype']) && \in_array($current_argument['subtype'], ['DataJSON', '%DataJSON'], true)) {
798806
array_walk($value, static function (&$arg): void {
799-
$arg = ['_' => 'dataJSON', 'data' => json_encode($arg)];
807+
if (!self::isDataJSON($arg)) {
808+
$arg = ['_' => 'dataJSON', 'data' => json_encode($arg)];
809+
}
800810
});
801811
}
802812
if ($type === 'InputFile' && (!\is_array($value) || !(isset($value['_']) && $this->constructors->findByPredicate($value['_'])['type'] === 'InputFile'))) {

‎src/Tgcalls/Controller.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ final class Controller
5858
private ?RTCDataChannel $dataChannel = null;
5959

6060
private MediaStreamTrack $outgoingAudio;
61-
private Vp8PlaybackTrack $outgoingVideo;
61+
private VideoPlaybackTrack $outgoingVideo;
6262
private WebmSource $webm;
6363
private ?OpusRecorder $recorder = null;
6464

@@ -114,7 +114,7 @@ function (int $cause): void {
114114
$this->webm = new WebmSource($call);
115115
$this->outgoingAudio = new OpusPlaybackTrack($dj, $call, $this->webm);
116116
$this->peerConnection->addTransceiver($this->outgoingAudio, SDPDirections::sendrecv);
117-
$this->outgoingVideo = new Vp8PlaybackTrack($this->webm, $call);
117+
$this->outgoingVideo = new VideoPlaybackTrack($this->webm, $call);
118118
$this->peerConnection->addTransceiver($this->outgoingVideo, SDPDirections::sendrecv);
119119

120120
$this->peerConnection->on('track', function (MediaStreamTrack $track): void {

0 commit comments

Comments
 (0)