livekit-client 2.22.3 → 2.22.4

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 (214) hide show
  1. package/README.md +35 -0
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +2 -1
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +602 -247
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.fm.worker.js +1 -1
  9. package/dist/livekit-client.fm.worker.js.map +1 -1
  10. package/dist/livekit-client.fm.worker.mjs +2 -1
  11. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  12. package/dist/livekit-client.umd.js +1 -1
  13. package/dist/livekit-client.umd.js.map +1 -1
  14. package/dist/src/api/SignalClient.d.ts +4 -2
  15. package/dist/src/api/SignalClient.d.ts.map +1 -1
  16. package/dist/src/options.d.ts +14 -0
  17. package/dist/src/options.d.ts.map +1 -1
  18. package/dist/src/room/PCTransport.d.ts +78 -7
  19. package/dist/src/room/PCTransport.d.ts.map +1 -1
  20. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  21. package/dist/src/room/RTCEngine.d.ts +8 -0
  22. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  23. package/dist/src/room/Room.d.ts +1 -1
  24. package/dist/src/room/Room.d.ts.map +1 -1
  25. package/dist/src/room/defaults.d.ts.map +1 -1
  26. package/dist/src/room/errors.d.ts +18 -0
  27. package/dist/src/room/errors.d.ts.map +1 -1
  28. package/dist/src/room/events.d.ts +3 -2
  29. package/dist/src/room/events.d.ts.map +1 -1
  30. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  31. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  32. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  33. package/dist/src/room/track/LocalVideoTrack.d.ts +2 -0
  34. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  35. package/dist/src/room/track/utils.d.ts +13 -0
  36. package/dist/src/room/track/utils.d.ts.map +1 -1
  37. package/dist/src/room/utils.d.ts +32 -0
  38. package/dist/src/room/utils.d.ts.map +1 -1
  39. package/dist/src/test/compressionStreamStubs.d.ts +12 -0
  40. package/dist/src/test/compressionStreamStubs.d.ts.map +1 -0
  41. package/dist/ts4.2/api/SignalClient.d.ts +187 -185
  42. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -85
  43. package/dist/ts4.2/api/WebSocketStream.d.ts +33 -33
  44. package/dist/ts4.2/api/utils.d.ts +9 -9
  45. package/dist/ts4.2/connectionHelper/ConnectionCheck.d.ts +30 -30
  46. package/dist/ts4.2/connectionHelper/checks/Checker.d.ts +62 -62
  47. package/dist/ts4.2/connectionHelper/checks/cloudRegion.d.ts +18 -18
  48. package/dist/ts4.2/connectionHelper/checks/connectionProtocol.d.ts +20 -20
  49. package/dist/ts4.2/connectionHelper/checks/publishAudio.d.ts +6 -6
  50. package/dist/ts4.2/connectionHelper/checks/publishVideo.d.ts +7 -7
  51. package/dist/ts4.2/connectionHelper/checks/reconnect.d.ts +6 -6
  52. package/dist/ts4.2/connectionHelper/checks/turn.d.ts +6 -6
  53. package/dist/ts4.2/connectionHelper/checks/webrtc.d.ts +6 -6
  54. package/dist/ts4.2/connectionHelper/checks/websocket.d.ts +6 -6
  55. package/dist/ts4.2/e2ee/E2eeManager.d.ts +111 -111
  56. package/dist/ts4.2/e2ee/KeyProvider.d.ts +54 -54
  57. package/dist/ts4.2/e2ee/constants.d.ts +21 -21
  58. package/dist/ts4.2/e2ee/errors.d.ts +12 -12
  59. package/dist/ts4.2/e2ee/events.d.ts +39 -39
  60. package/dist/ts4.2/e2ee/index.d.ts +6 -6
  61. package/dist/ts4.2/e2ee/types.d.ts +231 -231
  62. package/dist/ts4.2/e2ee/utils.d.ts +32 -32
  63. package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +16 -16
  64. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -21
  65. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +209 -209
  66. package/dist/ts4.2/e2ee/worker/ParticipantKeyHandler.d.ts +81 -84
  67. package/dist/ts4.2/e2ee/worker/e2ee.worker.d.ts +2 -2
  68. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +28 -28
  69. package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +23 -23
  70. package/dist/ts4.2/frameMetadata/FrameMetadataManager.d.ts +53 -53
  71. package/dist/ts4.2/frameMetadata/frameMetadata.d.ts +33 -33
  72. package/dist/ts4.2/frameMetadata/types.d.ts +62 -62
  73. package/dist/ts4.2/frameMetadata/utils.d.ts +9 -9
  74. package/dist/ts4.2/frameMetadata/worker/frameMetadata.worker.d.ts +2 -2
  75. package/dist/ts4.2/index.d.ts +76 -76
  76. package/dist/ts4.2/logger.d.ts +66 -66
  77. package/dist/ts4.2/options.d.ts +163 -149
  78. package/dist/ts4.2/room/BackOffStrategy.d.ts +27 -27
  79. package/dist/ts4.2/room/DefaultReconnectPolicy.d.ts +8 -8
  80. package/dist/ts4.2/room/DeviceManager.d.ts +12 -12
  81. package/dist/ts4.2/room/PCTransport.d.ts +246 -175
  82. package/dist/ts4.2/room/PCTransportManager.d.ts +77 -77
  83. package/dist/ts4.2/room/RTCEngine.d.ts +269 -262
  84. package/dist/ts4.2/room/ReconnectPolicy.d.ts +24 -24
  85. package/dist/ts4.2/room/RegionUrlProvider.d.ts +36 -36
  86. package/dist/ts4.2/room/Room.d.ts +354 -365
  87. package/dist/ts4.2/room/attribute-typings.d.ts +35 -35
  88. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -62
  89. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +94 -95
  90. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -41
  91. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -57
  92. package/dist/ts4.2/room/data-channel/types.d.ts +12 -12
  93. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -49
  94. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -18
  95. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +29 -29
  96. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +80 -80
  97. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +73 -73
  98. package/dist/ts4.2/room/data-stream/outgoing/StreamWriter.d.ts +16 -16
  99. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -18
  100. package/dist/ts4.2/room/data-track/LocalDataTrack.d.ts +79 -80
  101. package/dist/ts4.2/room/data-track/RemoteDataTrack.d.ts +50 -50
  102. package/dist/ts4.2/room/data-track/depacketizer.d.ts +59 -59
  103. package/dist/ts4.2/room/data-track/frame.d.ts +22 -22
  104. package/dist/ts4.2/room/data-track/handle.d.ts +27 -27
  105. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +124 -124
  106. package/dist/ts4.2/room/data-track/incoming/errors.d.ts +24 -24
  107. package/dist/ts4.2/room/data-track/incoming/pipeline.d.ts +41 -41
  108. package/dist/ts4.2/room/data-track/incoming/types.d.ts +20 -20
  109. package/dist/ts4.2/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +126 -126
  110. package/dist/ts4.2/room/data-track/outgoing/errors.d.ts +74 -74
  111. package/dist/ts4.2/room/data-track/outgoing/pipeline.d.ts +23 -23
  112. package/dist/ts4.2/room/data-track/outgoing/types.d.ts +48 -48
  113. package/dist/ts4.2/room/data-track/packet/constants.d.ts +20 -20
  114. package/dist/ts4.2/room/data-track/packet/errors.d.ts +40 -40
  115. package/dist/ts4.2/room/data-track/packet/extensions.d.ts +69 -69
  116. package/dist/ts4.2/room/data-track/packet/index.d.ts +98 -98
  117. package/dist/ts4.2/room/data-track/packet/serializable.d.ts +13 -13
  118. package/dist/ts4.2/room/data-track/packetizer.d.ts +43 -43
  119. package/dist/ts4.2/room/data-track/track-interfaces.d.ts +23 -23
  120. package/dist/ts4.2/room/data-track/types.d.ts +22 -22
  121. package/dist/ts4.2/room/data-track/utils.d.ts +60 -60
  122. package/dist/ts4.2/room/debounce.d.ts +11 -11
  123. package/dist/ts4.2/room/defaults.d.ts +9 -9
  124. package/dist/ts4.2/room/errors.d.ts +180 -162
  125. package/dist/ts4.2/room/events.d.ts +638 -637
  126. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +287 -287
  127. package/dist/ts4.2/room/participant/Participant.d.ts +142 -144
  128. package/dist/ts4.2/room/participant/ParticipantTrackPermission.d.ts +26 -26
  129. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +85 -86
  130. package/dist/ts4.2/room/participant/publishUtils.d.ts +53 -53
  131. package/dist/ts4.2/room/rpc/client/RpcClientManager.d.ts +43 -43
  132. package/dist/ts4.2/room/rpc/client/events.d.ts +8 -8
  133. package/dist/ts4.2/room/rpc/index.d.ts +7 -7
  134. package/dist/ts4.2/room/rpc/server/RpcServerManager.d.ts +44 -44
  135. package/dist/ts4.2/room/rpc/server/events.d.ts +8 -8
  136. package/dist/ts4.2/room/rpc/utils.d.ts +131 -131
  137. package/dist/ts4.2/room/stats.d.ts +71 -71
  138. package/dist/ts4.2/room/statsSummary.d.ts +13 -13
  139. package/dist/ts4.2/room/timers.d.ts +12 -12
  140. package/dist/ts4.2/room/token-source/TokenSource.d.ts +82 -82
  141. package/dist/ts4.2/room/token-source/types.d.ts +70 -70
  142. package/dist/ts4.2/room/token-source/utils.d.ts +8 -8
  143. package/dist/ts4.2/room/track/FrameMetadataExtractor.d.ts +19 -19
  144. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +39 -42
  145. package/dist/ts4.2/room/track/LocalTrack.d.ts +125 -125
  146. package/dist/ts4.2/room/track/LocalTrackPublication.d.ts +42 -42
  147. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +102 -100
  148. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +55 -55
  149. package/dist/ts4.2/room/track/RemoteTrack.d.ts +38 -38
  150. package/dist/ts4.2/room/track/RemoteTrackPublication.d.ts +72 -75
  151. package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +73 -76
  152. package/dist/ts4.2/room/track/Track.d.ts +156 -166
  153. package/dist/ts4.2/room/track/TrackPublication.d.ts +75 -81
  154. package/dist/ts4.2/room/track/create.d.ts +25 -25
  155. package/dist/ts4.2/room/track/facingMode.d.ts +41 -41
  156. package/dist/ts4.2/room/track/options.d.ts +403 -403
  157. package/dist/ts4.2/room/track/processor/types.d.ts +31 -31
  158. package/dist/ts4.2/room/track/record.d.ts +13 -13
  159. package/dist/ts4.2/room/track/types.d.ts +28 -28
  160. package/dist/ts4.2/room/track/utils.d.ts +60 -47
  161. package/dist/ts4.2/room/types.d.ts +127 -127
  162. package/dist/ts4.2/room/utils.d.ts +246 -214
  163. package/dist/ts4.2/test/MockMediaStreamTrack.d.ts +26 -26
  164. package/dist/ts4.2/test/compressionStreamStubs.d.ts +12 -0
  165. package/dist/ts4.2/test/mocks.d.ts +12 -12
  166. package/dist/ts4.2/test/promiseState.d.ts +12 -12
  167. package/dist/ts4.2/test/signalToken.d.ts +26 -26
  168. package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -2
  169. package/dist/ts4.2/utils/AsyncQueue.d.ts +23 -23
  170. package/dist/ts4.2/utils/TypedPromise.d.ts +17 -17
  171. package/dist/ts4.2/utils/abort-signal-polyfill.d.ts +13 -13
  172. package/dist/ts4.2/utils/browserParser.d.ts +13 -13
  173. package/dist/ts4.2/utils/camelToSnakeCase.d.ts +8 -8
  174. package/dist/ts4.2/utils/cloneDeep.d.ts +2 -2
  175. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +34 -34
  176. package/dist/ts4.2/utils/deferrable-map.d.ts +32 -32
  177. package/dist/ts4.2/utils/machineInspector.d.ts +54 -54
  178. package/dist/ts4.2/utils/serializer.d.ts +48 -48
  179. package/dist/ts4.2/utils/subscribeToEvents.d.ts +15 -15
  180. package/dist/ts4.2/utils/ttlmap.d.ts +20 -20
  181. package/dist/ts4.2/version.d.ts +15 -15
  182. package/package.json +5 -4
  183. package/src/api/SignalClient.e2e.test.ts +1 -3
  184. package/src/api/SignalClient.test.ts +160 -10
  185. package/src/api/SignalClient.ts +75 -54
  186. package/src/options.ts +16 -0
  187. package/src/room/PCTransport.test.ts +183 -7
  188. package/src/room/PCTransport.ts +197 -36
  189. package/src/room/PCTransportManager.ts +3 -1
  190. package/src/room/RTCEngine.test.ts +84 -0
  191. package/src/room/RTCEngine.ts +24 -5
  192. package/src/room/Room.test.ts +32 -6
  193. package/src/room/Room.ts +26 -6
  194. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +16 -20
  195. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +5 -5
  196. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +60 -39
  197. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +4 -4
  198. package/src/room/defaults.ts +1 -0
  199. package/src/room/errors.test.ts +36 -0
  200. package/src/room/errors.ts +27 -0
  201. package/src/room/events.ts +2 -1
  202. package/src/room/participant/publishUtils.ts +0 -19
  203. package/src/room/rpc/client/RpcClientManager.test.ts +51 -0
  204. package/src/room/rpc/client/RpcClientManager.ts +32 -15
  205. package/src/room/track/LocalTrack.ts +18 -7
  206. package/src/room/track/LocalVideoTrack.ts +10 -3
  207. package/src/room/track/RemoteAudioTrack.ts +3 -3
  208. package/src/room/track/RemoteVideoTrack.test.ts +210 -0
  209. package/src/room/track/RemoteVideoTrack.ts +29 -3
  210. package/src/room/track/utils.test.ts +201 -2
  211. package/src/room/track/utils.ts +88 -0
  212. package/src/room/utils.test.ts +124 -0
  213. package/src/room/utils.ts +70 -0
  214. package/src/test/compressionStreamStubs.ts +53 -0
