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.
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +1 -0
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +383 -117
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +1 -1
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +6 -2
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/defaults.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts +2 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +5 -1
- package/dist/src/room/events.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +9 -0
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/Participant.d.ts +1 -1
- package/dist/src/room/participant/Participant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts +10 -0
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts +1 -0
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +8 -0
- package/dist/src/room/track/options.d.ts.map +1 -1
- package/dist/src/room/track/record.d.ts +6 -0
- package/dist/src/room/track/record.d.ts.map +1 -0
- package/dist/src/room/utils.d.ts +3 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/src/index.d.ts +1 -0
- package/dist/ts4.2/src/room/RTCEngine.d.ts +1 -1
- package/dist/ts4.2/src/room/Room.d.ts +5 -1
- package/dist/ts4.2/src/room/errors.d.ts +2 -1
- package/dist/ts4.2/src/room/events.d.ts +5 -1
- package/dist/ts4.2/src/room/participant/LocalParticipant.d.ts +9 -0
- package/dist/ts4.2/src/room/participant/Participant.d.ts +1 -1
- package/dist/ts4.2/src/room/track/LocalTrack.d.ts +10 -0
- package/dist/ts4.2/src/room/track/LocalVideoTrack.d.ts +1 -1
- package/dist/ts4.2/src/room/track/Track.d.ts +1 -0
- package/dist/ts4.2/src/room/track/options.d.ts +8 -0
- package/dist/ts4.2/src/room/track/record.d.ts +6 -0
- package/dist/ts4.2/src/room/utils.d.ts +3 -0
- package/package.json +13 -12
- package/src/e2ee/worker/tsconfig.json +9 -1
- package/src/index.ts +2 -0
- package/src/room/RTCEngine.ts +7 -7
- package/src/room/Room.ts +23 -8
- package/src/room/defaults.ts +1 -0
- package/src/room/errors.ts +1 -0
- package/src/room/events.ts +5 -0
- package/src/room/participant/LocalParticipant.ts +215 -34
- package/src/room/participant/Participant.ts +1 -1
- package/src/room/participant/publishUtils.ts +4 -0
- package/src/room/track/LocalTrack.ts +47 -2
- package/src/room/track/LocalVideoTrack.ts +14 -5
- package/src/room/track/Track.ts +1 -0
- package/src/room/track/options.ts +9 -0
- package/src/room/track/record.ts +51 -0
- 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
|
-
|
852
|
-
|
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.
|
869
|
-
|
870
|
-
|
871
|
-
|
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
|
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([
|
1234
|
+
const rets = await Promise.all([addTrackPromise, negotiate()]);
|
1147
1235
|
ti = rets[0];
|
1148
1236
|
} else {
|
1149
|
-
ti = await
|
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 (
|
2158
|
-
|
2159
|
-
|
2160
|
-
|
2161
|
-
|
2162
|
-
|
2163
|
-
|
2164
|
-
this.
|
2165
|
-
|
2166
|
-
|
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
|
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 =
|
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 ?? '';
|
package/src/room/track/Track.ts
CHANGED
@@ -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
|
|