livekit-client 2.20.0 → 2.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +41 -11
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +744 -178
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +41 -7
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/e2ee/E2eeManager.d.ts +1 -0
  15. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  16. package/dist/src/e2ee/types.d.ts +1 -0
  17. package/dist/src/e2ee/types.d.ts.map +1 -1
  18. package/dist/src/e2ee/utils.d.ts +1 -0
  19. package/dist/src/e2ee/utils.d.ts.map +1 -1
  20. package/dist/src/e2ee/worker/DataCryptor.d.ts +1 -0
  21. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  22. package/dist/src/e2ee/worker/FrameCryptor.d.ts +1 -0
  23. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  24. package/dist/src/e2ee/worker/naluUtils.d.ts +1 -0
  25. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  26. package/dist/src/e2ee/worker/sifPayload.d.ts +1 -0
  27. package/dist/src/e2ee/worker/sifPayload.d.ts.map +1 -1
  28. package/dist/src/frameMetadata/frameMetadata.d.ts +1 -0
  29. package/dist/src/frameMetadata/frameMetadata.d.ts.map +1 -1
  30. package/dist/src/frameMetadata/types.d.ts +1 -0
  31. package/dist/src/frameMetadata/types.d.ts.map +1 -1
  32. package/dist/src/logger.d.ts +3 -1
  33. package/dist/src/logger.d.ts.map +1 -1
  34. package/dist/src/room/PCTransport.d.ts +42 -1
  35. package/dist/src/room/PCTransport.d.ts.map +1 -1
  36. package/dist/src/room/PCTransportManager.d.ts +1 -0
  37. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  38. package/dist/src/room/RTCEngine.d.ts +46 -4
  39. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  40. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  41. package/dist/src/room/Room.d.ts +1 -0
  42. package/dist/src/room/Room.d.ts.map +1 -1
  43. package/dist/src/room/data-track/depacketizer.d.ts.map +1 -1
  44. package/dist/src/room/data-track/frame.d.ts +1 -0
  45. package/dist/src/room/data-track/frame.d.ts.map +1 -1
  46. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  47. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  48. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
  49. package/dist/src/room/data-track/outgoing/types.d.ts +1 -0
  50. package/dist/src/room/data-track/outgoing/types.d.ts.map +1 -1
  51. package/dist/src/room/data-track/packet/extensions.d.ts +1 -0
  52. package/dist/src/room/data-track/packet/extensions.d.ts.map +1 -1
  53. package/dist/src/room/data-track/packet/index.d.ts +1 -0
  54. package/dist/src/room/data-track/packet/index.d.ts.map +1 -1
  55. package/dist/src/room/data-track/packet/serializable.d.ts +1 -0
  56. package/dist/src/room/data-track/packet/serializable.d.ts.map +1 -1
  57. package/dist/src/room/participant/LocalParticipant.d.ts +1 -0
  58. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  59. package/dist/src/room/participant/Participant.d.ts +1 -0
  60. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  61. package/dist/src/room/participant/publishUtils.d.ts +7 -0
  62. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  63. package/dist/src/room/track/LocalAudioTrack.d.ts +1 -0
  64. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  65. package/dist/src/room/track/Track.d.ts +1 -0
  66. package/dist/src/room/track/Track.d.ts.map +1 -1
  67. package/dist/src/room/track/options.d.ts +11 -1
  68. package/dist/src/room/track/options.d.ts.map +1 -1
  69. package/dist/src/room/track/record.d.ts.map +1 -1
  70. package/dist/src/room/utils.d.ts +1 -0
  71. package/dist/src/room/utils.d.ts.map +1 -1
  72. package/dist/src/test/signalToken.d.ts +26 -0
  73. package/dist/src/test/signalToken.d.ts.map +1 -0
  74. package/dist/src/type-polyfills/non-shared-typed-arrays.d.ts +2 -0
  75. package/dist/src/type-polyfills/non-shared-typed-arrays.d.ts.map +1 -0
  76. package/dist/src/utils/dataPacketBuffer.d.ts +19 -0
  77. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  78. package/dist/ts4.2/e2ee/E2eeManager.d.ts +1 -0
  79. package/dist/ts4.2/e2ee/types.d.ts +1 -0
  80. package/dist/ts4.2/e2ee/utils.d.ts +1 -0
  81. package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +1 -0
  82. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +1 -0
  83. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +1 -0
  84. package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +1 -0
  85. package/dist/ts4.2/frameMetadata/frameMetadata.d.ts +1 -0
  86. package/dist/ts4.2/frameMetadata/types.d.ts +1 -0
  87. package/dist/ts4.2/logger.d.ts +3 -1
  88. package/dist/ts4.2/room/PCTransport.d.ts +42 -1
  89. package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
  90. package/dist/ts4.2/room/RTCEngine.d.ts +46 -4
  91. package/dist/ts4.2/room/Room.d.ts +1 -0
  92. package/dist/ts4.2/room/data-track/frame.d.ts +1 -0
  93. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  94. package/dist/ts4.2/room/data-track/outgoing/types.d.ts +1 -0
  95. package/dist/ts4.2/room/data-track/packet/extensions.d.ts +1 -0
  96. package/dist/ts4.2/room/data-track/packet/index.d.ts +1 -0
  97. package/dist/ts4.2/room/data-track/packet/serializable.d.ts +1 -0
  98. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -0
  99. package/dist/ts4.2/room/participant/Participant.d.ts +1 -0
  100. package/dist/ts4.2/room/participant/publishUtils.d.ts +7 -0
  101. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
  102. package/dist/ts4.2/room/track/Track.d.ts +1 -0
  103. package/dist/ts4.2/room/track/options.d.ts +11 -1
  104. package/dist/ts4.2/room/utils.d.ts +1 -0
  105. package/dist/ts4.2/test/signalToken.d.ts +26 -0
  106. package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -0
  107. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +19 -0
  108. package/package.json +8 -4
  109. package/src/api/SignalClient.e2e.test.ts +264 -0
  110. package/src/api/SignalClient.ts +35 -2
  111. package/src/e2ee/E2eeManager.ts +3 -1
  112. package/src/e2ee/types.ts +1 -0
  113. package/src/e2ee/utils.ts +1 -0
  114. package/src/e2ee/worker/DataCryptor.ts +1 -0
  115. package/src/e2ee/worker/FrameCryptor.test.ts +1 -0
  116. package/src/e2ee/worker/FrameCryptor.ts +1 -0
  117. package/src/e2ee/worker/ParticipantKeyHandler.test.ts +1 -0
  118. package/src/e2ee/worker/e2ee.worker.ts +1 -0
  119. package/src/e2ee/worker/naluUtils.ts +1 -0
  120. package/src/e2ee/worker/sifPayload.ts +1 -0
  121. package/src/frameMetadata/frameMetadata.test.ts +83 -0
  122. package/src/frameMetadata/frameMetadata.ts +8 -0
  123. package/src/frameMetadata/types.ts +1 -0
  124. package/src/logger.ts +2 -0
  125. package/src/room/PCTransport.test.ts +150 -0
  126. package/src/room/PCTransport.ts +179 -31
  127. package/src/room/PCTransportManager.ts +10 -6
  128. package/src/room/RTCEngine.test.ts +275 -2
  129. package/src/room/RTCEngine.ts +277 -71
  130. package/src/room/RegionUrlProvider.ts +4 -2
  131. package/src/room/Room.ts +8 -1
  132. package/src/room/data-track/depacketizer.ts +1 -0
  133. package/src/room/data-track/frame.ts +1 -0
  134. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
  135. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
  136. package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
  137. package/src/room/data-track/outgoing/types.ts +1 -0
  138. package/src/room/data-track/packet/extensions.ts +1 -0
  139. package/src/room/data-track/packet/index.ts +1 -0
  140. package/src/room/data-track/packet/serializable.ts +1 -0
  141. package/src/room/errors.ts +4 -4
  142. package/src/room/participant/LocalParticipant.ts +17 -6
  143. package/src/room/participant/Participant.ts +6 -0
  144. package/src/room/participant/publishUtils.ts +14 -11
  145. package/src/room/track/LocalAudioTrack.ts +16 -0
  146. package/src/room/track/Track.ts +11 -6
  147. package/src/room/track/options.ts +11 -1
  148. package/src/room/track/record.ts +1 -0
  149. package/src/room/utils.ts +1 -0
  150. package/src/test/signalServerSetup.ts +151 -0
  151. package/src/test/signalToken.ts +59 -0
  152. package/src/test/vitest-context.d.ts +10 -0
  153. package/src/type-polyfills/{non-shared-typed-arrays.d.ts → non-shared-typed-arrays.ts} +6 -1
  154. package/src/utils/dataPacketBuffer.test.ts +100 -0
  155. package/src/utils/dataPacketBuffer.ts +48 -1
