@voltkit-rtc/client 0.3.0 → 0.4.0
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/CHANGELOG.md +95 -0
- package/README.md +125 -14
- package/dist/cjs/chat.d.ts +2 -0
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +23 -4
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/device.d.ts +85 -0
- package/dist/cjs/device.d.ts.map +1 -0
- package/dist/cjs/device.js +134 -0
- package/dist/cjs/device.js.map +1 -0
- package/dist/cjs/index.d.ts +153 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +506 -36
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/quality.d.ts +35 -6
- package/dist/cjs/quality.d.ts.map +1 -1
- package/dist/cjs/quality.js +51 -19
- package/dist/cjs/quality.js.map +1 -1
- package/dist/cjs/stats.d.ts +151 -0
- package/dist/cjs/stats.d.ts.map +1 -0
- package/dist/cjs/stats.js +243 -0
- package/dist/cjs/stats.js.map +1 -0
- package/dist/esm/chat.d.ts +2 -0
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +23 -4
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/device.d.ts +85 -0
- package/dist/esm/device.d.ts.map +1 -0
- package/dist/esm/device.js +122 -0
- package/dist/esm/device.js.map +1 -0
- package/dist/esm/index.d.ts +153 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +507 -37
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/quality.d.ts +35 -6
- package/dist/esm/quality.d.ts.map +1 -1
- package/dist/esm/quality.js +49 -19
- package/dist/esm/quality.js.map +1 -1
- package/dist/esm/stats.d.ts +151 -0
- package/dist/esm/stats.d.ts.map +1 -0
- package/dist/esm/stats.js +232 -0
- package/dist/esm/stats.js.map +1 -0
- package/package.json +1 -1
- package/src/chat.ts +21 -4
- package/src/device.ts +168 -0
- package/src/index.ts +582 -42
- package/src/quality.ts +54 -19
- package/src/stats.ts +328 -0
package/dist/cjs/index.js
CHANGED
|
@@ -9,8 +9,10 @@ exports.StreamClient = exports.RpcError = exports.VIDEO_QUALITIES = exports.vide
|
|
|
9
9
|
* setup, consumer resume ordering and reconnection, each slightly wrong.
|
|
10
10
|
*/
|
|
11
11
|
const mediasoup_client_1 = require("mediasoup-client");
|
|
12
|
+
const device_js_1 = require("./device.js");
|
|
12
13
|
const music_js_1 = require("./music.js");
|
|
13
14
|
const quality_js_1 = require("./quality.js");
|
|
15
|
+
const stats_js_1 = require("./stats.js");
|
|
14
16
|
var broadcast_js_1 = require("./broadcast.js");
|
|
15
17
|
Object.defineProperty(exports, "BroadcastPublisher", { enumerable: true, get: function () { return broadcast_js_1.BroadcastPublisher; } });
|
|
16
18
|
Object.defineProperty(exports, "WhipError", { enumerable: true, get: function () { return broadcast_js_1.WhipError; } });
|
|
@@ -64,6 +66,14 @@ const CLOSE_REPLACED = 4001;
|
|
|
64
66
|
/** The server ended the session on purpose and said why first: do not reconnect. */
|
|
65
67
|
const CLOSE_ENDED = 4003;
|
|
66
68
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
69
|
+
const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
70
|
+
/**
|
|
71
|
+
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
72
|
+
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
73
|
+
*/
|
|
74
|
+
const VIDEO_START_BITRATE_KBPS = 1500;
|
|
75
|
+
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
76
|
+
const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
|
|
67
77
|
class StreamClient {
|
|
68
78
|
wsUrl;
|
|
69
79
|
ws = null;
|
|
@@ -96,9 +106,31 @@ class StreamClient {
|
|
|
96
106
|
musicProducerId = null;
|
|
97
107
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
98
108
|
remoteSources = new Map();
|
|
109
|
+
/**
|
|
110
|
+
* The layers asked for each remote video, by producerId. A reconnect
|
|
111
|
+
* consumes everything again, and the new consumer would otherwise start at
|
|
112
|
+
* the full picture.
|
|
113
|
+
*/
|
|
114
|
+
preferredLayers = new Map();
|
|
99
115
|
closedByUs = false;
|
|
100
116
|
reconnecting = false;
|
|
101
117
|
reconnectAttempt = 0;
|
|
118
|
+
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
119
|
+
joinedOnce = false;
|
|
120
|
+
/** The camera producer's layers, to tell whether a new track fits them. */
|
|
121
|
+
cameraEncodings = null;
|
|
122
|
+
// Changes to the camera producer run one at a time. Two overlapping swaps
|
|
123
|
+
// (an effect toggled on and off quickly) would otherwise both close the
|
|
124
|
+
// same producer and both produce a new one.
|
|
125
|
+
cameraQueue = Promise.resolve();
|
|
126
|
+
// Bumped by unpublish(). A producer still being made when it runs (camera,
|
|
127
|
+
// microphone or music) belongs to the publish it ended: it is closed, not
|
|
128
|
+
// kept.
|
|
129
|
+
publishGeneration = 0;
|
|
130
|
+
codecFallbackLogged = false;
|
|
131
|
+
sendStatsBaseline = new Map();
|
|
132
|
+
recvStatsBaseline = new Map();
|
|
133
|
+
limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.collectVideoStats(baseline), (event) => this.emit('videoLimited', event));
|
|
102
134
|
constructor(wsUrl, token) {
|
|
103
135
|
this.wsUrl = wsUrl;
|
|
104
136
|
this.token = token;
|
|
@@ -110,7 +142,17 @@ class StreamClient {
|
|
|
110
142
|
this.listeners.set(event, set);
|
|
111
143
|
}
|
|
112
144
|
set.add(fn);
|
|
113
|
-
|
|
145
|
+
// videoLimited costs a getStats every 2 s, so it runs only while heard.
|
|
146
|
+
if (event === 'videoLimited')
|
|
147
|
+
this.syncLimitWatch();
|
|
148
|
+
return () => {
|
|
149
|
+
set.delete(fn);
|
|
150
|
+
if (event === 'videoLimited')
|
|
151
|
+
this.syncLimitWatch();
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
listenerCount(event) {
|
|
155
|
+
return this.listeners.get(event)?.size ?? 0;
|
|
114
156
|
}
|
|
115
157
|
emit(event, ...args) {
|
|
116
158
|
for (const fn of this.listeners.get(event) ?? []) {
|
|
@@ -124,12 +166,36 @@ class StreamClient {
|
|
|
124
166
|
}
|
|
125
167
|
// ------------------------------------------------------------- connection
|
|
126
168
|
async connect() {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
169
|
+
let result;
|
|
170
|
+
try {
|
|
171
|
+
await this.openSocket();
|
|
172
|
+
result = (await this.rpc('join', { token: this.token }));
|
|
173
|
+
this.joinResult = result;
|
|
174
|
+
this.device = this.createDevice();
|
|
175
|
+
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
// A session that never got going: the app has the error. Left open,
|
|
179
|
+
// the socket would be closed by the server's join deadline instead, and
|
|
180
|
+
// that close would start reconnecting a session that never began.
|
|
181
|
+
if (!this.joinedOnce)
|
|
182
|
+
this.detachSocket();
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
186
|
+
// Not when the socket opens -- a join refused with a code worth retrying
|
|
187
|
+
// (no-capacity) never used them up, and every client of a full cluster
|
|
188
|
+
// retried about twice a second, forever.
|
|
189
|
+
this.reconnectAttempt = 0;
|
|
190
|
+
this.joinedOnce = true;
|
|
132
191
|
this.emit('joined', result);
|
|
192
|
+
// A video that ended while this client was away sends it no
|
|
193
|
+
// producerClosed: forget the layers asked for it.
|
|
194
|
+
const present = new Set(result.producers.map((p) => p.producerId));
|
|
195
|
+
for (const producerId of this.preferredLayers.keys()) {
|
|
196
|
+
if (!present.has(producerId))
|
|
197
|
+
this.preferredLayers.delete(producerId);
|
|
198
|
+
}
|
|
133
199
|
// Subscribe to everything already publishing before we announce readiness.
|
|
134
200
|
for (const p of result.producers) {
|
|
135
201
|
await this.consume(p).catch((err) => this.emit('error', err));
|
|
@@ -142,6 +208,10 @@ class StreamClient {
|
|
|
142
208
|
}
|
|
143
209
|
return result;
|
|
144
210
|
}
|
|
211
|
+
/** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
|
|
212
|
+
createDevice() {
|
|
213
|
+
return new mediasoup_client_1.Device();
|
|
214
|
+
}
|
|
145
215
|
openSocket() {
|
|
146
216
|
// Abandon any previous socket first. Its close handler checks identity
|
|
147
217
|
// against `this.ws`, so once it is detached it can no longer trigger a
|
|
@@ -153,7 +223,6 @@ class StreamClient {
|
|
|
153
223
|
const ws = new WebSocket(url.toString());
|
|
154
224
|
this.ws = ws;
|
|
155
225
|
const onOpen = () => {
|
|
156
|
-
this.reconnectAttempt = 0;
|
|
157
226
|
this.emit('connected');
|
|
158
227
|
resolve();
|
|
159
228
|
};
|
|
@@ -179,6 +248,9 @@ class StreamClient {
|
|
|
179
248
|
// displace it, and it would displace us right back, forever.
|
|
180
249
|
this.emit('error', new Error('this identity connected from somewhere else'));
|
|
181
250
|
this.closedByUs = true;
|
|
251
|
+
// The producers outlive this close until the app calls close(); the
|
|
252
|
+
// videoLimited sampling must not, or it reports after `closed`.
|
|
253
|
+
this.limitWatch.reset();
|
|
182
254
|
this.emit('closed', 'replaced');
|
|
183
255
|
return;
|
|
184
256
|
}
|
|
@@ -186,9 +258,14 @@ class StreamClient {
|
|
|
186
258
|
// Normally roomClosed has already ended the session; this covers a
|
|
187
259
|
// close that arrived without it.
|
|
188
260
|
this.closedByUs = true;
|
|
261
|
+
this.limitWatch.reset();
|
|
189
262
|
this.emit('closed', 'room-closed');
|
|
190
263
|
return;
|
|
191
264
|
}
|
|
265
|
+
// Before a first successful join the app is still waiting on
|
|
266
|
+
// connect(), which reports the failure; there is nothing to restore.
|
|
267
|
+
if (!this.joinedOnce)
|
|
268
|
+
return;
|
|
192
269
|
void this.scheduleReconnect();
|
|
193
270
|
});
|
|
194
271
|
});
|
|
@@ -232,8 +309,13 @@ class StreamClient {
|
|
|
232
309
|
return;
|
|
233
310
|
try {
|
|
234
311
|
this.teardownMedia();
|
|
235
|
-
await this.connect();
|
|
236
|
-
|
|
312
|
+
await this.connect(); // which resets the attempt counter once joined
|
|
313
|
+
// The socket can drop again while the session is being restored,
|
|
314
|
+
// and its close handler then finds this loop still running and
|
|
315
|
+
// leaves it to us. Returning would leave the client disconnected
|
|
316
|
+
// for good, with no `closed`.
|
|
317
|
+
if (this.ws)
|
|
318
|
+
return;
|
|
237
319
|
}
|
|
238
320
|
catch (err) {
|
|
239
321
|
this.emit('error', err);
|
|
@@ -259,6 +341,9 @@ class StreamClient {
|
|
|
259
341
|
this.closedByUs = true;
|
|
260
342
|
this.wasPublishing = false;
|
|
261
343
|
this.teardownMedia();
|
|
344
|
+
this.preferredLayers.clear();
|
|
345
|
+
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
346
|
+
this.limitWatch.reset();
|
|
262
347
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
263
348
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
264
349
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -277,6 +362,10 @@ class StreamClient {
|
|
|
277
362
|
p.close();
|
|
278
363
|
this.producers.clear();
|
|
279
364
|
this.musicProducerId = null;
|
|
365
|
+
this.cameraEncodings = null;
|
|
366
|
+
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
367
|
+
// limitation already reported is not reported twice.
|
|
368
|
+
this.syncLimitWatch();
|
|
280
369
|
this.sendTransport?.close();
|
|
281
370
|
this.recvTransport?.close();
|
|
282
371
|
this.sendTransport = null;
|
|
@@ -345,6 +434,7 @@ class StreamClient {
|
|
|
345
434
|
break;
|
|
346
435
|
case 'producerClosed': {
|
|
347
436
|
const { producerId, identity } = data;
|
|
437
|
+
this.preferredLayers.delete(producerId);
|
|
348
438
|
const consumer = this.consumers.get(producerId);
|
|
349
439
|
if (!consumer)
|
|
350
440
|
break; // never consumed it, or already cleaned up
|
|
@@ -524,9 +614,17 @@ class StreamClient {
|
|
|
524
614
|
*
|
|
525
615
|
* `options.quality` picks 1080p, 720p or 480p (see `videoPolicy` for what
|
|
526
616
|
* the project allows). Capture at least that size for the full picture.
|
|
617
|
+
* `options.layers`, `codec` and `degradationPreference` tune how the video
|
|
618
|
+
* is encoded; the defaults suit both phones and computers. The options are
|
|
619
|
+
* kept for a reconnect's republish.
|
|
527
620
|
*/
|
|
528
621
|
async publish(stream, constraints, options) {
|
|
622
|
+
const generation = this.publishGeneration;
|
|
529
623
|
const transport = await this.ensureSendTransport();
|
|
624
|
+
// unpublish() ran first: this publish is over before it began, and must
|
|
625
|
+
// leave nothing behind that a reconnect would publish again.
|
|
626
|
+
if (generation !== this.publishGeneration)
|
|
627
|
+
throw new Error(unpublishedWhile('stream'));
|
|
530
628
|
this.localStream = stream;
|
|
531
629
|
this.wasPublishing = true;
|
|
532
630
|
if (constraints)
|
|
@@ -543,11 +641,16 @@ class StreamClient {
|
|
|
543
641
|
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
544
642
|
appData: { source: 'mic' },
|
|
545
643
|
});
|
|
546
|
-
this.
|
|
644
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
645
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
646
|
+
await this.pauseOnServer(producer);
|
|
547
647
|
}
|
|
548
648
|
const video = stream.getVideoTracks()[0];
|
|
549
|
-
if (video)
|
|
550
|
-
|
|
649
|
+
if (video) {
|
|
650
|
+
// The track is read when the camera's turn comes: one handed to
|
|
651
|
+
// replaceVideoTrack while this publish was under way is the one to send.
|
|
652
|
+
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
653
|
+
}
|
|
551
654
|
}
|
|
552
655
|
/** What this project may publish. Known once joined; 720p before that. */
|
|
553
656
|
get videoPolicy() {
|
|
@@ -565,36 +668,354 @@ class StreamClient {
|
|
|
565
668
|
*/
|
|
566
669
|
async setVideoQuality(quality) {
|
|
567
670
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
671
|
+
await this.cameraTask(async () => {
|
|
672
|
+
const current = this.cameraProducer();
|
|
673
|
+
const track = current?.track;
|
|
674
|
+
if (!current || !track || track.readyState !== 'live')
|
|
675
|
+
return;
|
|
676
|
+
await this.reproduceCamera(current, track);
|
|
677
|
+
});
|
|
678
|
+
return this.videoQuality;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Send a different video track from now on: a canvas with effects drawn on
|
|
682
|
+
* it, the raw camera again, another camera. When the new track needs the
|
|
683
|
+
* same layers as the current one (the same size, or another size the
|
|
684
|
+
* project's quality scales alike), viewers see no pause, and the layers
|
|
685
|
+
* take the new picture's bitrates. Otherwise the camera producer is
|
|
686
|
+
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
687
|
+
* paused.
|
|
688
|
+
*
|
|
689
|
+
* Your own stream is left as it is: the client keeps a stream of its own
|
|
690
|
+
* (`local`, announced by `localStreamChanged`) with the new track and your
|
|
691
|
+
* microphone, and a reconnect republishes that. The track you replaced is
|
|
692
|
+
* not stopped; it is yours again, switched back on if pausing the camera
|
|
693
|
+
* had switched it off. Rejects when no camera is being published.
|
|
694
|
+
*/
|
|
695
|
+
async replaceVideoTrack(track) {
|
|
696
|
+
if (!track || track.kind !== 'video')
|
|
697
|
+
throw new TypeError('replaceVideoTrack needs a video track');
|
|
698
|
+
if (track.readyState === 'ended')
|
|
699
|
+
throw new Error('replaceVideoTrack: that track has ended');
|
|
700
|
+
await this.cameraTask(async () => {
|
|
701
|
+
const current = this.cameraProducer();
|
|
702
|
+
// Between a dropped connection and the republish there is no producer,
|
|
703
|
+
// but the camera is still being published: the republish sends this.
|
|
704
|
+
const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
705
|
+
if (!current && !reconnecting)
|
|
706
|
+
throw new Error('replaceVideoTrack: no camera is being published');
|
|
707
|
+
if (!current) {
|
|
708
|
+
// The republish reads the camera's state off the track it sends, and
|
|
709
|
+
// the track being replaced is switched off exactly when the camera is
|
|
710
|
+
// paused. Hand that state on, or a camera that was off comes back on;
|
|
711
|
+
// and the old track back switched on, as below.
|
|
712
|
+
const replaced = this.localStream?.getVideoTracks()[0];
|
|
713
|
+
if (replaced && replaced !== track) {
|
|
714
|
+
track.enabled = replaced.enabled;
|
|
715
|
+
replaced.enabled = true;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
this.adoptVideoTrack(track);
|
|
719
|
+
if (!current || current.track === track)
|
|
720
|
+
return;
|
|
721
|
+
const previous = current.track;
|
|
722
|
+
const paused = current.paused;
|
|
723
|
+
try {
|
|
724
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
725
|
+
const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount());
|
|
726
|
+
if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next)) {
|
|
727
|
+
await current.replaceTrack({ track });
|
|
728
|
+
await this.applyLayerBitrates(current, next);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
await this.reproduceCamera(current, track);
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
// Pausing switched the old track off (mediasoup-client pauses that
|
|
735
|
+
// way), and nothing switches it back on once it is replaced. It is the
|
|
736
|
+
// app's again, often the camera its canvas draws from: handed back
|
|
737
|
+
// off, that canvas stays black after the camera is resumed.
|
|
738
|
+
if (paused && previous && previous !== this.cameraProducer()?.track)
|
|
739
|
+
previous.enabled = true;
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Give a camera producer whose track was swapped in place the bitrates of
|
|
745
|
+
* its new picture. The layout matched, but a bigger picture at the same
|
|
746
|
+
* scales (a 1280×720 canvas for a 960×540 camera) would otherwise stay
|
|
747
|
+
* capped at the smaller one's. Best effort: a browser that refuses keeps
|
|
748
|
+
* the old caps.
|
|
749
|
+
*/
|
|
750
|
+
async applyLayerBitrates(producer, encodings) {
|
|
751
|
+
const before = this.cameraEncodings;
|
|
752
|
+
if (before && before.every((encoding, i) => encoding.maxBitrate === encodings[i]?.maxBitrate)) {
|
|
753
|
+
this.cameraEncodings = encodings;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const sender = producer.rtpSender;
|
|
757
|
+
if (!sender)
|
|
758
|
+
return;
|
|
759
|
+
try {
|
|
760
|
+
const parameters = sender.getParameters();
|
|
761
|
+
// One entry per layer, smallest first, as they were produced. A browser
|
|
762
|
+
// that reports them some other way is left with its own.
|
|
763
|
+
if (parameters.encodings.length !== encodings.length)
|
|
764
|
+
return;
|
|
765
|
+
parameters.encodings.forEach((encoding, i) => {
|
|
766
|
+
encoding.maxBitrate = encodings[i].maxBitrate;
|
|
767
|
+
});
|
|
768
|
+
await sender.setParameters(parameters);
|
|
769
|
+
this.cameraEncodings = encodings;
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
// Unsupported here; the layers keep the caps they were produced with.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* What your camera video is sending now, layer by layer, or null when no
|
|
777
|
+
* camera is being published. The top-level fields describe the biggest
|
|
778
|
+
* layer going out. `qualityLimitation` says why it is less than asked:
|
|
779
|
+
* `cpu` is this device, `bandwidth` the upload, `none` neither (then check
|
|
780
|
+
* the camera and the light). Bitrates are averaged since the previous call,
|
|
781
|
+
* so call it every second or two; the first call reports 0.
|
|
782
|
+
*/
|
|
783
|
+
async getVideoStats() {
|
|
784
|
+
return this.collectVideoStats(this.sendStatsBaseline);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* What this client receives of a remote video (by its `producerId`, from
|
|
788
|
+
* the `track` event), or null when it is not receiving that video. Bitrates
|
|
789
|
+
* are averaged since the previous call for it; the first call reports 0.
|
|
790
|
+
*/
|
|
791
|
+
async getReceiveStats(producerId) {
|
|
792
|
+
const consumer = this.consumers.get(producerId);
|
|
793
|
+
if (!consumer || consumer.kind !== 'video')
|
|
794
|
+
return null;
|
|
795
|
+
let report;
|
|
796
|
+
try {
|
|
797
|
+
report = await consumer.getStats();
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
if (consumer.closed)
|
|
801
|
+
return null;
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
const live = new Set([...this.consumers.values()].map((c) => c.id));
|
|
805
|
+
(0, stats_js_1.pruneBaseline)(this.recvStatsBaseline, (key) => live.has(key));
|
|
806
|
+
return (0, stats_js_1.parseReceiveStats)(report.values(), { producerId, baseline: this.recvStatsBaseline, key: consumer.id });
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Receive a smaller copy of someone's video (by its `producerId`, from the
|
|
810
|
+
* `track` event), for a tile that does not need the full picture. Spatial
|
|
811
|
+
* layer 0 is the smallest copy the publisher sends: a computer sends three
|
|
812
|
+
* (at 720p 320×180, 640×360 and 1280×720), a phone two (640×360 and
|
|
813
|
+
* 1280×720), and a layer above the biggest means the biggest. Leave
|
|
814
|
+
* `temporalLayer` out to keep the full frame rate. It is the most this
|
|
815
|
+
* client is sent: a connection that cannot carry it still gets less. The
|
|
816
|
+
* choice is kept across reconnects and forgotten when the track ends.
|
|
817
|
+
* Rejects for a producer whose video this client is not receiving.
|
|
818
|
+
*/
|
|
819
|
+
async setPreferredLayers(producerId, spatialLayer, temporalLayer) {
|
|
820
|
+
if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
|
|
821
|
+
throw new RangeError('setPreferredLayers: layers are whole numbers from 0');
|
|
822
|
+
}
|
|
823
|
+
const consumer = this.consumers.get(producerId);
|
|
824
|
+
if (!consumer || consumer.kind !== 'video') {
|
|
825
|
+
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
826
|
+
}
|
|
827
|
+
const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
828
|
+
// Kept first: a reconnect that interrupts the request still applies it.
|
|
829
|
+
this.preferredLayers.set(producerId, layers);
|
|
830
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
831
|
+
}
|
|
832
|
+
async collectVideoStats(baseline) {
|
|
833
|
+
const producer = this.cameraProducer();
|
|
834
|
+
if (!producer)
|
|
835
|
+
return null;
|
|
836
|
+
let report;
|
|
837
|
+
try {
|
|
838
|
+
report = await producer.getStats();
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
if (producer.closed)
|
|
842
|
+
return null;
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
const prefix = `${producer.id}:`;
|
|
846
|
+
(0, stats_js_1.pruneBaseline)(baseline, (key) => key.startsWith(prefix));
|
|
847
|
+
return (0, stats_js_1.parseSendStats)(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
848
|
+
}
|
|
849
|
+
cameraProducer() {
|
|
850
|
+
for (const p of this.producers.values()) {
|
|
851
|
+
if (p.kind === 'video' && p.appData.source === 'camera')
|
|
852
|
+
return p;
|
|
853
|
+
}
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
cameraTask(task) {
|
|
857
|
+
const run = this.cameraQueue.then(task, task);
|
|
858
|
+
this.cameraQueue = run.catch(() => undefined);
|
|
859
|
+
return run;
|
|
860
|
+
}
|
|
861
|
+
/** Swap the video in the client's own stream, never the app's. */
|
|
862
|
+
adoptVideoTrack(track) {
|
|
863
|
+
const previous = this.localStream;
|
|
864
|
+
if (previous && previous.getVideoTracks()[0] === track)
|
|
865
|
+
return;
|
|
866
|
+
const others = previous ? previous.getTracks().filter((t) => t.kind !== 'video') : [];
|
|
867
|
+
this.localStream = new MediaStream([...others, track]);
|
|
868
|
+
this.emit('localStreamChanged', this.localStream);
|
|
869
|
+
}
|
|
870
|
+
/** Close the camera producer and produce `track` in its place, paused on both ends if it was. */
|
|
871
|
+
async reproduceCamera(current, track) {
|
|
872
|
+
const generation = this.publishGeneration;
|
|
572
873
|
const paused = current.paused;
|
|
573
874
|
current.close();
|
|
574
875
|
this.producers.delete(current.id);
|
|
876
|
+
this.cameraEncodings = null;
|
|
877
|
+
this.syncLimitWatch();
|
|
575
878
|
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
return
|
|
879
|
+
return this.produceCamera(await this.ensureSendTransport(), track, generation, paused);
|
|
880
|
+
}
|
|
881
|
+
/** `options.layers` when valid, else 2 on phones and tablets and 3 elsewhere. */
|
|
882
|
+
layerCount() {
|
|
883
|
+
const asked = this.publishOptions.layers;
|
|
884
|
+
return asked === 1 || asked === 2 || asked === 3 ? asked : (0, device_js_1.defaultLayerCount)();
|
|
582
885
|
}
|
|
583
|
-
|
|
886
|
+
/**
|
|
887
|
+
* Produce the camera from `track`, paused on both ends when `paused`. By
|
|
888
|
+
* default the track says: one handed over switched off is a camera that is
|
|
889
|
+
* off, and so is one a pause switched off before a reconnect.
|
|
890
|
+
*/
|
|
891
|
+
async produceCamera(transport, track, generation, paused = !track.enabled) {
|
|
892
|
+
if (generation !== this.publishGeneration)
|
|
893
|
+
throw new Error(unpublishedWhile('camera'));
|
|
894
|
+
// mediasoup-client starts a producer paused exactly when its track is
|
|
895
|
+
// off. Setting the track to the camera's state first makes the producer
|
|
896
|
+
// start in it, and a camera that is off never sends a frame of the new
|
|
897
|
+
// track; the server is told below.
|
|
898
|
+
track.enabled = !paused;
|
|
584
899
|
// Simulcast from day one: it is what lets one node serve far more viewers.
|
|
585
|
-
// The layers follow the picture's real size
|
|
586
|
-
// the browser sends every layer asked for (see
|
|
900
|
+
// The layers follow the picture's real size, the project's quality and
|
|
901
|
+
// the device, so the browser sends every layer asked for (see
|
|
902
|
+
// videoEncodings).
|
|
587
903
|
const { width = 0, height = 0 } = track.getSettings();
|
|
588
|
-
const
|
|
904
|
+
const quality = this.videoQuality;
|
|
905
|
+
const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount());
|
|
906
|
+
let codecs;
|
|
907
|
+
try {
|
|
908
|
+
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
codecs = undefined;
|
|
912
|
+
}
|
|
913
|
+
const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
|
|
914
|
+
const produce = (codec) => transport.produce({
|
|
589
915
|
track,
|
|
590
916
|
stopTracks: false,
|
|
591
|
-
encodings
|
|
592
|
-
codecOptions: { videoGoogleStartBitrate:
|
|
917
|
+
encodings,
|
|
918
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
919
|
+
...(codec ? { codec } : {}),
|
|
593
920
|
appData: { source: 'camera' },
|
|
594
921
|
});
|
|
595
|
-
|
|
922
|
+
let producer;
|
|
923
|
+
try {
|
|
924
|
+
producer = await produce(chosen.capability);
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
// Never fail a publish over the codec: H.264 refused is VP8 instead.
|
|
928
|
+
// A closed transport (a reconnect under way) fails either way.
|
|
929
|
+
if (chosen.codec !== 'h264' || transport.closed)
|
|
930
|
+
throw err;
|
|
931
|
+
this.reportCodecFallback(`H.264 was refused (${err.message}): publishing VP8 instead`);
|
|
932
|
+
producer = await produce((0, device_js_1.findCodec)(codecs, 'vp8'));
|
|
933
|
+
}
|
|
934
|
+
// unpublish() may have run while this was being made (a publish, a
|
|
935
|
+
// quality change or a track swap under way).
|
|
936
|
+
await this.keepProducer(producer, generation, 'camera');
|
|
937
|
+
this.cameraEncodings = encodings;
|
|
938
|
+
await this.pauseOnServer(producer);
|
|
939
|
+
await this.applyDegradationPreference(producer);
|
|
940
|
+
this.syncLimitWatch();
|
|
596
941
|
return producer;
|
|
597
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* Keep a producer just made -- unless unpublish() ran while it was being
|
|
945
|
+
* made. Kept, it would publish again what was just unpublished, so it is
|
|
946
|
+
* closed instead, on both ends.
|
|
947
|
+
*/
|
|
948
|
+
async keepProducer(producer, generation, what) {
|
|
949
|
+
if (generation !== this.publishGeneration) {
|
|
950
|
+
producer.close();
|
|
951
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
952
|
+
throw new Error(unpublishedWhile(what));
|
|
953
|
+
}
|
|
954
|
+
this.producers.set(producer.id, producer);
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* mediasoup-client makes a producer paused when its track is off, and the
|
|
958
|
+
* server makes every producer live. Unless the server is told, viewers get
|
|
959
|
+
* black video or silence from a camera or microphone they are told is on.
|
|
960
|
+
*/
|
|
961
|
+
async pauseOnServer(producer) {
|
|
962
|
+
if (producer.paused)
|
|
963
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* H.264 or VP8 for the camera (see chooseCodec), with the device's own
|
|
967
|
+
* capability entry for it: produce() must only ever get one of those.
|
|
968
|
+
*/
|
|
969
|
+
async cameraCodec(codecs, encodings, width, height, quality) {
|
|
970
|
+
const requested = (0, device_js_1.normalizeCodec)(this.publishOptions.codec);
|
|
971
|
+
const env = (0, device_js_1.currentEnv)();
|
|
972
|
+
const phoneOrTablet = (0, device_js_1.isPhoneOrTablet)(env);
|
|
973
|
+
const h264 = (0, device_js_1.findCodec)(codecs, 'h264');
|
|
974
|
+
let h264Hardware = null;
|
|
975
|
+
if (requested === 'auto' && phoneOrTablet && h264) {
|
|
976
|
+
const top = (0, quality_js_1.topLayerSize)(width, height, quality);
|
|
977
|
+
h264Hardware = await (0, device_js_1.h264HardwareEncode)({ width: top.width, height: top.height, bitrate: encodings[encodings.length - 1].maxBitrate });
|
|
978
|
+
}
|
|
979
|
+
const choice = (0, device_js_1.chooseCodec)({ requested, phoneOrTablet, appleMobile: (0, device_js_1.isAppleMobile)(env), h264Available: Boolean(h264), h264Hardware });
|
|
980
|
+
if (choice.warning)
|
|
981
|
+
this.reportCodecFallback(choice.warning);
|
|
982
|
+
else if (requested === 'auto' && phoneOrTablet && !h264)
|
|
983
|
+
this.reportCodecFallback('this browser cannot send H.264 to this server: publishing VP8');
|
|
984
|
+
if (choice.codec === 'h264' && h264)
|
|
985
|
+
return { codec: 'h264', capability: h264 };
|
|
986
|
+
return { codec: 'vp8', capability: (0, device_js_1.findCodec)(codecs, 'vp8') };
|
|
987
|
+
}
|
|
988
|
+
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
989
|
+
reportCodecFallback(message) {
|
|
990
|
+
if ((0, device_js_1.normalizeCodec)(this.publishOptions.codec) === 'h264') {
|
|
991
|
+
this.emit('error', new Error(message));
|
|
992
|
+
}
|
|
993
|
+
else if (!this.codecFallbackLogged) {
|
|
994
|
+
this.codecFallbackLogged = true;
|
|
995
|
+
console.warn(`[voltkit-rtc] ${message}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/** Best effort: a browser that ignores or refuses it keeps its own default. */
|
|
999
|
+
async applyDegradationPreference(producer) {
|
|
1000
|
+
const preference = this.publishOptions.degradationPreference;
|
|
1001
|
+
const sender = producer.rtpSender;
|
|
1002
|
+
if (!sender)
|
|
1003
|
+
return;
|
|
1004
|
+
if (preference !== 'maintain-resolution' && preference !== 'maintain-framerate' && preference !== 'balanced')
|
|
1005
|
+
return;
|
|
1006
|
+
try {
|
|
1007
|
+
const parameters = sender.getParameters();
|
|
1008
|
+
parameters.degradationPreference = preference;
|
|
1009
|
+
await sender.setParameters(parameters);
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
// Unsupported here; the encoder keeps the browser's own trade-off.
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1016
|
+
syncLimitWatch() {
|
|
1017
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
|
|
1018
|
+
}
|
|
598
1019
|
/**
|
|
599
1020
|
* Re-establish this client's broadcast after a reconnect.
|
|
600
1021
|
*
|
|
@@ -603,10 +1024,15 @@ class StreamClient {
|
|
|
603
1024
|
* when the tracks have genuinely ended.
|
|
604
1025
|
*/
|
|
605
1026
|
async republish() {
|
|
1027
|
+
// unpublish() can run while this is under way, and wins: nothing it
|
|
1028
|
+
// stopped is published again.
|
|
1029
|
+
const generation = this.publishGeneration;
|
|
606
1030
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
607
1031
|
// happens to the camera and microphone below.
|
|
608
1032
|
if (this.music)
|
|
609
|
-
await this.produceMusic(this.music).catch((err) => this.emit('error', err));
|
|
1033
|
+
await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
|
|
1034
|
+
if (generation !== this.publishGeneration)
|
|
1035
|
+
return;
|
|
610
1036
|
let stream = this.localStream;
|
|
611
1037
|
if (!stream && !this.publishConstraints)
|
|
612
1038
|
return; // music was all that was being published
|
|
@@ -616,7 +1042,20 @@ class StreamClient {
|
|
|
616
1042
|
this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
|
|
617
1043
|
return;
|
|
618
1044
|
}
|
|
1045
|
+
const ended = stream;
|
|
619
1046
|
stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
|
|
1047
|
+
if (generation !== this.publishGeneration) {
|
|
1048
|
+
for (const track of stream.getTracks())
|
|
1049
|
+
track.stop();
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
// New devices come back switched on. A camera or microphone the user
|
|
1053
|
+
// had switched off stays off: a dropped connection is no reason to
|
|
1054
|
+
// switch it back on.
|
|
1055
|
+
for (const track of stream.getTracks()) {
|
|
1056
|
+
if (ended?.getTracks().some((t) => t.kind === track.kind && !t.enabled))
|
|
1057
|
+
track.enabled = false;
|
|
1058
|
+
}
|
|
620
1059
|
this.localStream = stream;
|
|
621
1060
|
this.emit('localStreamChanged', stream);
|
|
622
1061
|
}
|
|
@@ -636,11 +1075,17 @@ class StreamClient {
|
|
|
636
1075
|
* the user has not interacted with.
|
|
637
1076
|
*/
|
|
638
1077
|
async publishMusic(input, options) {
|
|
1078
|
+
const generation = this.publishGeneration;
|
|
639
1079
|
if (this.music)
|
|
640
1080
|
await this.stopMusic();
|
|
641
1081
|
const music = new music_js_1.MusicSource(input, options);
|
|
642
1082
|
try {
|
|
643
|
-
await this.produceMusic(music);
|
|
1083
|
+
await this.produceMusic(music, generation);
|
|
1084
|
+
// Checked again here: an unpublish() after the producer was kept has
|
|
1085
|
+
// already closed it, and the music must not be remembered for a
|
|
1086
|
+
// reconnect to publish again.
|
|
1087
|
+
if (generation !== this.publishGeneration)
|
|
1088
|
+
throw new Error(unpublishedWhile('music'));
|
|
644
1089
|
}
|
|
645
1090
|
catch (err) {
|
|
646
1091
|
music.close();
|
|
@@ -650,8 +1095,10 @@ class StreamClient {
|
|
|
650
1095
|
this.wasPublishing = true;
|
|
651
1096
|
this.emitLocalMedia();
|
|
652
1097
|
}
|
|
653
|
-
async produceMusic(music) {
|
|
1098
|
+
async produceMusic(music, generation) {
|
|
654
1099
|
const transport = await this.ensureSendTransport();
|
|
1100
|
+
if (generation !== this.publishGeneration)
|
|
1101
|
+
throw new Error(unpublishedWhile('music'));
|
|
655
1102
|
const producer = await transport.produce({
|
|
656
1103
|
track: music.track,
|
|
657
1104
|
stopTracks: false,
|
|
@@ -660,8 +1107,10 @@ class StreamClient {
|
|
|
660
1107
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
661
1108
|
appData: { source: 'music' },
|
|
662
1109
|
});
|
|
663
|
-
this.
|
|
1110
|
+
await this.keepProducer(producer, generation, 'music');
|
|
664
1111
|
this.musicProducerId = producer.id;
|
|
1112
|
+
// Music switched off before a reconnect comes back paused here.
|
|
1113
|
+
await this.pauseOnServer(producer);
|
|
665
1114
|
}
|
|
666
1115
|
/** True while music is published, whether or not it is switched on. */
|
|
667
1116
|
get musicPublishing() {
|
|
@@ -724,6 +1173,13 @@ class StreamClient {
|
|
|
724
1173
|
}));
|
|
725
1174
|
const consumer = await transport.consume(params);
|
|
726
1175
|
this.consumers.set(remote.producerId, consumer);
|
|
1176
|
+
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1177
|
+
// its first frame instead of starting at the full picture. Not getting
|
|
1178
|
+
// them costs data, not the track, so a refusal is only reported.
|
|
1179
|
+
const layers = this.preferredLayers.get(remote.producerId);
|
|
1180
|
+
if (layers) {
|
|
1181
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1182
|
+
}
|
|
727
1183
|
// The server created it paused. Resume only now that our end exists,
|
|
728
1184
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
729
1185
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
@@ -783,21 +1239,35 @@ class StreamClient {
|
|
|
783
1239
|
}
|
|
784
1240
|
/** Stop publishing entirely and release the local devices. */
|
|
785
1241
|
async unpublish() {
|
|
1242
|
+
// All of it takes effect now; only telling the server is waited for. So
|
|
1243
|
+
// publish() and unpublish() act in the order they are called: a publish
|
|
1244
|
+
// called while this still waits is not undone by it, and one already
|
|
1245
|
+
// under way stops at its next step (keepProducer).
|
|
1246
|
+
this.publishGeneration += 1;
|
|
786
1247
|
const ids = [...this.producers.keys()];
|
|
787
1248
|
for (const producer of this.producers.values())
|
|
788
1249
|
producer.close();
|
|
789
1250
|
this.producers.clear();
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1251
|
+
this.cameraEncodings = null;
|
|
1252
|
+
// Start over for the next publish, and clear a limitation still showing:
|
|
1253
|
+
// nothing is being held back once nothing is being sent.
|
|
1254
|
+
const cleared = this.limitWatch.reset();
|
|
1255
|
+
if (cleared)
|
|
1256
|
+
this.emit('videoLimited', cleared);
|
|
793
1257
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
794
1258
|
this.localStream = null;
|
|
1259
|
+
// Forgotten with the stream: kept, a reconnect would ask for the camera
|
|
1260
|
+
// and microphone again and publish them, after the app unpublished them.
|
|
1261
|
+
this.publishConstraints = null;
|
|
795
1262
|
this.music?.close();
|
|
796
1263
|
this.music = null;
|
|
797
1264
|
this.musicProducerId = null;
|
|
798
1265
|
this.wasPublishing = false;
|
|
799
1266
|
this.emit('localStreamChanged', null);
|
|
800
1267
|
this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
|
|
1268
|
+
for (const id of ids) {
|
|
1269
|
+
await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
|
|
1270
|
+
}
|
|
801
1271
|
}
|
|
802
1272
|
get local() {
|
|
803
1273
|
return this.localStream;
|