@@ -2,11 +2,15 @@ import { type MediaDescription, parse } from 'sdp-transform';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import {
4
4
  applyVideoStartBitrate,
5
+ computeConnectionStartBitrate,
6
+ computeTrackStartBitrate,
5
7
  conformBundledCodecFmtp,
6
8
  ensureAudioNackAndStereo,
7
9
  ensureVideoDDExtension,
8
10
  extractStereoAndNackAudioFromOffer,
11
+ findTrackCodecPayload,
9
12
  fmtpConfigHasParam,
13
+ isDuplicateAnswer,
10
14
  placeholderMidsFromTransceivers,
11
15
  } from './PCTransport';
12
16
  import { ddExtensionURI } from './utils';
@@ -61,9 +65,7 @@ a=recvonly
61
65
  a=rtpmap:49 H265/90000
62
66
  a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`;
63
67
 
64
- describe('video start bitrate', () => {
65
- it('applies the bitrate only to the section whose msid track ID matches the cid', () => {
66
- const { media } = parse(`v=0
68
+ const TWO_VIDEO_SECTIONS = `v=0
67
69
  o=- 0 0 IN IP4 127.0.0.1
68
70
  s=-
69
71
  t=0 0
@@ -79,15 +81,159 @@ c=IN IP4 0.0.0.0
79
81
  a=mid:1
80
82
  a=sendonly
81
83
  a=msid:PA_remote|camera camera-cid
82
- a=rtpmap:96 VP8/90000`);
84
+ a=rtpmap:96 VP8/90000`;
83
85
 
84
- for (const section of media) {
85
- applyVideoStartBitrate(section, 'camera-cid', 'VP8', 1_000);
86
- }
86
+ // The same bundle after the screen share is unpublished: `unpublishTrack` sets the
87
+ // transceiver to `inactive`, but the section keeps its `a=msid`, so it still matches the
88
+ // append-only trackBitrates entry. Only the camera is still sending.
89
+ const UNPUBLISHED_SCREEN_SHARE = `v=0
90
+ o=- 0 0 IN IP4 127.0.0.1
91
+ s=-
92
+ t=0 0
93
+ a=group:BUNDLE 0 1
94
+ m=video 9 UDP/TLS/RTP/SAVPF 96
95
+ c=IN IP4 0.0.0.0
96
+ a=mid:0
97
+ a=inactive
98
+ a=msid:PA_remote|camera other-track
99
+ a=rtpmap:96 VP8/90000
100
+ m=video 9 UDP/TLS/RTP/SAVPF 96
101
+ c=IN IP4 0.0.0.0
102
+ a=mid:1
103
+ a=sendonly
104
+ a=msid:PA_remote|camera camera-cid
105
+ a=rtpmap:96 VP8/90000`;
106
+
107
+ // The legacy `addTrack` fallback (no `addTransceiver` support) reuses a transceiver instead
108
+ // of creating a sendonly one, so the published camera lands on a `sendrecv` section. Mid 1
109
+ // omits the direction attribute entirely, which SDP also defaults to sendrecv. Both send.
110
+ const LEGACY_ADD_TRACK_SECTIONS = `v=0
111
+ o=- 0 0 IN IP4 127.0.0.1
112
+ s=-
113
+ t=0 0
114
+ a=group:BUNDLE 0 1
115
+ m=video 9 UDP/TLS/RTP/SAVPF 96
116
+ c=IN IP4 0.0.0.0
117
+ a=mid:0
118
+ a=sendrecv
119
+ a=msid:PA_remote|camera camera-cid
120
+ a=rtpmap:96 VP8/90000
121
+ m=video 9 UDP/TLS/RTP/SAVPF 96
122
+ c=IN IP4 0.0.0.0
123
+ a=mid:1
124
+ a=msid:PA_remote|camera other-track
125
+ a=rtpmap:96 VP8/90000`;
126
+
127
+ describe('video start bitrate', () => {
128
+ it('matches only the section whose msid track ID matches the cid', () => {
129
+ const { media } = parse(TWO_VIDEO_SECTIONS);
130
+
131
+ expect(findTrackCodecPayload(media[0], 'camera-cid', 'VP8')).toBeUndefined();
132
+ expect(findTrackCodecPayload(media[1], 'camera-cid', 'VP8')).toBe(96);
133
+ // Section belongs to the track but does not offer the codec.
134
+ expect(findTrackCodecPayload(media[1], 'camera-cid', 'AV1')).toBe(0);
135
+ });
136
+
137
+ it('applies the bitrate only to the section it is given', () => {
138
+ const { media } = parse(TWO_VIDEO_SECTIONS);
139
+
140
+ applyVideoStartBitrate(media[1], 96, 900);
87
141
 
88
142
  expect(fmtpOf(media, '0', 96)).toBeUndefined();
89
143
  expect(paramSet(fmtpOf(media, '1', 96)!)).toContain('x-google-start-bitrate=900');
90
144
  });
145
+
146
+ it('caps camera at 1 Mbps but leaves screen share uncapped', () => {
147
+ const camera = { cid: 'c', codec: 'VP8', maxbr: 3_000 };
148
+ const screenShare = { ...camera, isScreenShare: true };
149
+
150
+ expect(computeTrackStartBitrate(camera)).toBe(1_000);
151
+ expect(computeTrackStartBitrate(screenShare)).toBe(2_700);
152
+ });
153
+
154
+ it('gives no hint below the 300 kbps target floor', () => {
155
+ expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 299 })).toBeUndefined();
156
+ expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 })).toBe(270);
157
+ });
158
+
159
+ it('uses one connection-level value: the largest hint across video sections', () => {
160
+ const { media } = parse(TWO_VIDEO_SECTIONS);
161
+
162
+ const startBitrate = computeConnectionStartBitrate(media, [
163
+ { cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
164
+ { cid: 'other-track', codec: 'VP8', maxbr: 3_000, isScreenShare: true },
165
+ ]);
166
+
167
+ expect(startBitrate).toBe(2_700);
168
+ });
169
+
170
+ it('ignores registered tracks with no section in the current SDP', () => {
171
+ const { media } = parse(TWO_VIDEO_SECTIONS);
172
+
173
+ const startBitrate = computeConnectionStartBitrate(media, [
174
+ { cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
175
+ // Stale entry: trackBitrates is append-only and outlives an unpublish.
176
+ { cid: 'unpublished-cid', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
177
+ ]);
178
+
179
+ expect(startBitrate).toBe(900);
180
+ });
181
+
182
+ it('ignores a section that stopped sending but kept its msid', () => {
183
+ const trackBitrates = [
184
+ { cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
185
+ { cid: 'other-track', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
186
+ ];
187
+
188
+ // While both send, the uncapped screen share wins the connection-level max.
189
+ expect(computeConnectionStartBitrate(parse(TWO_VIDEO_SECTIONS).media, trackBitrates)).toBe(
190
+ 7_200,
191
+ );
192
+ // Once it is unpublished its entry is stale, so only the capped camera counts. Both
193
+ // directions a removed sender can land on are excluded: `inactive` from a sendonly
194
+ // transceiver, `recvonly` from the sendrecv one the addTrack fallback reuses.
195
+ expect(
196
+ computeConnectionStartBitrate(parse(UNPUBLISHED_SCREEN_SHARE).media, trackBitrates),
197
+ ).toBe(900);
198
+ expect(
199
+ computeConnectionStartBitrate(
200
+ parse(UNPUBLISHED_SCREEN_SHARE.replace('a=inactive', 'a=recvonly')).media,
201
+ trackBitrates,
202
+ ),
203
+ ).toBe(900);
204
+ });
205
+
206
+ it('gives no connection value when no section maps to a published track', () => {
207
+ const { media } = parse(TWO_VIDEO_SECTIONS);
208
+
209
+ expect(computeConnectionStartBitrate(media, [])).toBeUndefined();
210
+ });
211
+
212
+ it('counts sendrecv and direction-less sections, which still send local media', () => {
213
+ // The legacy addTrack fallback never produces `sendonly`, so a strict match on it would
214
+ // leave those clients with no hint at all.
215
+ const { media } = parse(LEGACY_ADD_TRACK_SECTIONS);
216
+
217
+ expect(
218
+ computeConnectionStartBitrate(media, [{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 }]),
219
+ ).toBe(900);
220
+ expect(
221
+ computeConnectionStartBitrate(media, [
222
+ { cid: 'other-track', codec: 'VP8', maxbr: 2_000, isScreenShare: true },
223
+ ]),
224
+ ).toBe(1_800);
225
+ });
226
+
227
+ it('leaves the hint unset when only non-sending sections match', () => {
228
+ // Nothing is published, so the one-shot hint must not be consumed on a dead section.
229
+ const { media } = parse(UNPUBLISHED_SCREEN_SHARE);
230
+
231
+ expect(
232
+ computeConnectionStartBitrate(media, [
233
+ { cid: 'other-track', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
234
+ ]),
235
+ ).toBeUndefined();
236
+ });
91
237
  });
92
238
 
93
239
  describe('placeholderMidsFromTransceivers', () => {
@@ -390,3 +536,33 @@ a=extmap:3 ${ddExtensionURI}`);
390
536
  expect(ddOf(sdp.media, '1')).toBe(12);