@@ -15,15 +15,24 @@ interface TrackBitrateInfo {
15
15
  transceiver?: RTCRtpTransceiver;
16
16
  codec: string;
17
17
  maxbr: number;
18
+ isScreenShare?: boolean;
18
19
  }
19
20
 
20
- /* The svc codec (av1/vp9) would use a very low bitrate at the begining and
21
- increase slowly by the bandwidth estimator until it reach the target bitrate. The
22
- process commonly cost more than 10 seconds cause subscriber will get blur video at
23
- the first few seconds. So we use a 70% of target bitrate here as the start bitrate to
24
- eliminate this issue.
25
- */
26
- const startBitrateForSVC = 0.7;
21
+ /*
22
+ * Video codecs use a very low bitrate at the beginning and increase slowly by
23
+ * the bandwidth estimator until they reach the target bitrate. The process commonly
24
+ * costs more than 10 seconds causing subscribers to get blurry video at the first
25
+ * few seconds. We use x-google-start-bitrate to hint the BWE to start higher.
26
+ *
27
+ * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target.
28
+ * Why same for all codecs: Target bitrate already accounts for codec efficiency
29
+ * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
30
+ * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
31
+ */
32
+ const startBitrateMultiplier = 0.9;
33
+
34
+ /** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
35
+ const maxStartBitrateKbps = 1000;
27
36
 
28
37
  const debounceInterval = 20;
29
38
 
@@ -53,6 +62,8 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
53
62
 
54
63
  private log = log;
55
64
 
65
+ private iceLog = log;
66
+
56
67
  private loggerOptions: LoggerOptions;
57
68
 
58
69
  private ddExtID = 0;
@@ -95,8 +106,12 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
95
106
 
96
107
  constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
97
108
  super();
98
- this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCTransport);
99
109
  this.loggerOptions = loggerOptions;
110
+ this.log = getLogger(
111
+ loggerOptions.loggerName ?? LoggerNames.PCTransport,
112
+ () => this.logContext,
113
+ );
114
+ this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
100
115
  this.config = config;
101
116
  this._pc = this.createPC();
102
117
  this.offerLock = new Mutex();
@@ -107,24 +122,33 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
107
122
 
108
123
  pc.onicecandidate = (ev) => {
109
124
  if (!ev.candidate) return;
125
+ this.iceLog.debug('local ICE candidate gathered', { candidate: ev.candidate.candidate });
110
126
  this.onIceCandidate?.(ev.candidate);
111
127
  };
112
128
  pc.onicecandidateerror = (ev) => {
129
+ this.iceLog.debug('ICE candidate error', { event: ev });
113
130
  this.onIceCandidateError?.(ev);
114
131
  };
115
132
 
116
133
  pc.oniceconnectionstatechange = () => {
134
+ this.iceLog.debug(`ICE connection state: ${pc.iceConnectionState}`);
117
135
  this.onIceConnectionStateChange?.(pc.iceConnectionState);
118
136
  };
119
137
 
120
138
  pc.onsignalingstatechange = () => {
139
+ this.log.debug(`signaling state: ${pc.signalingState}`);
121
140
  this.onSignalingStatechange?.(pc.signalingState);
122
141
  };
123
142
 
124
143
  pc.onconnectionstatechange = () => {
144
+ this.log.debug(`connection state: ${pc.connectionState}`);
125
145
  this.onConnectionStateChange?.(pc.connectionState);
126
146
  };
127
147
  pc.ondatachannel = (ev) => {
148
+ this.log.debug('data channel opened by peer', {
149
+ label: ev.channel.label,
150
+ id: ev.channel.id,
151
+ });
128
152
  this.onDataChannel?.(ev);
129
153
  };
130
154
  pc.ontrack = (ev) => {
@@ -150,6 +174,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
150
174
  if (this.pc.remoteDescription && !this.restartingIce) {
151
175
  return this.pc.addIceCandidate(candidate);
152
176
  }
177
+ this.iceLog.debug('queuing remote ICE candidate until remote description applied', {
178
+ pendingCount: this.pendingCandidates.length + 1,
179
+ });
153
180
  this.pendingCandidates.push(candidate);
154
181
  }
155
182
 
@@ -161,7 +188,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
161
188
  offerId !== this.latestOfferId
162
189
  ) {
163
190
  this.log.warn('ignoring answer for old offer', {
164
- ...this.logContext,
165
191
  offerId,
166
192
  latestOfferId: this.latestOfferId,
167
193
  });
@@ -180,7 +206,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
180
206
  sdpParsed.media.forEach((media) => {
181
207
  ensureIPAddrMatchVersion(media);
182
208
  });
183
- this.log.debug('setting pending initial offer before processing answer', this.logContext);
209
+ this.log.debug('setting pending initial offer before processing answer');
184
210
  await this.setMungedSDP(initialOffer, write(sdpParsed));
185
211
  }
186
212
  const sdpParsed = parse(sd.sdp ?? '');
@@ -234,10 +260,26 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
234
260
  });
235
261
  }
236
262
  });
263
+ // The server's answer sets per-track codec params (e.g. opus `usedtx`) on
264
+ // the sections mapped to a published track, but leaves the pre-populated
265
+ // recvonly placeholder sections with defaults. Conform the placeholders so
266
+ // each shared payload type is consistent across the bundle, otherwise
267
+ // libwebrtc flags a "bundled payload type collision".
268
+ const placeholderMids = this.getPlaceholderMids();
269
+ if (placeholderMids.size > 0) {
270
+ conformBundledCodecFmtp(sdpParsed.media, (media) =>
271
+ placeholderMids.has(getMidString(media.mid!)),
272
+ );
273
+ }
237
274
  mungedSDP = write(sdpParsed);
238
275
  }
239
276
  await this.setMungedSDP(sd, mungedSDP, true);
240
277
 
278
+ if (this.pendingCandidates.length > 0) {
279
+ this.iceLog.debug('flushing queued ICE candidates', {
280
+ count: this.pendingCandidates.length,
281
+ });
282
+ }
241
283
  this.pendingCandidates.forEach((candidate) => {
242
284
  this.pc.addIceCandidate(candidate);
243
285
  });
@@ -287,10 +329,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
287
329
  const unlock = await this.offerLock.lock();
288
330
  try {
289
331
  if (this.pc.signalingState !== 'stable') {
290
- this.log.warn(
291
- 'signaling state is not stable, cannot create initial offer',
292
- this.logContext,
293
- );
332
+ this.log.warn('signaling state is not stable, cannot create initial offer');
294
333
  return;
295
334
  }
296
335
  const offerId = this.latestOfferId + 1;
@@ -317,7 +356,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
317
356
  }
318
357
 
319
358
  if (options?.iceRestart) {
320
- this.log.debug('restarting ICE', this.logContext);
359
+ this.iceLog.debug('restarting ICE');
321
360
  this.restartingIce = true;
322
361
  }
323
362
 
@@ -334,21 +373,21 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
334
373
  await this._pc.setRemoteDescription(currentSD);
335
374
  } else {
336
375
  this.renegotiate = true;
337
- this.log.debug('requesting renegotiation', { ...this.logContext });
376
+ this.log.debug('requesting renegotiation');
338
377
  return;
339
378
  }
340
379
  } else if (!this._pc || this._pc.signalingState === 'closed') {
341
- this.log.warn('could not createOffer with closed peer connection', this.logContext);
380
+ this.log.warn('could not createOffer with closed peer connection');
342
381
  return;
343
382
  }
344
383
 
345
384
  // actually negotiate
346
- this.log.debug('starting to negotiate', this.logContext);
385
+ this.log.debug('starting to negotiate');
347
386
  // increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior
348
387
  const offerId = this.latestOfferId + 1;
349
388
  this.latestOfferId = offerId;
350
389
  const offer = await this.pc.createOffer(options);
351
- this.log.debug('original offer', { sdp: offer.sdp, ...this.logContext });
390
+ this.log.debug('original offer', { sdp: offer.sdp });
352
391
 
353
392
  const sdpParsed = parse(offer.sdp ?? '');
354
393
  sdpParsed.media.forEach((media) => {
@@ -379,14 +418,18 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
379
418
  }
380
419
 
381
420
  // mung sdp for bitrate setting that can't apply by sendEncoding
382
- if (!isSVCCodec(trackbr.codec)) {
383
- return true;
384
- }
385
-
386
- const startBitrate = Math.round(trackbr.maxbr * startBitrateForSVC);
421
+ // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE from starting too aggressively
422
+ // Screen share is not capped since text/UI clarity requires high bitrate from the start
423
+ // TODO: dynamically adjust start bitrate based on network conditions (e.g., use previous BWE estimate)
424
+ const calculatedStartBitrate = Math.round(trackbr.maxbr * startBitrateMultiplier);
425
+ const startBitrate = trackbr.isScreenShare
426
+ ? calculatedStartBitrate
427
+ : Math.min(calculatedStartBitrate, maxStartBitrateKbps);
387
428
 
429
+ let fmtpFound = false;
388
430
  for (const fmtp of media.fmtp) {
389
431
  if (fmtp.payload === codecPayload) {
432
+ fmtpFound = true;
390
433
  // if another track's fmtp already is set, we cannot override the bitrate
391
434
  // this has the unfortunate consequence of being forced to use the
392
435
  // initial track's bitrate for all tracks
@@ -396,13 +439,30 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
396
439
  break;
397
440
  }
398
441
  }
442
+ // VP8 and some codecs may not have an existing fmtp line - create one
443
+ if (!fmtpFound) {
444
+ media.fmtp.push({
445
+ payload: codecPayload,
446
+ config: `x-google-start-bitrate=${startBitrate}`,
447
+ });
448
+ }
399
449
  return true;
400
450
  });
401
451
  }
402
452
  });
453
+ // Conform the placeholder sections (pre-populated, or reverted after an
454
+ // unpublish) so every shared payload type carries identical fmtp across the
455
+ // bundle, otherwise libwebrtc flags a "bundled payload type collision".
456
+ // Detection is by transceiver (mids are stable across renegotiations) since
457
+ // an unpublished section keeps its `a=msid`.
458
+ const placeholderMids = this.getPlaceholderMids();
459
+ if (placeholderMids.size > 0) {
460
+ conformBundledCodecFmtp(sdpParsed.media, (media) =>
461
+ placeholderMids.has(getMidString(media.mid!)),
462
+ );
463
+ }
403
464
  if (this.latestOfferId > offerId) {
404
465
  this.log.warn('latestOfferId mismatch', {
405
- ...this.logContext,
406
466
  latestOfferId: this.latestOfferId,
407
467
  offerId,
408
468
  });
@@ -428,6 +488,17 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
428
488
  return answer;
429
489
  }
430
490
 
491
+ /**
492
+ * Returns the mids of transceivers that carry no outgoing track on this
493
+ * (publisher) connection: the pre-populated placeholders added by
494
+ * `RTCEngine.applyInitialPublisherLayout`, plus any transceiver that was used
495
+ * for a track and reverted on unpublish. Their codec fmtp is conformed to the
496
+ * published tracks so a shared payload type stays consistent across the bundle.
497
+ */
498
+ private getPlaceholderMids(): Set<string> {
499
+ return placeholderMidsFromTransceivers(this._pc?.getTransceivers() ?? []);
500
+ }
501
+
431
502
  createDataChannel(label: string, dataChannelDict: RTCDataChannelInit) {
432
503
  return this.pc.createDataChannel(label, dataChannelDict);
433
504
  }
