livekit-client 2.13.0 → 2.13.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 (66) 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 +1 -0
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +383 -117
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/index.d.ts +1 -0
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/room/RTCEngine.d.ts +1 -1
  12. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  13. package/dist/src/room/Room.d.ts +6 -2
  14. package/dist/src/room/Room.d.ts.map +1 -1
  15. package/dist/src/room/defaults.d.ts.map +1 -1
  16. package/dist/src/room/errors.d.ts +2 -1
  17. package/dist/src/room/errors.d.ts.map +1 -1
  18. package/dist/src/room/events.d.ts +5 -1
  19. package/dist/src/room/events.d.ts.map +1 -1
  20. package/dist/src/room/participant/LocalParticipant.d.ts +9 -0
  21. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  22. package/dist/src/room/participant/Participant.d.ts +1 -1
  23. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  24. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  25. package/dist/src/room/track/LocalTrack.d.ts +10 -0
  26. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  27. package/dist/src/room/track/LocalVideoTrack.d.ts +1 -1
  28. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  29. package/dist/src/room/track/Track.d.ts +1 -0
  30. package/dist/src/room/track/Track.d.ts.map +1 -1
  31. package/dist/src/room/track/options.d.ts +8 -0
  32. package/dist/src/room/track/options.d.ts.map +1 -1
  33. package/dist/src/room/track/record.d.ts +6 -0
  34. package/dist/src/room/track/record.d.ts.map +1 -0
  35. package/dist/src/room/utils.d.ts +3 -0
  36. package/dist/src/room/utils.d.ts.map +1 -1
  37. package/dist/ts4.2/src/index.d.ts +1 -0
  38. package/dist/ts4.2/src/room/RTCEngine.d.ts +1 -1
  39. package/dist/ts4.2/src/room/Room.d.ts +5 -1
  40. package/dist/ts4.2/src/room/errors.d.ts +2 -1
  41. package/dist/ts4.2/src/room/events.d.ts +5 -1
  42. package/dist/ts4.2/src/room/participant/LocalParticipant.d.ts +9 -0
  43. package/dist/ts4.2/src/room/participant/Participant.d.ts +1 -1
  44. package/dist/ts4.2/src/room/track/LocalTrack.d.ts +10 -0
  45. package/dist/ts4.2/src/room/track/LocalVideoTrack.d.ts +1 -1
  46. package/dist/ts4.2/src/room/track/Track.d.ts +1 -0
  47. package/dist/ts4.2/src/room/track/options.d.ts +8 -0
  48. package/dist/ts4.2/src/room/track/record.d.ts +6 -0
  49. package/dist/ts4.2/src/room/utils.d.ts +3 -0
  50. package/package.json +13 -12
  51. package/src/e2ee/worker/tsconfig.json +9 -1
  52. package/src/index.ts +2 -0
  53. package/src/room/RTCEngine.ts +7 -7
  54. package/src/room/Room.ts +23 -8
  55. package/src/room/defaults.ts +1 -0
  56. package/src/room/errors.ts +1 -0
  57. package/src/room/events.ts +5 -0
  58. package/src/room/participant/LocalParticipant.ts +215 -34
  59. package/src/room/participant/Participant.ts +1 -1
  60. package/src/room/participant/publishUtils.ts +4 -0
  61. package/src/room/track/LocalTrack.ts +47 -2
  62. package/src/room/track/LocalVideoTrack.ts +14 -5
  63. package/src/room/track/Track.ts +1 -0
  64. package/src/room/track/options.ts +9 -0
  65. package/src/room/track/record.ts +51 -0
  66. package/src/room/utils.ts +14 -1
@@ -1,6 +1,7 @@
1
1
  import { Mutex } from '@livekit/mutex';
