livekit-client 2.9.0 → 2.9.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.esm.mjs +393 -75
- 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/connectionHelper/ConnectionCheck.d.ts +2 -0
- package/dist/src/connectionHelper/ConnectionCheck.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/Checker.d.ts +5 -2
- package/dist/src/connectionHelper/checks/Checker.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/cloudRegion.d.ts +17 -0
- package/dist/src/connectionHelper/checks/cloudRegion.d.ts.map +1 -0
- package/dist/src/connectionHelper/checks/connectionProtocol.d.ts +19 -0
- package/dist/src/connectionHelper/checks/connectionProtocol.d.ts.map +1 -0
- package/dist/src/connectionHelper/checks/publishAudio.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/publishVideo.d.ts +1 -0
- package/dist/src/connectionHelper/checks/publishVideo.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/StreamReader.d.ts +4 -4
- package/dist/src/room/StreamReader.d.ts.map +1 -1
- package/dist/src/room/StreamWriter.d.ts +3 -3
- package/dist/src/room/StreamWriter.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/create.d.ts.map +1 -1
- package/dist/src/room/types.d.ts +0 -5
- package/dist/src/room/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +1 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/src/connectionHelper/ConnectionCheck.d.ts +2 -0
- package/dist/ts4.2/src/connectionHelper/checks/Checker.d.ts +5 -2
- package/dist/ts4.2/src/connectionHelper/checks/cloudRegion.d.ts +18 -0
- package/dist/ts4.2/src/connectionHelper/checks/connectionProtocol.d.ts +20 -0
- package/dist/ts4.2/src/connectionHelper/checks/publishVideo.d.ts +1 -0
- package/dist/ts4.2/src/index.d.ts +2 -2
- package/dist/ts4.2/src/room/StreamReader.d.ts +4 -4
- package/dist/ts4.2/src/room/StreamWriter.d.ts +3 -12
- package/dist/ts4.2/src/room/types.d.ts +0 -5
- package/dist/ts4.2/src/room/utils.d.ts +1 -0
- package/package.json +17 -17
- package/src/connectionHelper/ConnectionCheck.ts +15 -0
- package/src/connectionHelper/checks/Checker.ts +41 -8
- package/src/connectionHelper/checks/cloudRegion.ts +94 -0
- package/src/connectionHelper/checks/connectionProtocol.ts +149 -0
- package/src/connectionHelper/checks/publishAudio.ts +8 -0
- package/src/connectionHelper/checks/publishVideo.ts +52 -0
- package/src/index.ts +1 -1
- package/src/room/StreamReader.ts +9 -16
- package/src/room/StreamWriter.ts +4 -4
- package/src/room/participant/LocalParticipant.ts +9 -26
- package/src/room/participant/publishUtils.ts +4 -0
- package/src/room/track/LocalTrack.ts +5 -2
- package/src/room/track/create.ts +9 -5
- package/src/room/types.ts +0 -6
- package/src/room/utils.ts +16 -0
@@ -2,6 +2,8 @@ import { EventEmitter } from 'events';
|
|
2
2
|
import type TypedEmitter from 'typed-emitter';
|
3
3
|
import type { CheckInfo, CheckerOptions, InstantiableCheck } from './checks/Checker';
|
4
4
|
import { CheckStatus, Checker } from './checks/Checker';
|
5
|
+
import { CloudRegionCheck } from './checks/cloudRegion';
|
6
|
+
import { ConnectionProtocolCheck, type ProtocolStats } from './checks/connectionProtocol';
|
5
7
|
import { PublishAudioCheck } from './checks/publishAudio';
|
6
8
|
import { PublishVideoCheck } from './checks/publishVideo';
|
7
9
|
import { ReconnectCheck } from './checks/reconnect';
|
@@ -86,6 +88,19 @@ export class ConnectionCheck extends (EventEmitter as new () => TypedEmitter<Con
|
|
86
88
|
async checkPublishVideo() {
|
87
89
|
return this.createAndRunCheck(PublishVideoCheck);
|
88
90
|
}
|
91
|
+
|
92
|
+
async checkConnectionProtocol() {
|
93
|
+
const info = await this.createAndRunCheck(ConnectionProtocolCheck);
|
94
|
+
if (info.data && 'protocol' in info.data) {
|
95
|
+
const stats = info.data as ProtocolStats;
|
96
|
+
this.options.protocol = stats.protocol;
|
97
|
+
}
|
98
|
+
return info;
|
99
|
+
}
|
100
|
+
|
101
|
+
async checkCloudRegion() {
|
102
|
+
return this.createAndRunCheck(CloudRegionCheck);
|
103
|
+
}
|
89
104
|
}
|
90
105
|
|
91
106
|
type ConnectionCheckCallbacks = {
|
@@ -3,6 +3,9 @@ import type TypedEmitter from 'typed-emitter';
|
|
3
3
|
import type { RoomConnectOptions, RoomOptions } from '../../options';
|
4
4
|
import type RTCEngine from '../../room/RTCEngine';
|
5
5
|
import Room, { ConnectionState } from '../../room/Room';
|
6
|
+
import { RoomEvent } from '../../room/events';
|
7
|
+
import type { SimulationScenario } from '../../room/types';
|
8
|
+
import { sleep } from '../../room/utils';
|
6
9
|
|
7
10
|
type LogMessage = {
|
8
11
|
level: 'info' | 'warning' | 'error';
|
@@ -22,12 +25,14 @@ export type CheckInfo = {
|
|
22
25
|
logs: Array<LogMessage>;
|
23
26
|
status: CheckStatus;
|
24
27
|
description: string;
|
28
|
+
data?: any;
|
25
29
|
};
|
26
30
|
|
27
31
|
export interface CheckerOptions {
|
28
32
|
errorsAsWarnings?: boolean;
|
29
33
|
roomOptions?: RoomOptions;
|
30
34
|
connectOptions?: RoomConnectOptions;
|
35
|
+
protocol?: 'udp' | 'tcp';
|
31
36
|
}
|
32
37
|
|
33
38
|
export abstract class Checker extends (EventEmitter as new () => TypedEmitter<CheckerCallbacks>) {
|
@@ -43,10 +48,10 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
43
48
|
|
44
49
|
logs: Array<LogMessage> = [];
|
45
50
|
|
46
|
-
errorsAsWarnings: boolean = false;
|
47
|
-
|
48
51
|
name: string;
|
49
52
|
|
53
|
+
options: CheckerOptions = {};
|
54
|
+
|
50
55
|
constructor(url: string, token: string, options: CheckerOptions = {}) {
|
51
56
|
super();
|
52
57
|
this.url = url;
|
@@ -54,9 +59,7 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
54
59
|
this.name = this.constructor.name;
|
55
60
|
this.room = new Room(options.roomOptions);
|
56
61
|
this.connectOptions = options.connectOptions;
|
57
|
-
|
58
|
-
this.errorsAsWarnings = options.errorsAsWarnings;
|
59
|
-
}
|
62
|
+
this.options = options;
|
60
63
|
}
|
61
64
|
|
62
65
|
abstract get description(): string;
|
@@ -73,7 +76,7 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
73
76
|
await this.perform();
|
74
77
|
} catch (err) {
|
75
78
|
if (err instanceof Error) {
|
76
|
-
if (this.errorsAsWarnings) {
|
79
|
+
if (this.options.errorsAsWarnings) {
|
77
80
|
this.appendWarning(err.message);
|
78
81
|
} else {
|
79
82
|
this.appendError(err.message);
|
@@ -101,11 +104,14 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
101
104
|
return !this.logs.some((l) => l.level === 'error');
|
102
105
|
}
|
103
106
|
|
104
|
-
protected async connect(): Promise<Room> {
|
107
|
+
protected async connect(url?: string): Promise<Room> {
|
105
108
|
if (this.room.state === ConnectionState.Connected) {
|
106
109
|
return this.room;
|
107
110
|
}
|
108
|
-
|
111
|
+
if (!url) {
|
112
|
+
url = this.url;
|
113
|
+
}
|
114
|
+
await this.room.connect(url, this.token, this.connectOptions);
|
109
115
|
return this.room;
|
110
116
|
}
|
111
117
|
|
@@ -121,6 +127,33 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
121
127
|
this.setStatus(CheckStatus.SKIPPED);
|
122
128
|
}
|
123
129
|
|
130
|
+
protected async switchProtocol(protocol: 'udp' | 'tcp' | 'tls') {
|
131
|
+
let hasReconnecting = false;
|
132
|
+
let hasReconnected = false;
|
133
|
+
this.room.on(RoomEvent.Reconnecting, () => {
|
134
|
+
hasReconnecting = true;
|
135
|
+
});
|
136
|
+
this.room.once(RoomEvent.Reconnected, () => {
|
137
|
+
hasReconnected = true;
|
138
|
+
});
|
139
|
+
this.room.simulateScenario(`force-${protocol}` as SimulationScenario);
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
141
|
+
if (!hasReconnecting) {
|
142
|
+
// no need to wait for reconnection
|
143
|
+
return;
|
144
|
+
}
|
145
|
+
|
146
|
+
// wait for 10 seconds for reconnection
|
147
|
+
const timeout = Date.now() + 10000;
|
148
|
+
while (Date.now() < timeout) {
|
149
|
+
if (hasReconnected) {
|
150
|
+
return;
|
151
|
+
}
|
152
|
+
await sleep(100);
|
153
|
+
}
|
154
|
+
throw new Error(`Could not reconnect using ${protocol} protocol after 10 seconds`);
|
155
|
+
}
|
156
|
+
|
124
157
|
protected appendMessage(message: string) {
|
125
158
|
this.logs.push({ level: 'info', message });
|
126
159
|
this.emit('update', this.getInfo());
|
@@ -0,0 +1,94 @@
|
|
1
|
+
import { RegionUrlProvider } from '../../room/RegionUrlProvider';
|
2
|
+
import { type CheckInfo, Checker } from './Checker';
|
3
|
+
|
4
|
+
export interface RegionStats {
|
5
|
+
region: string;
|
6
|
+
rtt: number;
|
7
|
+
duration: number;
|
8
|
+
}
|
9
|
+
|
10
|
+
/**
|
11
|
+
* Checks for connections quality to closests Cloud regions and determining the best quality
|
12
|
+
*/
|
13
|
+
export class CloudRegionCheck extends Checker {
|
14
|
+
private bestStats?: RegionStats;
|
15
|
+
|
16
|
+
get description(): string {
|
17
|
+
return 'Cloud regions';
|
18
|
+
}
|
19
|
+
|
20
|
+
async perform(): Promise<void> {
|
21
|
+
const regionProvider = new RegionUrlProvider(this.url, this.token);
|
22
|
+
if (!regionProvider.isCloud()) {
|
23
|
+
this.skip();
|
24
|
+
return;
|
25
|
+
}
|
26
|
+
|
27
|
+
const regionStats: RegionStats[] = [];
|
28
|
+
const seenUrls: Set<string> = new Set();
|
29
|
+
for (let i = 0; i < 3; i++) {
|
30
|
+
const regionUrl = await regionProvider.getNextBestRegionUrl();
|
31
|
+
if (!regionUrl) {
|
32
|
+
break;
|
33
|
+
}
|
34
|
+
if (seenUrls.has(regionUrl)) {
|
35
|
+
continue;
|
36
|
+
}
|
37
|
+
seenUrls.add(regionUrl);
|
38
|
+
const stats = await this.checkCloudRegion(regionUrl);
|
39
|
+
this.appendMessage(`${stats.region} RTT: ${stats.rtt}ms, duration: ${stats.duration}ms`);
|
40
|
+
regionStats.push(stats);
|
41
|
+
}
|
42
|
+
|
43
|
+
regionStats.sort((a, b) => {
|
44
|
+
return (a.duration - b.duration) * 0.5 + (a.rtt - b.rtt) * 0.5;
|
45
|
+
});
|
46
|
+
const bestRegion = regionStats[0];
|
47
|
+
this.bestStats = bestRegion;
|
48
|
+
this.appendMessage(`best Cloud region: ${bestRegion.region}`);
|
49
|
+
}
|
50
|
+
|
51
|
+
getInfo(): CheckInfo {
|
52
|
+
const info = super.getInfo();
|
53
|
+
info.data = this.bestStats;
|
54
|
+
return info;
|
55
|
+
}
|
56
|
+
|
57
|
+
private async checkCloudRegion(url: string): Promise<RegionStats> {
|
58
|
+
await this.connect(url);
|
59
|
+
if (this.options.protocol === 'tcp') {
|
60
|
+
await this.switchProtocol('tcp');
|
61
|
+
}
|
62
|
+
const region = this.room.serverInfo?.region;
|
63
|
+
if (!region) {
|
64
|
+
throw new Error('Region not found');
|
65
|
+
}
|
66
|
+
|
67
|
+
const writer = await this.room.localParticipant.streamText({ topic: 'test' });
|
68
|
+
const chunkSize = 1000; // each chunk is about 1000 bytes
|
69
|
+
const totalSize = 1_000_000; // approximately 1MB of data
|
70
|
+
const numChunks = totalSize / chunkSize; // will yield 1000 chunks
|
71
|
+
const chunkData = 'A'.repeat(chunkSize); // create a string of 1000 'A' characters
|
72
|
+
|
73
|
+
const startTime = Date.now();
|
74
|
+
for (let i = 0; i < numChunks; i++) {
|
75
|
+
await writer.write(chunkData);
|
76
|
+
}
|
77
|
+
await writer.close();
|
78
|
+
const endTime = Date.now();
|
79
|
+
const stats = await this.room.engine.pcManager?.publisher.getStats();
|
80
|
+
const regionStats: RegionStats = {
|
81
|
+
region: region,
|
82
|
+
rtt: 10000,
|
83
|
+
duration: endTime - startTime,
|
84
|
+
};
|
85
|
+
stats?.forEach((stat) => {
|
86
|
+
if (stat.type === 'candidate-pair' && stat.nominated) {
|
87
|
+
regionStats.rtt = stat.currentRoundTripTime * 1000;
|
88
|
+
}
|
89
|
+
});
|
90
|
+
|
91
|
+
await this.disconnect();
|
92
|
+
return regionStats;
|
93
|
+
}
|
94
|
+
}
|
@@ -0,0 +1,149 @@
|
|
1
|
+
import { type CheckInfo, Checker } from './Checker';
|
2
|
+
|
3
|
+
export interface ProtocolStats {
|
4
|
+
protocol: 'udp' | 'tcp';
|
5
|
+
packetsLost: number;
|
6
|
+
packetsSent: number;
|
7
|
+
qualityLimitationDurations: Record<string, number>;
|
8
|
+
// total metrics measure sum of all measurements, along with a count
|
9
|
+
rttTotal: number;
|
10
|
+
jitterTotal: number;
|
11
|
+
bitrateTotal: number;
|
12
|
+
count: number;
|
13
|
+
}
|
14
|
+
|
15
|
+
const TEST_DURATION = 10000;
|
16
|
+
|
17
|
+
export class ConnectionProtocolCheck extends Checker {
|
18
|
+
private bestStats?: ProtocolStats;
|
19
|
+
|
20
|
+
get description(): string {
|
21
|
+
return 'Connection via UDP vs TCP';
|
22
|
+
}
|
23
|
+
|
24
|
+
async perform(): Promise<void> {
|
25
|
+
const udpStats = await this.checkConnectionProtocol('udp');
|
26
|
+
const tcpStats = await this.checkConnectionProtocol('tcp');
|
27
|
+
this.bestStats = udpStats;
|
28
|
+
// udp should is the better protocol typically. however, we'd prefer TCP when either of these conditions are true:
|
29
|
+
// 1. the bandwidth limitation is worse on UDP by 500ms
|
30
|
+
// 2. the packet loss is higher on UDP by 1%
|
31
|
+
if (
|
32
|
+
udpStats.qualityLimitationDurations.bandwidth -
|
33
|
+
tcpStats.qualityLimitationDurations.bandwidth >
|
34
|
+
0.5 ||
|
35
|
+
(udpStats.packetsLost - tcpStats.packetsLost) / udpStats.packetsSent > 0.01
|
36
|
+
) {
|
37
|
+
this.appendMessage('best connection quality via tcp');
|
38
|
+
this.bestStats = tcpStats;
|
39
|
+
} else {
|
40
|
+
this.appendMessage('best connection quality via udp');
|
41
|
+
}
|
42
|
+
|
43
|
+
const stats = this.bestStats;
|
44
|
+
this.appendMessage(
|
45
|
+
`upstream bitrate: ${(stats.bitrateTotal / stats.count / 1000 / 1000).toFixed(2)} mbps`,
|
46
|
+
);
|
47
|
+
this.appendMessage(`RTT: ${((stats.rttTotal / stats.count) * 1000).toFixed(2)} ms`);
|
48
|
+
this.appendMessage(`jitter: ${((stats.jitterTotal / stats.count) * 1000).toFixed(2)} ms`);
|
49
|
+
|
50
|
+
if (stats.packetsLost > 0) {
|
51
|
+
this.appendWarning(
|
52
|
+
`packets lost: ${((stats.packetsLost / stats.packetsSent) * 100).toFixed(2)}%`,
|
53
|
+
);
|
54
|
+
}
|
55
|
+
if (stats.qualityLimitationDurations.bandwidth > 1) {
|
56
|
+
this.appendWarning(
|
57
|
+
`bandwidth limited ${((stats.qualityLimitationDurations.bandwidth / (TEST_DURATION / 1000)) * 100).toFixed(2)}%`,
|
58
|
+
);
|
59
|
+
}
|
60
|
+
if (stats.qualityLimitationDurations.cpu > 0) {
|
61
|
+
this.appendWarning(
|
62
|
+
`cpu limited ${((stats.qualityLimitationDurations.cpu / (TEST_DURATION / 1000)) * 100).toFixed(2)}%`,
|
63
|
+
);
|
64
|
+
}
|
65
|
+
}
|
66
|
+
|
67
|
+
getInfo(): CheckInfo {
|
68
|
+
const info = super.getInfo();
|
69
|
+
info.data = this.bestStats;
|
70
|
+
return info;
|
71
|
+
}
|
72
|
+
|
73
|
+
private async checkConnectionProtocol(protocol: 'tcp' | 'udp'): Promise<ProtocolStats> {
|
74
|
+
await this.connect();
|
75
|
+
if (protocol === 'tcp') {
|
76
|
+
await this.switchProtocol('tcp');
|
77
|
+
} else {
|
78
|
+
await this.switchProtocol('udp');
|
79
|
+
}
|
80
|
+
|
81
|
+
// create a canvas with animated content
|
82
|
+
const canvas = document.createElement('canvas');
|
83
|
+
canvas.width = 1280;
|
84
|
+
canvas.height = 720;
|
85
|
+
const ctx = canvas.getContext('2d');
|
86
|
+
if (!ctx) {
|
87
|
+
throw new Error('Could not get canvas context');
|
88
|
+
}
|
89
|
+
|
90
|
+
let hue = 0;
|
91
|
+
const animate = () => {
|
92
|
+
hue = (hue + 1) % 360;
|
93
|
+
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
|
94
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
95
|
+
requestAnimationFrame(animate);
|
96
|
+
};
|
97
|
+
animate();
|
98
|
+
|
99
|
+
// create video track from canvas
|
100
|
+
const stream = canvas.captureStream(30); // 30fps
|
101
|
+
const videoTrack = stream.getVideoTracks()[0];
|
102
|
+
|
103
|
+
// publish to room
|
104
|
+
const pub = await this.room.localParticipant.publishTrack(videoTrack, {
|
105
|
+
simulcast: false,
|
106
|
+
degradationPreference: 'maintain-resolution',
|
107
|
+
videoEncoding: {
|
108
|
+
maxBitrate: 2000000,
|
109
|
+
},
|
110
|
+
});
|
111
|
+
const track = pub!.track!;
|
112
|
+
|
113
|
+
const protocolStats: ProtocolStats = {
|
114
|
+
protocol,
|
115
|
+
packetsLost: 0,
|
116
|
+
packetsSent: 0,
|
117
|
+
qualityLimitationDurations: {},
|
118
|
+
rttTotal: 0,
|
119
|
+
jitterTotal: 0,
|
120
|
+
bitrateTotal: 0,
|
121
|
+
count: 0,
|
122
|
+
};
|
123
|
+
// gather stats once a second
|
124
|
+
const interval = setInterval(async () => {
|
125
|
+
const stats = await track.getRTCStatsReport();
|
126
|
+
stats?.forEach((stat) => {
|
127
|
+
if (stat.type === 'outbound-rtp') {
|
128
|
+
protocolStats.packetsSent = stat.packetsSent;
|
129
|
+
protocolStats.qualityLimitationDurations = stat.qualityLimitationDurations;
|
130
|
+
protocolStats.bitrateTotal += stat.targetBitrate;
|
131
|
+
protocolStats.count++;
|
132
|
+
} else if (stat.type === 'remote-inbound-rtp') {
|
133
|
+
protocolStats.packetsLost = stat.packetsLost;
|
134
|
+
protocolStats.rttTotal += stat.roundTripTime;
|
135
|
+
protocolStats.jitterTotal += stat.jitter;
|
136
|
+
}
|
137
|
+
});
|
138
|
+
}, 1000);
|
139
|
+
|
140
|
+
// wait a bit to gather stats
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, TEST_DURATION));
|
142
|
+
clearInterval(interval);
|
143
|
+
|
144
|
+
videoTrack.stop();
|
145
|
+
canvas.remove();
|
146
|
+
await this.disconnect();
|
147
|
+
return protocolStats;
|
148
|
+
}
|
149
|
+
}
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import { createLocalAudioTrack } from '../../room/track/create';
|
2
|
+
import { detectSilence } from '../../room/track/utils';
|
2
3
|
import { Checker } from './Checker';
|
3
4
|
|
4
5
|
export class PublishAudioCheck extends Checker {
|
@@ -10,6 +11,13 @@ export class PublishAudioCheck extends Checker {
|
|
10
11
|
const room = await this.connect();
|
11
12
|
|
12
13
|
const track = await createLocalAudioTrack();
|
14
|
+
|
15
|
+
const trackIsSilent = await detectSilence(track, 1000);
|
16
|
+
if (trackIsSilent) {
|
17
|
+
throw new Error('unable to detect audio from microphone');
|
18
|
+
}
|
19
|
+
this.appendMessage('detected audio from microphone');
|
20
|
+
|
13
21
|
room.localParticipant.publishTrack(track);
|
14
22
|
// wait for a few seconds to publish
|
15
23
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
@@ -10,6 +10,10 @@ export class PublishVideoCheck extends Checker {
|
|
10
10
|
const room = await this.connect();
|
11
11
|
|
12
12
|
const track = await createLocalVideoTrack();
|
13
|
+
|
14
|
+
// check if we have video from camera
|
15
|
+
await this.checkForVideo(track.mediaStreamTrack);
|
16
|
+
|
13
17
|
room.localParticipant.publishTrack(track);
|
14
18
|
// wait for a few seconds to publish
|
15
19
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
@@ -33,4 +37,52 @@ export class PublishVideoCheck extends Checker {
|
|
33
37
|
}
|
34
38
|
this.appendMessage(`published ${numPackets} video packets`);
|
35
39
|
}
|
40
|
+
|
41
|
+
async checkForVideo(track: MediaStreamTrack) {
|
42
|
+
const stream = new MediaStream();
|
43
|
+
stream.addTrack(track.clone());
|
44
|
+
|
45
|
+
// Create video element to check frames
|
46
|
+
const video = document.createElement('video');
|
47
|
+
video.srcObject = stream;
|
48
|
+
video.muted = true;
|
49
|
+
|
50
|
+
await new Promise<void>((resolve) => {
|
51
|
+
video.onplay = () => {
|
52
|
+
setTimeout(() => {
|
53
|
+
const canvas = document.createElement('canvas');
|
54
|
+
const settings = track.getSettings();
|
55
|
+
const width = settings.width ?? video.videoWidth ?? 1280;
|
56
|
+
const height = settings.height ?? video.videoHeight ?? 720;
|
57
|
+
canvas.width = width;
|
58
|
+
canvas.height = height;
|
59
|
+
const ctx = canvas.getContext('2d')!;
|
60
|
+
|
61
|
+
// Draw video frame to canvas
|
62
|
+
ctx.drawImage(video, 0, 0);
|
63
|
+
|
64
|
+
// Get image data and check if all pixels are black
|
65
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
66
|
+
const data = imageData.data;
|
67
|
+
let isAllBlack = true;
|
68
|
+
for (let i = 0; i < data.length; i += 4) {
|
69
|
+
if (data[i] !== 0 || data[i + 1] !== 0 || data[i + 2] !== 0) {
|
70
|
+
isAllBlack = false;
|
71
|
+
break;
|
72
|
+
}
|
73
|
+
}
|
74
|
+
|
75
|
+
if (isAllBlack) {
|
76
|
+
this.appendError('camera appears to be producing only black frames');
|
77
|
+
} else {
|
78
|
+
this.appendMessage('received video frames');
|
79
|
+
}
|
80
|
+
resolve();
|
81
|
+
}, 1000);
|
82
|
+
};
|
83
|
+
video.play();
|
84
|
+
});
|
85
|
+
|
86
|
+
video.remove();
|
87
|
+
}
|
36
88
|
}
|
package/src/index.ts
CHANGED
@@ -95,6 +95,7 @@ export {
|
|
95
95
|
Room,
|
96
96
|
SubscriptionError,
|
97
97
|
TrackPublication,
|
98
|
+
TrackType,
|
98
99
|
compareVersions,
|
99
100
|
createAudioAnalyser,
|
100
101
|
getBrowser,
|
@@ -127,5 +128,4 @@ export type {
|
|
127
128
|
VideoSenderStats,
|
128
129
|
ReconnectContext,
|
129
130
|
ReconnectPolicy,
|
130
|
-
TrackType,
|
131
131
|
};
|
package/src/room/StreamReader.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import type { DataStream_Chunk } from '@livekit/protocol';
|
2
|
-
import type { BaseStreamInfo, ByteStreamInfo,
|
2
|
+
import type { BaseStreamInfo, ByteStreamInfo, TextStreamInfo } from './types';
|
3
3
|
import { bigIntToNumber } from './utils';
|
4
4
|
|
5
5
|
abstract class BaseStreamReader<T extends BaseStreamInfo> {
|
@@ -59,7 +59,7 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
|
|
59
59
|
}
|
60
60
|
},
|
61
61
|
|
62
|
-
return(): IteratorResult<Uint8Array
|
62
|
+
async return(): Promise<IteratorResult<Uint8Array>> {
|
63
63
|
reader.releaseLock();
|
64
64
|
return { done: true, value: undefined };
|
65
65
|
},
|
@@ -124,7 +124,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
124
124
|
const decoder = new TextDecoder();
|
125
125
|
|
126
126
|
return {
|
127
|
-
next: async (): Promise<IteratorResult<
|
127
|
+
next: async (): Promise<IteratorResult<string>> => {
|
128
128
|
try {
|
129
129
|
const { done, value } = await reader.read();
|
130
130
|
if (done) {
|
@@ -134,14 +134,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
134
134
|
|
135
135
|
return {
|
136
136
|
done: false,
|
137
|
-
value:
|
138
|
-
index: bigIntToNumber(value.chunkIndex),
|
139
|
-
current: decoder.decode(value.content),
|
140
|
-
collected: Array.from(this.receivedChunks.values())
|
141
|
-
.sort((a, b) => bigIntToNumber(a.chunkIndex) - bigIntToNumber(b.chunkIndex))
|
142
|
-
.map((chunk) => decoder.decode(chunk.content))
|
143
|
-
.join(''),
|
144
|
-
},
|
137
|
+
value: decoder.decode(value.content),
|
145
138
|
};
|
146
139
|
}
|
147
140
|
} catch (error) {
|
@@ -150,7 +143,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
150
143
|
}
|
151
144
|
},
|
152
145
|
|
153
|
-
return(): IteratorResult<
|
146
|
+
async return(): Promise<IteratorResult<string>> {
|
154
147
|
reader.releaseLock();
|
155
148
|
return { done: true, value: undefined };
|
156
149
|
},
|
@@ -158,11 +151,11 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
158
151
|
}
|
159
152
|
|
160
153
|
async readAll(): Promise<string> {
|
161
|
-
let
|
162
|
-
for await (const
|
163
|
-
|
154
|
+
let finalString: string = '';
|
155
|
+
for await (const chunk of this) {
|
156
|
+
finalString += chunk;
|
164
157
|
}
|
165
|
-
return
|
158
|
+
return finalString;
|
166
159
|
}
|
167
160
|
}
|
168
161
|
|
package/src/room/StreamWriter.ts
CHANGED
@@ -1,15 +1,15 @@
|
|
1
1
|
import type { BaseStreamInfo, ByteStreamInfo, TextStreamInfo } from './types';
|
2
2
|
|
3
3
|
class BaseStreamWriter<T, InfoType extends BaseStreamInfo> {
|
4
|
-
protected writableStream: WritableStream<
|
4
|
+
protected writableStream: WritableStream<T>;
|
5
5
|
|
6
|
-
protected defaultWriter: WritableStreamDefaultWriter<
|
6
|
+
protected defaultWriter: WritableStreamDefaultWriter<T>;
|
7
7
|
|
8
8
|
protected onClose?: () => void;
|
9
9
|
|
10
10
|
readonly info: InfoType;
|
11
11
|
|
12
|
-
constructor(writableStream: WritableStream<
|
12
|
+
constructor(writableStream: WritableStream<T>, info: InfoType, onClose?: () => void) {
|
13
13
|
this.writableStream = writableStream;
|
14
14
|
this.defaultWriter = writableStream.getWriter();
|
15
15
|
this.onClose = onClose;
|
@@ -17,7 +17,7 @@ class BaseStreamWriter<T, InfoType extends BaseStreamInfo> {
|
|
17
17
|
}
|
18
18
|
|
19
19
|
write(chunk: T): Promise<void> {
|
20
|
-
return this.defaultWriter.write(
|
20
|
+
return this.defaultWriter.write(chunk);
|
21
21
|
}
|
22
22
|
|
23
23
|
async close() {
|
@@ -90,6 +90,7 @@ import {
|
|
90
90
|
isWeb,
|
91
91
|
numberToBigInt,
|
92
92
|
sleep,
|
93
|
+
splitUtf8,
|
93
94
|
supportsAV1,
|
94
95
|
supportsVP9,
|
95
96
|
} from '../utils';
|
@@ -1536,19 +1537,9 @@ export default class LocalParticipant extends Participant {
|
|
1536
1537
|
attachedStreamIds: fileIds,
|
1537
1538
|
});
|
1538
1539
|
|
1539
|
-
|
1540
|
-
|
1541
|
-
|
1542
|
-
for (let i = 0; i < totalTextChunks; i++) {
|
1543
|
-
const chunkData = text.slice(
|
1544
|
-
i * textChunkSize,
|
1545
|
-
Math.min((i + 1) * textChunkSize, totalTextLength),
|
1546
|
-
);
|
1547
|
-
await this.engine.waitForBufferStatusLow(DataPacket_Kind.RELIABLE);
|
1548
|
-
await writer.write(chunkData);
|
1549
|
-
|
1550
|
-
handleProgress(Math.ceil((i + 1) / totalTextChunks), 0);
|
1551
|
-
}
|
1540
|
+
await writer.write(text);
|
1541
|
+
// set text part of progress to 1
|
1542
|
+
handleProgress(1, 0);
|
1552
1543
|
|
1553
1544
|
await writer.close();
|
1554
1545
|
|
@@ -1614,20 +1605,13 @@ export default class LocalParticipant extends Participant {
|
|
1614
1605
|
let chunkId = 0;
|
1615
1606
|
const localP = this;
|
1616
1607
|
|
1617
|
-
const writableStream = new WritableStream<
|
1608
|
+
const writableStream = new WritableStream<string>({
|
1618
1609
|
// Implement the sink
|
1619
|
-
write(
|
1620
|
-
const
|
1621
|
-
|
1622
|
-
if (textInBytes.byteLength > STREAM_CHUNK_SIZE) {
|
1623
|
-
this.abort?.();
|
1624
|
-
throw new Error('chunk size too large');
|
1625
|
-
}
|
1626
|
-
|
1627
|
-
return new Promise(async (resolve) => {
|
1610
|
+
async write(text) {
|
1611
|
+
for (const textChunk in splitUtf8(text, STREAM_CHUNK_SIZE)) {
|
1628
1612
|
await localP.engine.waitForBufferStatusLow(DataPacket_Kind.RELIABLE);
|
1629
1613
|
const chunk = new DataStream_Chunk({
|
1630
|
-
content:
|
1614
|
+
content: new TextEncoder().encode(textChunk),
|
1631
1615
|
streamId,
|
1632
1616
|
chunkIndex: numberToBigInt(chunkId),
|
1633
1617
|
});
|
@@ -1641,8 +1625,7 @@ export default class LocalParticipant extends Participant {
|
|
1641
1625
|
await localP.engine.sendDataPacket(chunkPacket, DataPacket_Kind.RELIABLE);
|
1642
1626
|
|
1643
1627
|
chunkId += 1;
|
1644
|
-
|
1645
|
-
});
|
1628
|
+
}
|
1646
1629
|
},
|
1647
1630
|
async close() {
|
1648
1631
|
const trailer = new DataStream_Trailer({
|
@@ -256,6 +256,10 @@ export function computeTrackBackupEncodings(
|
|
256
256
|
const width = settings.width ?? track.dimensions?.width;
|
257
257
|
const height = settings.height ?? track.dimensions?.height;
|
258
258
|
|
259
|
+
// disable simulcast for screenshare backup codec since L1Tx is used by primary codec
|
260
|
+
if (track.source === Track.Source.ScreenShare && opts.simulcast) {
|
261
|
+
opts.simulcast = false;
|
262
|
+
}
|
259
263
|
const encodings = computeVideoEncodings(
|
260
264
|
track.source === Track.Source.ScreenShare,
|
261
265
|
width,
|
@@ -232,6 +232,7 @@ export default abstract class LocalTrack<
|
|
232
232
|
) {
|
233
233
|
return true;
|
234
234
|
}
|
235
|
+
|
235
236
|
this._constraints.deviceId = deviceId;
|
236
237
|
|
237
238
|
// when track is muted, underlying media stream track is stopped and
|
@@ -313,6 +314,7 @@ export default abstract class LocalTrack<
|
|
313
314
|
if (!constraints) {
|
314
315
|
constraints = this._constraints;
|
315
316
|
}
|
317
|
+
const { deviceId, ...otherConstraints } = this._constraints;
|
316
318
|
this.log.debug('restarting track with constraints', { ...this.logContext, constraints });
|
317
319
|
|
318
320
|
const streamConstraints: MediaStreamConstraints = {
|
@@ -321,9 +323,9 @@ export default abstract class LocalTrack<
|
|
321
323
|
};
|
322
324
|
|
323
325
|
if (this.kind === Track.Kind.Video) {
|
324
|
-
streamConstraints.video =
|
326
|
+
streamConstraints.video = deviceId ? { deviceId } : true;
|
325
327
|
} else {
|
326
|
-
streamConstraints.audio =
|
328
|
+
streamConstraints.audio = deviceId ? { deviceId } : true;
|
327
329
|
}
|
328
330
|
|
329
331
|
// these steps are duplicated from setMediaStreamTrack because we must stop
|
@@ -340,6 +342,7 @@ export default abstract class LocalTrack<
|
|
340
342
|
// create new track and attach
|
341
343
|
const mediaStream = await navigator.mediaDevices.getUserMedia(streamConstraints);
|
342
344
|
const newTrack = mediaStream.getTracks()[0];
|
345
|
+
await newTrack.applyConstraints(otherConstraints);
|
343
346
|
newTrack.addEventListener('ended', this.handleEnded);
|
344
347
|
this.log.debug('re-acquired MediaStreamTrack', this.logContext);
|
345
348
|
|