391
537
  });
392
538
  });
539
+
540
+ describe('isDuplicateAnswer', () => {
541
+ it('drops an answer while the same offer is still being answered', () => {
542
+ expect(isDuplicateAnswer(3, 3, 0, 'have-local-offer', false)).toBe(true);
543
+ });
544
+
545
+ it('drops a repeat of an unnumbered answer while the first is still applying', () => {
546
+ expect(isDuplicateAnswer(0, 0, 0, 'have-local-offer', false)).toBe(true);
547
+ });
548
+
549
+ it('drops an answer for an offer whose answer was already applied', () => {
550
+ expect(isDuplicateAnswer(3, undefined, 3, 'stable', false)).toBe(true);
551
+ });
552
+
553
+ it('accepts a retry after a failed apply: nothing in flight, nothing applied', () => {
554
+ expect(isDuplicateAnswer(3, undefined, 2, 'have-local-offer', false)).toBe(false);
555
+ });
556
+
557
+ it('accepts the answer for a new offer', () => {
558
+ expect(isDuplicateAnswer(4, undefined, 3, 'have-local-offer', false)).toBe(false);
559
+ });
560
+
561
+ it('without an offerId, drops an answer in stable', () => {
562
+ expect(isDuplicateAnswer(0, undefined, 0, 'stable', false)).toBe(true);
563
+ });
564
+
565
+ it('without an offerId, accepts the answer to a deferred initial offer', () => {
566
+ expect(isDuplicateAnswer(0, undefined, 0, 'stable', true)).toBe(false);
567
+ });
568
+ });
@@ -34,51 +34,130 @@ const startBitrateMultiplier = 0.9;
34
34
  /** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
35
35
  const maxStartBitrateKbps = 1000;
36
36
 
37
+ /**
38
+ * Minimum target bitrate in kbps for the start bitrate hint. Below this, seeding above the
39
+ * real capacity costs more than the ramp it saves, so libwebrtc's default is left in place.
40
+ */
41
+ const minTargetBitrateKbps = 300;
42
+
37
43
  const debounceInterval = 20;