@@ -543,6 +614,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
543
614
  if (!this._pc) {
544
615
  return;
545
616
  }
617
+ this.log.debug('closing peer connection');
546
618
  this.pendingInitialOffer = undefined;
547
619
  this._pc.close();
548
620
  this._pc.onconnectionstatechange = null;
@@ -564,10 +636,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
564
636
  if (munged) {
565
637
  sd.sdp = munged;
566
638
  try {
567
- this.log.debug(
568
- `setting munged ${remote ? 'remote' : 'local'} description`,
569
- this.logContext,
570
- );
639
+ this.log.debug(`setting munged ${remote ? 'remote' : 'local'} description`);
571
640
  if (remote) {
572
641
  await this.pc.setRemoteDescription(sd);
573
642
  } else {
@@ -576,7 +645,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
576
645
  return;
577
646
  } catch (e) {
578
647
  this.log.warn(`not able to set ${sd.type}, falling back to unmodified sdp`, {
579
- ...this.logContext,
580
648
  error: e,
581
649
  mungedSdp: munged,
582
650
  originalSdp,
@@ -609,7 +677,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
609
677
  if (!remote && this.pc.remoteDescription) {
610
678
  fields.remoteSdp = this.pc.remoteDescription;
611
679
  }
612
- this.log.error(`unable to set ${sd.type}`, { ...this.logContext, fields });
680
+ this.log.error(`unable to set ${sd.type}`, { fields });
613
681
  throw new NegotiationError(msg);
614
682
  }
615
683
  }
@@ -703,6 +771,86 @@ function ensureAudioNackAndStereo(
703
771
  }
704
772
  }
705
773
 
774
+ /**
775
+ * Returns the mids of transceivers that carry no outgoing track: the
776
+ * pre-populated placeholders added by `RTCEngine.applyInitialPublisherLayout`,
777
+ * plus any transceiver that was used for a track and reverted on unpublish. The
778
+ * `sender.track` check is the reliable signal — an unpublished section keeps its
779
+ * `a=msid` (and its stale send-derived fmtp), so it can't be told apart from a
780
+ * real send by SDP alone. Transceiver mids are stable across renegotiations, so
781
+ * this works for every offer/answer after the first.
782
+ * @internal
783
+ */
784
+ export function placeholderMidsFromTransceivers(
785
+ transceivers: readonly RTCRtpTransceiver[],
786
+ ): Set<string> {
787
+ const mids = new Set<string>();
788
+ for (const transceiver of transceivers) {
789
+ if (transceiver.mid && !transceiver.sender.track) {
790
+ mids.add(transceiver.mid);
791
+ }
792
+ }
793
+ return mids;
794
+ }
795
+
796
+ /**
797
+ * Within a BUNDLE group a payload type must map to identical codec parameters
798
+ * across every m-line. When the same payload type carries different fmtp between
799
+ * sections — e.g. opus `usedtx=1` on the published microphone but not on the
800
+ * pre-populated recvonly placeholders, or H.265 with different `level-id` between
801
+ * a published video track and a placeholder — libwebrtc flags a "bundled payload
802
+ * type collision".
803
+ *
804
+ * Rewrite the placeholder sections so every shared payload type carries the
805
+ * same fmtp. Real (non-placeholder) sections always win the canonical value, so
806
+ * a published track's encoder parameters are never altered. When no real
807
+ * section declares a payload type — e.g. a placeholder that was reused for a
808
+ * track and then reverted to recvonly keeps its send-derived `level-id` while
809
+ * fresh placeholders use the default — the placeholders still converge on the
810
+ * first value seen, so two placeholders can't disagree either. Only placeholder
811
+ * sections are ever rewritten, and it is codec-agnostic (opus, H.265, ...).
812
+ * `isPlaceholder` identifies the sections to conform.
813
+ * @internal
814
+ */
815
+ export function conformBundledCodecFmtp(
816
+ media: MediaDescription[],
817
+ isPlaceholder: (media: MediaDescription) => boolean,
818
+ ) {
819
+ // Canonical fmtp per payload type. Payload types are unique within a BUNDLE
820
+ // group, so keying by payload alone (across audio and video) is safe. A real
821
+ // section's value always takes precedence; otherwise the first placeholder
822
+ // value seen is used so divergent placeholders still converge.
823
+ const canonicalByPayload = new Map<number, string>();
824
+ const fromRealSection = new Set<number>();
825
+ for (const m of media) {
826
+ const placeholder = isPlaceholder(m);
827
+ for (const fmtp of m.fmtp ?? []) {
828
+ if (!placeholder) {
829
+ canonicalByPayload.set(fmtp.payload, fmtp.config);
830
+ fromRealSection.add(fmtp.payload);
831
+ } else if (!canonicalByPayload.has(fmtp.payload)) {
832
+ canonicalByPayload.set(fmtp.payload, fmtp.config);
833
+ }
834
+ }
835
+ }
836
+ if (canonicalByPayload.size === 0) {
837
+ return;
838
+ }
839
+
840
+ // Conform placeholder sections to the canonical fmtp for each shared payload.
841
+ for (const m of media) {
842
+ if (!isPlaceholder(m)) {
843
+ continue;
844
+ }
845
+ for (const fmtp of m.fmtp ?? []) {
846
+ const config = canonicalByPayload.get(fmtp.payload);
847
+ if (config !== undefined && fmtp.config !== config) {
848
+ fmtp.config = config;
849
+ }
850
+ }
851
+ }
852
+ }
853
+
706
854
  function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
707
855
  stereoMids: string[];
708
856
  nackMids: string[];
@@ -65,6 +65,8 @@ export class PCTransportManager {
65
65
 
66
66
  private log = log;
67
67
 
68
+ private iceLog = log;
69
+
68
70
  private loggerOptions: LoggerOptions;
69
71
 
70
72
  private _mode: PCMode;
@@ -74,8 +76,9 @@ export class PCTransportManager {
74
76
  }
75
77
 
76
78
  constructor(mode: PCMode, loggerOptions: LoggerOptions, rtcConfig?: RTCConfiguration) {
77
- this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager);
78
79
  this.loggerOptions = loggerOptions;
80
+ this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager, () => this.logContext);
81
+ this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
79
82
 
80
83
  this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
81
84
  this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
@@ -151,7 +154,7 @@ export class PCTransportManager {
151
154
  publisher.removeTrack(sender);
152
155
  }
153
156
  } catch (e) {
154
- this.log.warn('could not removeTrack', { ...this.logContext, error: e });
157
+ this.log.warn('could not removeTrack', { error: e });
155
158
  }
156
159
  }