2
2
  import {
3
3
  AddTrackRequest,
4
+ AudioTrackFeature,
4
5
  BackupCodecPolicy,
5
6
  ChatMessage as ChatMessageModel,
6
7
  Codec,
@@ -13,6 +14,7 @@ import {
13
14
  DataStream_TextHeader,
14
15
  DataStream_Trailer,
15
16
  Encryption_Type,
17
+ JoinResponse,
16
18
  ParticipantInfo,
17
19
  ParticipantPermission,
18
20
  RequestResponse,
@@ -71,6 +73,7 @@ import {
71
73
  mergeDefaultOptions,
72
74
  mimeTypeToVideoCodecString,
73
75
  screenCaptureToDisplayMediaStreamOptions,
76
+ sourceToKind,
74
77
  } from '../track/utils';
75
78
  import {
76
79
  type ByteStreamInfo,
@@ -102,6 +105,7 @@ import {
102
105
  import Participant from './Participant';
103
106
  import type { ParticipantTrackPermission } from './ParticipantTrackPermission';
104
107
  import { trackPermissionToProto } from './ParticipantTrackPermission';
108
+ import type RemoteParticipant from './RemoteParticipant';
105
109
  import {
106
110
  computeTrackBackupEncodings,
107
111
  computeVideoEncodings,
@@ -145,6 +149,12 @@ export default class LocalParticipant extends Participant {
145
149
 
146
150
  private reconnectFuture?: Future<void>;
147
151
 
152
+ private signalConnectedFuture?: Future<void>;
153
+
154
+ private activeAgentFuture?: Future<RemoteParticipant>;
155
+
156
+ private firstActiveAgent?: RemoteParticipant;
157
+
148
158
  private rpcHandlers: Map<string, (data: RpcInvocationData) => Promise<string>>;
149
159
 
150
160
  private pendingSignalRequests: Map<
@@ -240,6 +250,7 @@ export default class LocalParticipant extends Participant {
240
250
 
241
251
  this.engine
242
252
  .on(EngineEvent.Connected, this.handleReconnected)
253
+ .on(EngineEvent.SignalConnected, this.handleSignalConnected)
243
254
  .on(EngineEvent.SignalRestarted, this.handleReconnected)
244
255
  .on(EngineEvent.SignalResumed, this.handleReconnected)
245
256
  .on(EngineEvent.Restarting, this.handleReconnecting)
@@ -269,6 +280,25 @@ export default class LocalParticipant extends Participant {
269
280
  this.reconnectFuture?.reject?.('Got disconnected during reconnection attempt');
270
281
  this.reconnectFuture = undefined;
271
282
  }
283
+ if (this.signalConnectedFuture) {
284
+ this.signalConnectedFuture.reject?.('Got disconnected without signal connected');
285
+ this.signalConnectedFuture = undefined;
286
+ }
287
+
288
+ this.activeAgentFuture?.reject?.('Got disconnected without active agent present');
289
+ this.activeAgentFuture = undefined;
290
+ this.firstActiveAgent = undefined;
291
+ };
292
+
293
+ private handleSignalConnected = (joinResponse: JoinResponse) => {
294
+ if (joinResponse.participant) {
295
+ this.updateInfo(joinResponse.participant);
296
+ }
297
+ if (!this.signalConnectedFuture) {
298
+ this.signalConnectedFuture = new Future<void>();
299
+ }
300
+
301
+ this.signalConnectedFuture.resolve?.();
272
302
  };
273
303
 
274
304
  private handleSignalRequestResponse = (response: RequestResponse) => {
@@ -517,11 +547,25 @@ export default class LocalParticipant extends Participant {
517
547
  tr.stop();
518
548
  });
519
549
  if (e instanceof Error) {
520
- this.emit(ParticipantEvent.MediaDevicesError, e);
550
+ this.emit(ParticipantEvent.MediaDevicesError, e, sourceToKind(source));
521
551
  }
522
552
  this.pendingPublishing.delete(source);
523
553
  throw e;
524
554
  }
555
+
556
+ for (const localTrack of localTracks) {
557
+ if (
558
+ source === Track.Source.Microphone &&
559
+ isAudioTrack(localTrack) &&
560
+ publishOptions?.preConnectBuffer
561
+ ) {
562
+ this.log.info('starting preconnect buffer for microphone', {
563
+ ...this.logContext,
564
+ });
565
+ localTrack.startPreConnectBuffer();
566
+ }
567
+ }
568
+
525
569
  try {
526
570
  const publishPromises: Array<Promise<LocalTrackPublication>> = [];
527
571
  for (const localTrack of localTracks) {
@@ -848,16 +892,8 @@ export default class LocalParticipant extends Participant {
848
892
  ...this.logContext,
849
893
  track: getLogContextFromTrack(track),
850
894
  });
851
- const onSignalConnected = async () => {
852
- try {
853
- const publication = await this.publish(track, opts, isStereo);
854
- resolve(publication);
855
- } catch (e) {
856
- reject(e);
857
- }
858
- };
859
- setTimeout(() => {
860
- this.engine.off(EngineEvent.SignalConnected, onSignalConnected);
895
+
896
+ const timeout = setTimeout(() => {
861
897
  reject(
862
898
  new PublishTrackError(
863
899
  'publishing rejected as engine not connected within timeout',
@@ -865,11 +901,10 @@ export default class LocalParticipant extends Participant {
865
901
  ),
866
902
  );
867
903
  }, 15_000);
868
- this.engine.once(EngineEvent.SignalConnected, onSignalConnected);
869
- this.engine.on(EngineEvent.Closing, () => {
870
- this.engine.off(EngineEvent.SignalConnected, onSignalConnected);
871
- reject(new PublishTrackError('publishing rejected as engine closed', 499));
872
- });
904
+ await this.waitUntilEngineConnected();
905
+ clearTimeout(timeout);
906
+ const publication = await this.publish(track, opts, isStereo);
907
+ resolve(publication);
873
908
  } else {
874
909
  try {
875
910
  const publication = await this.publish(track, opts, isStereo);
@@ -893,6 +928,13 @@ export default class LocalParticipant extends Participant {
893
928
  }
894
929
  }
895
930
 
931
+ private waitUntilEngineConnected() {
932
+ if (!this.signalConnectedFuture) {
933
+ this.signalConnectedFuture = new Future<void>();
934
+ }
935
+ return this.signalConnectedFuture.promise;
936
+ }
937
+
896
938
  private hasPermissionsToPublish(track: LocalTrack): boolean {
897
939
  if (!this.permissions) {
898
940
  this.log.warn('no permissions present for publishing track', {
@@ -970,6 +1012,30 @@ export default class LocalParticipant extends Participant {
970
1012
  track.on(TrackEvent.UpstreamResumed, this.onTrackUpstreamResumed);
971
1013
  track.on(TrackEvent.AudioTrackFeatureUpdate, this.onTrackFeatureUpdate);
972
1014
 
1015
+ const audioFeatures: AudioTrackFeature[] = [];
1016
+ const disableDtx = !(opts.dtx ?? true);
1017
+
1018
+ const settings = track.getSourceTrackSettings();
1019
+
1020
+ if (settings.autoGainControl) {
1021
+ audioFeatures.push(AudioTrackFeature.TF_AUTO_GAIN_CONTROL);
1022
+ }
1023
+ if (settings.echoCancellation) {
1024
+ audioFeatures.push(AudioTrackFeature.TF_ECHO_CANCELLATION);
1025
+ }
1026
+ if (settings.noiseSuppression) {
1027
+ audioFeatures.push(AudioTrackFeature.TF_NOISE_SUPPRESSION);
1028
+ }
1029
+ if (settings.channelCount && settings.channelCount > 1) {
1030
+ audioFeatures.push(AudioTrackFeature.TF_STEREO);
1031
+ }
1032
+ if (disableDtx) {
1033
+ audioFeatures.push(AudioTrackFeature.TF_NO_DTX);
1034
+ }
1035
+ if (isLocalAudioTrack(track) && track.hasPreConnectBuffer) {
1036
+ audioFeatures.push(AudioTrackFeature.TF_PRECONNECT_BUFFER);
1037
+ }
1038
+
973
1039
  // create track publication from track
974
1040
  const req = new AddTrackRequest({
975
1041
  // get local track id for use during publishing
@@ -978,12 +1044,13 @@ export default class LocalParticipant extends Participant {
978
1044
  type: Track.kindToProto(track.kind),
979
1045
  muted: track.isMuted,
980
1046
  source: Track.sourceToProto(track.source),
981
- disableDtx: !(opts.dtx ?? true),
1047
+ disableDtx,
982
1048
  encryption: this.encryptionType,
983
1049
  stereo: isStereo,
984
1050
  disableRed: this.isE2EEEnabled || !(opts.red ?? true),
985
1051
  stream: opts?.stream,
986
1052
  backupCodecPolicy: opts?.backupCodecPolicy as BackupCodecPolicy,
1053
+ audioFeatures,
987
1054
  });
988
1055
 
989
1056
  // compute encodings and layers for video
@@ -1142,11 +1209,32 @@ export default class LocalParticipant extends Participant {
1142
1209
  };
1143
1210
 
1144
1211
  let ti: TrackInfo;
1212
+ const addTrackPromise = new Promise<TrackInfo>(async (resolve, reject) => {
1213
+ try {
1214
+ ti = await this.engine.addTrack(req);
1215
+ resolve(ti);
1216
+ } catch (err) {
1217
+ if (track.sender && this.engine.pcManager?.publisher) {
1218
+ this.engine.pcManager.publisher.removeTrack(track.sender);
1219
+ await this.engine.negotiate().catch((negotiateErr) => {
1220
+ this.log.error(
1221
+ 'failed to negotiate after removing track due to failed add track request',
1222
+ {
1223
+ ...this.logContext,
1224
+ ...getLogContextFromTrack(track),
1225
+ error: negotiateErr,
1226
+ },
1227
+ );
1228
+ });
1229
+ }
1230
+ reject(err);
1231
+ }
1232
+ });
1145
1233
  if (this.enabledPublishVideoCodecs.length > 0) {
1146
- const rets = await Promise.all([this.engine.addTrack(req), negotiate()]);
1234
+ const rets = await Promise.all([addTrackPromise, negotiate()]);
1147
1235
  ti = rets[0];
1148
1236
  } else {
1149
- ti = await this.engine.addTrack(req);
1237
+ ti = await addTrackPromise;
1150
1238
  // server might not support the codec the client has requested, in that case, fallback
1151
1239
  // to a supported codec
1152
1240
  let primaryCodecMime: string | undefined;
@@ -1200,6 +1288,79 @@ export default class LocalParticipant extends Participant {
1200
1288
  this.addTrackPublication(publication);
1201
1289
  // send event for publication
1202
1290
  this.emit(ParticipantEvent.LocalTrackPublished, publication);
1291
+
1292
+ if (
1293
+ isLocalAudioTrack(track) &&
1294
+ ti.audioFeatures.includes(AudioTrackFeature.TF_PRECONNECT_BUFFER)
1295
+ ) {
1296
+ const stream = track.getPreConnectBuffer();
1297
+ // TODO: we're registering the listener after negotiation, so there might be a race
1298
+ this.on(ParticipantEvent.LocalTrackSubscribed, (pub) => {
1299
+ if (pub.trackSid === ti.sid) {
1300
+ if (!track.hasPreConnectBuffer) {
1301
+ this.log.warn('subscribe event came to late, buffer already closed', this.logContext);
1302
+ return;
1303
+ }
1304
+ this.log.debug('finished recording preconnect buffer', {
1305
+ ...this.logContext,
1306
+ ...getLogContextFromTrack(track),
1307
+ });
1308
+ track.stopPreConnectBuffer();
1309
+ }
1310
+ });
1311
+
1312
+ if (stream) {
1313
+ const bufferStreamPromise = new Promise<void>(async (resolve, reject) => {
1314
+ try {
1315
+ this.log.debug('waiting for agent', {
1316
+ ...this.logContext,
1317
+ ...getLogContextFromTrack(track),
1318
+ });
1319
+ const agentActiveTimeout = setTimeout(() => {
1320
+ reject(new Error('agent not active within 10 seconds'));
1321
+ }, 10_000);
1322
+ const agent = await this.waitUntilActiveAgentPresent();
1323
+ clearTimeout(agentActiveTimeout);
1324
+ this.log.debug('sending preconnect buffer', {
1325
+ ...this.logContext,
1326
+ ...getLogContextFromTrack(track),
1327
+ });
1328
+ const writer = await this.streamBytes({
1329
+ name: 'preconnect-buffer',
1330
+ mimeType: 'audio/opus',
1331
+ topic: 'lk.agent.pre-connect-audio-buffer',
1332
+ destinationIdentities: [agent.identity],
1333
+ attributes: {
1334
+ trackId: publication.trackSid,
1335
+ sampleRate: String(settings.sampleRate ?? '48000'),
1336
+ channels: String(settings.channelCount ?? '1'),
1337
+ },
1338
+ });
1339
+ for await (const chunk of stream) {
1340
+ await writer.write(chunk);
1341
+ }
1342
+ await writer.close();
1343
+ resolve();
1344
+ } catch (e) {
1345
+ reject(e);
1346
+ }
1347
+ });
1348
+ bufferStreamPromise
1349
+ .then(() => {
1350
+ this.log.debug('preconnect buffer sent successfully', {
1351
+ ...this.logContext,
1352
+ ...getLogContextFromTrack(track),
1353
+ });
1354
+ })
1355
+ .catch((e) => {
1356
+ this.log.error('error sending preconnect buffer', {
1357
+ ...this.logContext,
1358
+ ...getLogContextFromTrack(track),
1359
+ error: e,
1360
+ });
1361
+ });
1362
+ }
1363
+ }
1203
1364
  return publication;
1204
1365
  }
1205
1366
 
@@ -2091,6 +2252,30 @@ export default class LocalParticipant extends Participant {
2091
2252
  );
2092
2253
  };
2093
2254
 
2255
+ /** @internal */
2256
+ setActiveAgent(agent: RemoteParticipant | undefined) {
2257
+ this.firstActiveAgent = agent;
2258
+ if (agent && !this.firstActiveAgent) {
2259
+ this.firstActiveAgent = agent;
2260
+ }
2261
+ if (agent) {
2262
+ this.activeAgentFuture?.resolve?.(agent);
2263
+ } else {
2264
+ this.activeAgentFuture?.reject?.('Agent disconnected');
2265
+ }
2266
+ this.activeAgentFuture = undefined;
2267
+ }
2268
+
2269
+ private waitUntilActiveAgentPresent() {
2270
+ if (this.firstActiveAgent) {
2271
+ return Promise.resolve(this.firstActiveAgent);
2272
+ }
2273
+ if (!this.activeAgentFuture) {
2274
+ this.activeAgentFuture = new Future<RemoteParticipant>();
2275
+ }
2276
+ return this.activeAgentFuture.promise;
2277
+ }
2278
+
2094
2279
  /** @internal */
2095
2280
  private onTrackUnmuted = (track: LocalTrack) => {
2096
2281
  this.onTrackMuted(track, track.isUpstreamPaused);
@@ -2154,22 +2339,18 @@ export default class LocalParticipant extends Participant {
2154
2339
  });
2155
2340
  return;
2156
2341
  }
2157
- if (update.subscribedCodecs.length > 0) {
2158
- if (!pub.videoTrack) {
2159
- return;
2160
- }
2161
- const newCodecs = await pub.videoTrack.setPublishingCodecs(update.subscribedCodecs);
2162
- for await (const codec of newCodecs) {
2163
- if (isBackupCodec(codec)) {
2164
- this.log.debug(`publish ${codec} for ${pub.videoTrack.sid}`, {
2165
- ...this.logContext,
2166
- ...getLogContextFromTrack(pub),
2167
- });
2168
- await this.publishAdditionalCodecForTrack(pub.videoTrack, codec, pub.options);
2169
- }
2342
+ if (!pub.videoTrack) {
2343
+ return;
2344
+ }
2345
+ const newCodecs = await pub.videoTrack.setPublishingCodecs(update.subscribedCodecs);
2346
+ for await (const codec of newCodecs) {
2347
+ if (isBackupCodec(codec)) {
2348
+ this.log.debug(`publish ${codec} for ${pub.videoTrack.sid}`, {
2349
+ ...this.logContext,
2350
+ ...getLogContextFromTrack(pub),
2351
+ });
2352
+ await this.publishAdditionalCodecForTrack(pub.videoTrack, codec, pub.options);
2170
2353
  }
2171
- } else if (update.subscribedQualities.length > 0) {
2172
- await pub.videoTrack?.setPublishingLayers(update.subscribedQualities);
2173
2354
  }
2174
2355
  };
2175
2356
 
@@ -421,7 +421,7 @@ export type ParticipantEventCallbacks = {
421
421
  publication: RemoteTrackPublication,
422
422
  status: TrackPublication.PermissionStatus,
423
423
  ) => void;
424
- mediaDevicesError: (error: Error) => void;
424
+ mediaDevicesError: (error: Error, kind?: MediaDeviceKind) => void;
425
425
  audioStreamAcquired: () => void;
426
426
  participantPermissionsChanged: (prevPermissions?: ParticipantPermission) => void;
427
427
  trackSubscriptionStatusChanged: (
@@ -19,6 +19,7 @@ import {
19
19
  isReactNative,
20
20
  isSVCCodec,
21
21
  isSafari,
22
+ isSafariSvcApi,
22
23
  unwrapConstraint,
23
24
  } from '../utils';
24
25
 
@@ -158,12 +159,15 @@ export function computeVideoEncodings(
158
159
  (browser?.name === 'Chrome' && compareVersions(browser?.version, '113') < 0)
159
160
  ) {
160
161
  const bitratesRatio = sm.suffix == 'h' ? 2 : 3;
162
+ // safari 18.4 uses a different svc API that requires scaleResolutionDownBy to be set.
163
+ const requireScale = isSafariSvcApi(browser);
161
164
  for (let i = 0; i < sm.spatial; i += 1) {
162
165
  // in legacy SVC, scaleResolutionDownBy cannot be set
163
166
  encodings.push({
164
167
  rid: videoRids[2 - i],
165
168
  maxBitrate: videoEncoding.maxBitrate / bitratesRatio ** i,
166
169
  maxFramerate: original.encoding.maxFramerate,
170
+ scaleResolutionDownBy: requireScale ? 2 ** i : undefined,
167
171
  });
168
172
  }
169
173
  // legacy SVC, scalabilityMode is set only on the first encoding
@@ -9,15 +9,19 @@ import { compareVersions, isMobile, sleep, unwrapConstraint } from '../utils';
9
9
  import { Track, attachToElement, detachTrack } from './Track';
10
10
  import type { VideoCodec } from './options';
11
11
  import type { TrackProcessor } from './processor/types';
12
+ import { LocalTrackRecorder } from './record';
12
13
  import type { ReplaceTrackOptions } from './types';
13
14
 
14
- const defaultDimensionsTimeout = 1000;
15
+ const DEFAULT_DIMENSIONS_TIMEOUT = 1000;
16
+ const PRE_CONNECT_BUFFER_TIMEOUT = 10_000;
15
17
 
16
18
  export default abstract class LocalTrack<
17
19
  TrackKind extends Track.Kind = Track.Kind,
18
20
  > extends Track<TrackKind> {
19
21
  protected _sender?: RTCRtpSender;
20
22
 
23
+ private autoStopPreConnectBuffer: ReturnType<typeof setTimeout> | undefined;
24
+
21
25
  /** @internal */
22
26
  get sender(): RTCRtpSender | undefined {
23
27
  return this._sender;
@@ -35,6 +39,10 @@ export default abstract class LocalTrack<
35
39
  return this._constraints;
36
40
  }
37
41
 
42
+ get hasPreConnectBuffer() {
43
+ return !!this.localTrackRecorder;
44
+ }
45
+
38
46
  protected _constraints: MediaTrackConstraints;
39
47
 
40
48
  protected reacquireTrack: boolean;
@@ -55,6 +63,8 @@ export default abstract class LocalTrack<
55
63
 
56
64
  protected manuallyStopped: boolean = false;
57
65
 
66
+ protected localTrackRecorder: LocalTrackRecorder<typeof this> | undefined;
67
+
58
68
  private restartLock: Mutex;
59
69
 
60
70
  /**
@@ -203,7 +213,7 @@ export default abstract class LocalTrack<
203
213
  }
204
214
  }
205
215
 
206
- async waitForDimensions(timeout = defaultDimensionsTimeout): Promise<Track.Dimensions> {
216
+ async waitForDimensions(timeout = DEFAULT_DIMENSIONS_TIMEOUT): Promise<Track.Dimensions> {
207
217
  if (this.kind === Track.Kind.Audio) {
208
218
  throw new Error('cannot get dimensions for audio tracks');
209
219
  }
@@ -585,5 +595,40 @@ export default abstract class LocalTrack<
585
595
  this.emit(TrackEvent.TrackProcessorUpdate);
586
596
  }
587
597
 
598
+ /** @internal */
599
+ startPreConnectBuffer(timeslice: number = 100) {
600
+ if (!this.localTrackRecorder) {
601
+ this.localTrackRecorder = new LocalTrackRecorder(this, {
602
+ mimeType: 'audio/webm;codecs=opus',
603
+ });
604
+ } else {
605
+ this.log.warn('preconnect buffer already started');
606
+ return;
607
+ }
608
+
609
+ this.localTrackRecorder.start(timeslice);
610
+ this.autoStopPreConnectBuffer = setTimeout(() => {
611
+ this.log.warn(
612
+ 'preconnect buffer timed out, stopping recording automatically',
613
+ this.logContext,
614
+ );
615
+ this.stopPreConnectBuffer();
616
+ }, PRE_CONNECT_BUFFER_TIMEOUT);
617
+ }
618
+
619
+ /** @internal */
620
+ stopPreConnectBuffer() {
621
+ clearTimeout(this.autoStopPreConnectBuffer);
622
+ if (this.localTrackRecorder) {
623
+ this.localTrackRecorder.stop();
624
+ this.localTrackRecorder = undefined;
625
+ }
626
+ }
627
+
628
+ /** @internal */
629
+ getPreConnectBuffer() {
630
+ return this.localTrackRecorder?.byteStream;
631
+ }
632
+
588
633
  protected abstract monitorSender(): void;
589
634
  }
@@ -12,7 +12,7 @@ import { ScalabilityMode } from '../participant/publishUtils';
12
12
  import type { VideoSenderStats } from '../stats';
13
13
  import { computeBitrate, monitorFrequency } from '../stats';
14
14
  import type { LoggerOptions } from '../types';
15
- import { compareVersions, isFireFox, isMobile, isWeb } from '../utils';
15
+ import { compareVersions, isFireFox, isMobile, isSVCCodec, isWeb } from '../utils';
16
16
  import LocalTrack from './LocalTrack';
17
17
  import { Track, VideoQuality } from './Track';
18
18
  import type { VideoCaptureOptions, VideoCodec } from './options';
@@ -239,7 +239,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
239
239
  );
240
240
  }
241
241
  this.log.debug(`setting publishing quality. max quality ${maxQuality}`, this.logContext);
242
- this.setPublishingLayers(qualities);
242
+ this.setPublishingLayers(isSVCCodec(this.codec), qualities);
243
243
  }
244
244
 
245
245
  async restartTrack(options?: VideoCaptureOptions) {
@@ -334,7 +334,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
334
334
  });
335
335
  // only enable simulcast codec for preference codec setted
336
336
  if (!this.codec && codecs.length > 0) {
337
- await this.setPublishingLayers(codecs[0].qualities);
337
+ await this.setPublishingLayers(isSVCCodec(codecs[0].codec), codecs[0].qualities);
338
338
  return [];
339
339
  }
340
340
 
@@ -343,7 +343,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
343
343
  const newCodecs: VideoCodec[] = [];
344
344
  for await (const codec of codecs) {
345
345
  if (!this.codec || this.codec === codec.codec) {
346
- await this.setPublishingLayers(codec.qualities);
346
+ await this.setPublishingLayers(isSVCCodec(codec.codec), codec.qualities);
347
347
  } else {
348
348
  const simulcastCodecInfo = this.simulcastCodecs.get(codec.codec as VideoCodec);
349
349
  this.log.debug(`try setPublishingCodec for ${codec.codec}`, {
@@ -364,6 +364,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
364
364
  simulcastCodecInfo.encodings!,
365
365
  codec.qualities,
366
366
  this.senderLock,
367
+ isSVCCodec(codec.codec),
367
368
  this.log,
368
369
  this.logContext,
369
370
  );
@@ -377,7 +378,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
377
378
  * @internal
378
379
  * Sets layers that should be publishing
379
380
  */
380
- async setPublishingLayers(qualities: SubscribedQuality[]) {
381
+ async setPublishingLayers(isSvc: boolean, qualities: SubscribedQuality[]) {
381
382
  this.log.debug('setting publishing layers', { ...this.logContext, qualities });
382
383
  if (!this.sender || !this.encodings) {
383
384
  return;
@@ -388,6 +389,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
388
389
  this.encodings,
389
390
  qualities,
390
391
  this.senderLock,
392
+ isSvc,
391
393
  this.log,
392
394
  this.logContext,
393
395
  );
@@ -434,6 +436,7 @@ async function setPublishingLayersForSender(
434
436
  senderEncodings: RTCRtpEncodingParameters[],
435
437
  qualities: SubscribedQuality[],
436
438
  senderLock: Mutex,
439
+ isSVC: boolean,
437
440
  log: StructuredLogger,
438
441
  logContext: Record<string, unknown>,
439
442
  ) {
@@ -498,6 +501,12 @@ async function setPublishingLayersForSender(
498
501
  }
499
502
  }
500
503
  } else {
504
+ if (isSVC) {
505
+ const hasEnabledEncoding = qualities.some((q) => q.enabled);
506
+ if (hasEnabledEncoding) {
507
+ qualities.forEach((q) => (q.enabled = true));
508
+ }
509
+ }
501
510
  // simulcast dynacast encodings
502
511
  encodings.forEach((encoding, idx) => {
503
512
  let rid = encoding.rid ?? '';
@@ -528,4 +528,5 @@ export type TrackEventCallbacks = {
528
528
  trackProcessorUpdate: (processor?: TrackProcessor<Track.Kind, any>) => void;
529
529
  audioTrackFeatureUpdate: (track: any, feature: AudioTrackFeature, enabled: boolean) => void;
530
530
  timeSyncUpdate: (update: { timestamp: number; rtpTimestamp: number }) => void;
531
+ preConnectBufferFlushed: (buffer: Uint8Array[]) => void;
531
532
  };
@@ -119,6 +119,15 @@ export interface TrackPublishDefaults {
119
119
  * defaults to false
120
120
  */
121
121
  stopMicTrackOnMute?: boolean;
122
+
123
+ /**
124
+ * Enables preconnect buffer for a user's microphone track.
125
+ * This is useful for reducing perceived latency when the user starts to speak before the connection is established.
126
+ * Only works for agent use cases.
127
+ *
128
+ * Defaults to false.
129
+ */
130
+ preConnectBuffer?: boolean;
122
131
  }
123
132
 
124
133
  /**
@@ -0,0 +1,51 @@
1
+ import type LocalTrack from './LocalTrack';
2
+
3
+ export class LocalTrackRecorder<T extends LocalTrack> extends MediaRecorder {
4
+ byteStream: ReadableStream<Uint8Array>;
5
+
6
+ constructor(track: T, options?: MediaRecorderOptions) {
7
+ super(new MediaStream([track.mediaStreamTrack]), options);
8
+
9
+ let dataListener: (event: BlobEvent) => void;
10
+
11
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
12
+
13
+ const isClosed = () => streamController === undefined;
14
+
15
+ const onStop = () => {
16
+ this.removeEventListener('dataavailable', dataListener);
17
+ this.removeEventListener('stop', onStop);
18
+ this.removeEventListener('error', onError);
19
+ streamController?.close();
20
+ streamController = undefined;
21
+ };
22
+
23
+ const onError = (event: Event) => {
24
+ streamController?.error(event);
25
+ this.removeEventListener('dataavailable', dataListener);
26
+ this.removeEventListener('stop', onStop);
27
+ this.removeEventListener('error', onError);
28
+ streamController = undefined;
29
+ };
30
+
31
+ this.byteStream = new ReadableStream({
32
+ start: (controller) => {
33
+ streamController = controller;
34
+ dataListener = async (event: BlobEvent) => {
35
+ const arrayBuffer = await event.data.arrayBuffer();
36
+ if (isClosed()) {
37
+ return;
38
+ }
39
+ controller.enqueue(new Uint8Array(arrayBuffer));
40
+ };
41
+ this.addEventListener('dataavailable', dataListener);
42
+ },
43
+ cancel: () => {
44
+ onStop();
45
+ },
46
+ });
47
+
48
+ this.addEventListener('stop', onStop);
49
+ this.addEventListener('error', onError);
50
+ }
51
+ }
package/src/room/utils.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  DisconnectReason,
6
6
  Transcription as TranscriptionModel,
7
7
  } from '@livekit/protocol';
8
- import { getBrowser } from '../utils/browserParser';
8
+ import { type BrowserDetails, getBrowser } from '../utils/browserParser';
9
9
  import { protocolVersion, version } from '../version';
10
10
  import { type ConnectionError, ConnectionErrorReason } from './errors';
11
11
  import type LocalParticipant from './participant/LocalParticipant';
@@ -143,11 +143,24 @@ export function isSafari(): boolean {
143
143
  return getBrowser()?.name === 'Safari';
144
144
  }
145
145
 
146
+ export function isSafariBased(): boolean {
147
+ const b = getBrowser();
148
+ return b?.name === 'Safari' || b?.os === 'iOS';
149
+ }
150
+
146
151
  export function isSafari17(): boolean {
147
152
  const b = getBrowser();
148
153
  return b?.name === 'Safari' && b.version.startsWith('17.');
149
154
  }
150
155
 
156
+ export function isSafariSvcApi(browser?: BrowserDetails): boolean {
157
+ if (!browser) {
158
+ browser = getBrowser();
159
+ }
160
+ // Safari 18.4 requires legacy svc api and scaleResolutionDown to be set
161
+ return browser?.name === 'Safari' && compareVersions(browser.version, '18.3') > 0;
162
+ }
163
+
151
164
  export function isMobile(): boolean {
152
165
  if (!isWeb()) return false;
153
166