38
44
 
39
45
  /**
40
- * Applies the configured start bitrate when this media section belongs to `cid`.
41
- * This SDP munging is used for a bitrate setting that cannot be applied through
42
- * `RTCRtpEncodingParameters`.
46
+ * Codec payload for `codec` in this media section, when the section carries `cid`.
43
47
  *
44
- * Returns `undefined` when the section does not belong to the track, `0` when
45
- * it does but does not offer the requested codec, and the codec payload when the
46
- * requested codec is present (whether the bitrate was added or already set).
48
+ * Returns `undefined` when the section does not belong to the track, `0` when it does but
49
+ * does not offer the requested codec, and the codec payload otherwise.
47
50
  *
48
51
  * @internal
49
52
  */
50
- export function applyVideoStartBitrate(
53
+ export function findTrackCodecPayload(
51
54
  media: MediaDescription,
52
55
  cid: string,
53
56
  codec: string,
54
- maxbr: number,
55
- isScreenShare = false,
56
57
  ): number | undefined {
57
58
  if (!media.msid?.includes(cid)) {
58
59
  return undefined;
59
60
  }
61
+ return media.rtp.find((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())?.payload ?? 0;
62
+ }
60
63
 
61
- const codecPayload =
62
- media.rtp.find((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())?.payload ?? 0;
63
- if (codecPayload === 0) {
64
- return 0;
64
+ /**
65
+ * Start bitrate hinted for a single track, or `undefined` when its target is too low to
66
+ * be worth seeding.
67
+ *
68
+ * 90% of the target leaves ~10% headroom for the estimator to settle. The same multiplier
69
+ * is used for every codec because the target already reflects the codec's efficiency.
70
+ * Camera is capped at 1 Mbps so the estimator does not open too aggressively on a
71
+ * high-bitrate track; screen share is exempt, because its content needs the bitrate
72
+ * immediately to stay legible.
73
+ *
74
+ * TODO: adjust dynamically from network conditions (e.g. a previous BWE estimate) rather
75
+ * than a fixed cap.
76
+ *
77
+ * @internal
78
+ */
79
+ export function computeTrackStartBitrate(trackbr: TrackBitrateInfo): number | undefined {
80
+ if (trackbr.maxbr < minTargetBitrateKbps) {
81
+ return undefined;
65
82
  }
83
+ const calculated = Math.round(trackbr.maxbr * startBitrateMultiplier);
84
+ return trackbr.isScreenShare ? calculated : Math.min(calculated, maxStartBitrateKbps);
85
+ }
66
86
 
67
- // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE
68
- // from starting too aggressively. Screen share is not capped since text/UI
69
- // clarity requires high bitrate from the start.
70
- // TODO: dynamically adjust start bitrate based on network conditions (e.g., previous BWE estimate)
71
- const calculatedStartBitrate = Math.round(maxbr * startBitrateMultiplier);
72
- const startBitrate = isScreenShare
73
- ? calculatedStartBitrate
74
- : Math.min(calculatedStartBitrate, maxStartBitrateKbps);
87
+ /**
88
+ * The single start bitrate for this peer connection: the largest hint among the video
89
+ * m-sections of `media` that map to a published track.
90
+ *
91
+ * libwebrtc reads `x-google-start-bitrate` per m-section but applies it to the shared
92
+ * `Call` (`WebRtcVideoSendChannel::ApplyChangedParams` -> `SetSdpBitrateParameters`), where
93
+ * `RtpBitrateConfigurator` holds one config for the whole connection. Differing per-section
94
+ * values are therefore last-writer-wins, decided by m-section order, so every video section
95
+ * gets the same number instead.
96
+ *
97
+ * Only sections that can send local media are considered. `trackBitrates` is append-only and an
98
+ * unpublished section keeps its `a=msid`, so matching on msid alone would still pair a stale
99
+ * entry with its old section — letting an uncapped screen-share target seed a connection that
100
+ * now carries only a camera, or consuming the one-shot hint on a section that sends nothing.
101
+ *
102
+ * The direction is the discriminator, as an exclusion rather than a match: `recvonly` and
103
+ * `inactive` are the only directions that cannot carry local media, and they are exactly the
104
+ * two a dead section lands on — unpublish sets `inactive` (explicitly in
105
+ * `LocalParticipant.unpublishTrack`, and via `removeTrack`'s sendonly -> inactive transition for
106
+ * the simulcast senders), `removeTrack` on a `sendrecv` transceiver leaves `recvonly`, and the
107
+ * pre-populated placeholders are `recvonly`. Everything else sends: `sendonly` from the
108
+ * `addTransceiver` path, `sendrecv` from the legacy `addTrack` fallback (which reuses a
109
+ * transceiver rather than creating a sendonly one), and a section with no direction attribute,
110
+ * which SDP defaults to `sendrecv`.
111
+ *
112
+ * @internal
113
+ */
114
+ export function computeConnectionStartBitrate(
115
+ media: MediaDescription[],
116
+ trackBitrates: TrackBitrateInfo[],
117
+ ): number | undefined {
118
+ let connectionStartBitrate: number | undefined;
119
+ for (const m of media) {
120
+ if (m.type !== 'video' || m.direction === 'recvonly' || m.direction === 'inactive') {
121
+ continue;
122
+ }
123
+ for (const trackbr of trackBitrates) {
124
+ if (!trackbr.cid) {
125
+ continue;
126
+ }
127
+ const codecPayload = findTrackCodecPayload(m, trackbr.cid, trackbr.codec);
128
+ if (codecPayload === undefined) {
129
+ continue;
130
+ }
131
+ const startBitrate = codecPayload > 0 ? computeTrackStartBitrate(trackbr) : undefined;
132
+ if (
133
+ startBitrate !== undefined &&
134
+ (connectionStartBitrate === undefined || startBitrate > connectionStartBitrate)
135
+ ) {
136
+ connectionStartBitrate = startBitrate;
137
+ }
138
+ break;
139
+ }
140
+ }
141
+ return connectionStartBitrate;
142
+ }
75
143
 
144
+ /**
145
+ * Declares `x-google-start-bitrate` on `codecPayload`'s fmtp. This SDP munging is used for
146
+ * a bitrate setting that cannot be applied through `RTCRtpEncodingParameters`.
147
+ *
148
+ * Returns whether the section now carries the hint.
149
+ *
150
+ * @internal
151
+ */
152
+ export function applyVideoStartBitrate(
153
+ media: MediaDescription,
154
+ codecPayload: number,
155
+ startBitrate: number,
156
+ ): boolean {
76
157
  const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
77
158
  if (fmtp) {
78
- // If another track's fmtp already has a start bitrate, it cannot be
79
- // overridden here because the payload type is shared across the bundle.
80
- // This forces every track sharing that payload to use the initial track's
81
- // start bitrate.
159
+ // A payload type is shared across the bundle, so a value written for one section is
160
+ // already the connection-level one; leave it rather than rewrite it.
82
161
  if (!fmtp.config.includes('x-google-start-bitrate')) {
83
162
  fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
84
163
  }
@@ -90,7 +169,7 @@ export function applyVideoStartBitrate(
90
169
  });
91
170
  }
92
171
 
93
- return codecPayload;
172
+ return true;
94
173
  }
95
174
 
96
175
  export const PCEvents = {
@@ -127,6 +206,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
127
206
 
128
207
  latestOfferId: number = 0;
129
208
 
209
+ private answeredOfferId: number = 0;
210
+
211
+ /** offerId (0 if unnumbered) of the answer being applied right now */
212
+ private answerInFlight: number | undefined;
213
+
130
214
  latestAcknowledgedOfferId: number = 0;
131
215
 
132
216
  private offerLock: Mutex;
@@ -141,6 +225,15 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
141
225
 
142
226
  trackBitrates: TrackBitrateInfo[] = [];
143
227
 
228
+ /**
229
+ * Whether an offer carrying the connection-level `x-google-start-bitrate` has been
230
+ * accepted locally. The hint is written once per peer connection: libwebrtc retains
231
+ * `start_bitrate_bps` in `RtpBitrateConfigurator` and re-applies it on network route
232
+ * changes, so a later rewrite is at best a no-op and at worst restarts a converged
233
+ * bandwidth estimator. A new peer connection (full reconnect) seeds a new estimator.
234
+ */
235
+ private hasAppliedVideoStartBitrate = false;
236
+
144
237
  remoteStereoMids: string[] = [];
145
238
 
146
239
  remoteNackMids: string[] = [];
@@ -250,6 +343,36 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
250
343
  });
251
344
  return false;
252
345
  }
346
+ if (sd.type === 'answer') {
347
+ if (
348
+ isDuplicateAnswer(
349
+ offerId,
350
+ this.answerInFlight,
351
+ this.answeredOfferId,
352
+ this._pc?.signalingState,
353
+ this.pendingInitialOffer !== undefined,
354
+ )
355
+ ) {
356
+ // a repeat of an answer already applied or being applied (e.g.
357
+ // duplicated by a proxy): there is no local offer left to answer
358
+ this.log.warn('ignoring duplicate answer', { offerId });
359
+ return false;
360
+ }
361
+ // claimed before any await, so a copy arriving mid-apply is caught too;
362
+ // released however the apply ends, so a genuine retry is still possible
363
+ this.answerInFlight = offerId;
364
+ }
365
+ try {
366
+ return await this.applyRemoteDescription(sd, offerId);
367
+ } finally {
368
+ if (sd.type === 'answer') this.answerInFlight = undefined;
369
+ }
370
+ }
371
+
372
+ private async applyRemoteDescription(
373
+ sd: RTCSessionDescriptionInit,
374
+ offerId: number,
375
+ ): Promise<boolean> {
253
376
  let mungedSDP: string | undefined = undefined;
254
377
  if (sd.type === 'offer') {
255
378
  let { stereoMids, nackMids } = extractStereoAndNackAudioFromOffer(sd);
@@ -331,6 +454,8 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
331
454
  mungedSDP = write(sdpParsed);
332
455
  }
333
456
  await this.setMungedSDP(sd, mungedSDP, true);
457
+ // only a successfully applied answer makes later copies duplicates
458
+ if (sd.type === 'answer' && offerId > 0) this.answeredOfferId = offerId;
334
459
 
335
460
  if (this.pendingCandidates.length > 0) {
336
461
  this.iceLog.debug('flushing queued ICE candidates', {
@@ -453,6 +578,14 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
453
578
  this.log.debug('original offer', { sdp: offer.sdp });
454
579
 
455
580
  const sdpParsed = parse(offer.sdp ?? '');
581
+ // One value for every video m-section, written only on the first offer that carries
582
+ // local video: the hint is connection-level in libwebrtc, so differing per-section
583
+ // values would be last-writer-wins on m-section order. Offers before any video is
584
+ // published (data channel or audio only) find no target and leave the latch unset.
585
+ const connectionStartBitrate = this.hasAppliedVideoStartBitrate
586
+ ? undefined
587
+ : computeConnectionStartBitrate(sdpParsed.media, this.trackBitrates);
588
+ let appliedVideoStartBitrate = false;
456
589
  sdpParsed.media.forEach((media) => {
457
590
  ensureIPAddrMatchVersion(media);
458
591
  if (media.type === 'audio') {
@@ -463,19 +596,21 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
463
596
  return false;
464
597
  }
465
598
 
466
- const codecPayload = applyVideoStartBitrate(
467
- media,
468
- trackbr.cid,
469
- trackbr.codec,
470
- trackbr.maxbr,
471
- trackbr.isScreenShare,
472
- );
599
+ const codecPayload = findTrackCodecPayload(media, trackbr.cid, trackbr.codec);
473
600
  if (codecPayload === undefined) {
474
601
  return false;
475
602
  }
476
603
 
477
- if (codecPayload > 0 && isSVCCodec(trackbr.codec) && !isSafari()) {
478
- this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
604
+ if (codecPayload > 0) {
605
+ if (connectionStartBitrate !== undefined) {
606
+ appliedVideoStartBitrate =
607
+ applyVideoStartBitrate(media, codecPayload, connectionStartBitrate) ||
608
+ appliedVideoStartBitrate;
609
+ }
610
+
611
+ if (isSVCCodec(trackbr.codec) && !isSafari()) {
612
+ this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
613
+ }
479
614
  }
480
615
 
481
616
  return true;
@@ -500,7 +635,13 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
500
635
  });
501
636
  return;
502
637
  }
503
- await this.setMungedSDP(offer, write(sdpParsed));
638
+ const mungedSdp = write(sdpParsed);
639
+ await this.setMungedSDP(offer, mungedSdp);
640
+ // setMungedSDP falls back to the unmunged SDP on rejection. Only consume the
641
+ // one-shot hint once the SDP carrying it has been accepted locally.
642
+ if (appliedVideoStartBitrate && offer.sdp === mungedSdp) {
643
+ this.hasAppliedVideoStartBitrate = true;
644
+ }
504
645
  this.onOffer(offer, this.latestOfferId);
505
646
  } finally {
506
647
  unlock();
@@ -952,6 +1093,26 @@ export function conformBundledCodecFmtp(
952
1093
  }
953
1094
 
954
1095
  /** @internal */
1096
+ /**
1097
+ * Whether an incoming answer repeats one already applied or being applied.
1098
+ * Any answer for the offer whose answer is in flight is a duplicate, numbered
1099
+ * or not. With an offerId (current servers) an answer is also a duplicate if
1100
+ * that offer's answer was already applied. Without one, fall back to the
1101
+ * signaling state: in `stable` no local offer is pending, unless the initial
1102
+ * offer is still deferred (it is set locally only once its answer arrives).
1103
+ */
1104
+ export function isDuplicateAnswer(
1105
+ offerId: number,
1106
+ answerInFlight: number | undefined,
1107
+ answeredOfferId: number,
1108
+ signalingState: RTCSignalingState | undefined,
1109
+ hasPendingInitialOffer: boolean,
1110
+ ): boolean {
1111
+ if (answerInFlight === offerId) return true;
1112
+ if (offerId > 0) return offerId === answeredOfferId;
1113
+ return signalingState === 'stable' && !hasPendingInitialOffer;
1114
+ }
1115
+
955
1116
  export function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
956
1117
  stereoMids: string[];
957
1118
  nackMids: string[];
@@ -229,7 +229,9 @@ export class PCTransportManager {
229
229
  this.publisher.getConnectionState() !== 'connecting'
230
230
  ) {
231
231
  this.log.debug('negotiation required, start negotiating');
232
- this.publisher.negotiate();
232
+ this.publisher.negotiate().catch((err) => {
233
+ this.log.error(err);
234
+ });
233
235
  }
234
236
  await Promise.all(
235
237
  this.requiredTransports?.map((transport) =>
@@ -10,6 +10,7 @@ import { PCTransportState } from './PCTransportManager';
10
10
  import RTCEngine, { DataChannelKind } from './RTCEngine';
11
11
  import { roomOptionDefaults } from './defaults';
12
12
  import { PublishDataError, UnexpectedConnectionState } from './errors';
13
+ import { EngineEvent } from './events';
13
14
 
14
15
  describe('RTCEngine', () => {
15
16
  const originalRTCRtpSender = window.RTCRtpSender;
@@ -919,4 +920,87 @@ describe('RTCEngine', () => {
919
920
  expect(handleDisconnect).not.toHaveBeenCalled();
920
921
  });
921
922
  });
923
+
924
+ describe('negotiate', () => {
925
+ /**
926
+ * An engine whose `pcManager` parks in `negotiate()` until its abort controller fires, so
927
+ * several negotiations can be held in flight at once. The controllers are captured so tests
928
+ * can check the abort actually reached each in-flight call.
929
+ */
930
+ function stubNegotiableEngine(engine: RTCEngine) {
931
+ const controllers: AbortController[] = [];
932
+ const negotiate = vi.fn((abortController: AbortController) => {
933
+ controllers.push(abortController);
934
+ return new Promise<void>((_resolve, reject) => {
935
+ abortController.signal.addEventListener(
936
+ 'abort',
937
+ () => reject(new Error('negotiation aborted')),
938
+ { once: true },
939
+ );
940
+ });
941
+ });
942
+ Object.assign(engine as unknown as Record<string, unknown>, {
943
+ _isClosed: false,
944
+ pcManager: {
945
+ requirePublisher: vi.fn(),
946
+ // a non-empty transceiver list keeps negotiate() off the createDataChannels path
947
+ publisher: { getTransceivers: () => [{}], off: vi.fn(), once: vi.fn() },
948
+ negotiate,
949
+ },
950
+ handleDisconnect: vi.fn(),
951
+ });
952
+ return { negotiate, controllers };
953
+ }
954
+
955
+ const pendingAborts = (engine: RTCEngine) =>
956
+ (engine as unknown as { pendingNegotiationAborts: Set<() => void> }).pendingNegotiationAborts;
957
+
958
+ it('does not add a Closing/Restarting listener per in-flight negotiate call', async () => {
959
+ const engine = new RTCEngine(roomOptionDefaults);
960
+ const { negotiate, controllers } = stubNegotiableEngine(engine);
961
+
962
+ // A burst of server-initiated renegotiations used to stack a listener pair per call, which
963
+ // trips the emitter's max-listener warning at 11.
964
+ const closingBaseline = engine.listenerCount(EngineEvent.Closing);
965
+ const restartingBaseline = engine.listenerCount(EngineEvent.Restarting);
966
+
967
+ const pending = Array.from({ length: 20 }, () => engine.negotiate());
968
+ await tick();
969
+
970
+ expect(negotiate).toHaveBeenCalledTimes(20);
971
+ expect(engine.listenerCount(EngineEvent.Closing)).toBe(closingBaseline);
972
+ expect(engine.listenerCount(EngineEvent.Restarting)).toBe(restartingBaseline);
973
+
974
+ // The single central listener still has to reach every one of them.
975
+ engine.emit(EngineEvent.Closing);
976
+ await expect(Promise.all(pending)).resolves.toHaveLength(20);
977
+ expect(controllers).toHaveLength(20);
978
+ expect(controllers.every((c) => c.signal.aborted)).toBe(true);
979
+ expect(pendingAborts(engine).size).toBe(0);
980
+ });
981
+
982
+ it('aborts in-flight negotiations on restart without disarming later ones', async () => {
983
+ const engine = new RTCEngine(roomOptionDefaults);
984
+ const { controllers } = stubNegotiableEngine(engine);
985
+
986
+ const firstBatch = [engine.negotiate(), engine.negotiate()];
987
+ await tick();
988
+ engine.emit(EngineEvent.Restarting);
989
+ await expect(Promise.all(firstBatch)).resolves.toHaveLength(2);
990
+ expect(controllers.every((c) => c.signal.aborted)).toBe(true);
991
+
992
+ // Restarting fires on every reconnect attempt, so the fan-out point has to re-arm: a
993
+ // negotiation started after a restart must not be aborted on arrival.
994
+ const afterRestart = engine.negotiate();
995
+ await tick();
996
+ expect(controllers).toHaveLength(3);
997
+ expect(controllers[2].signal.aborted).toBe(false);
998
+ expect(pendingAborts(engine).size).toBe(1);
999
+
1000
+ engine.emit(EngineEvent.Restarting);
1001
+ await expect(afterRestart).resolves.toBeUndefined();
1002
+ expect(controllers[2].signal.aborted).toBe(true);
1003
+ expect(pendingAborts(engine).size).toBe(0);
1004
+ });
1005
+ });
922
1006
  });