157
160
  }
@@ -160,6 +163,7 @@ export class PCTransportManager {
160
163
  }
161
164
 
162
165
  async triggerIceRestart() {
166
+ this.iceLog.warn('triggering ICE restart');
163
167
  if (this.subscriber) {
164
168
  this.subscriber.restartingIce = true;
165
169
  }
@@ -170,6 +174,7 @@ export class PCTransportManager {
170
174
  }
171
175
 
172
176
  async addIceCandidate(candidate: RTCIceCandidateInit, target: SignalTarget) {
177
+ this.iceLog.debug('adding remote ICE candidate', { target, candidate });
173
178
  if (target === SignalTarget.PUBLISHER) {
174
179
  await this.publisher.addIceCandidate(candidate);
175
180
  } else {
@@ -179,7 +184,6 @@ export class PCTransportManager {
179
184
 
180
185
  async createSubscriberAnswerFromOffer(sd: RTCSessionDescriptionInit, offerId: number) {
181
186
  this.log.debug('received server offer', {
182
- ...this.logContext,
183
187
  RTCSdpType: sd.type,
184
188
  sdp: sd.sdp,
185
189
  signalingState: this.subscriber?.getSignallingState().toString(),
@@ -200,6 +204,7 @@ export class PCTransportManager {
200
204
  }
201
205
 
202
206
  updateConfiguration(config: RTCConfiguration, iceRestart?: boolean) {
207
+ this.log.debug('updating rtc configuration', { iceRestart });
203
208
  this.publisher.setConfiguration(config);
204
209
  this.subscriber?.setConfiguration(config);
205
210
  if (iceRestart) {
@@ -215,7 +220,7 @@ export class PCTransportManager {
215
220
  this.publisher.getConnectionState() !== 'connected' &&
216
221
  this.publisher.getConnectionState() !== 'connecting'
217
222
  ) {
218
- this.log.debug('negotiation required, start negotiating', this.logContext);
223
+ this.log.debug('negotiation required, start negotiating');
219
224
  this.publisher.negotiate();
220
225
  }
221
226
  await Promise.all(
@@ -368,7 +373,6 @@ export class PCTransportManager {
368
373
  `pc state change: from ${PCTransportState[previousState]} to ${
369
374
  PCTransportState[this.state]
370
375
  }`,
371
- this.logContext,
372
376
  );
373
377
  this.onStateChange?.(
374
378
  this.state,
@@ -390,7 +394,7 @@ export class PCTransportManager {
390
394
 
391
395
  return new Promise<void>(async (resolve, reject) => {
392
396
  const abortHandler = () => {
393
- this.log.warn('abort transport connection', this.logContext);
397
+ this.log.warn('abort transport connection');
394
398
  CriticalTimers.clearTimeout(connectTimeout);
395
399
 
396
400
  reject(ConnectionError.cancelled('room connection has been cancelled'));