@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +125 -14
  3. package/dist/cjs/chat.d.ts +2 -0
  4. package/dist/cjs/chat.d.ts.map +1 -1
  5. package/dist/cjs/chat.js +23 -4
  6. package/dist/cjs/chat.js.map +1 -1
  7. package/dist/cjs/device.d.ts +85 -0
  8. package/dist/cjs/device.d.ts.map +1 -0
  9. package/dist/cjs/device.js +134 -0
  10. package/dist/cjs/device.js.map +1 -0
  11. package/dist/cjs/index.d.ts +153 -2
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js +506 -36
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/quality.d.ts +35 -6
  16. package/dist/cjs/quality.d.ts.map +1 -1
  17. package/dist/cjs/quality.js +51 -19
  18. package/dist/cjs/quality.js.map +1 -1
  19. package/dist/cjs/stats.d.ts +151 -0
  20. package/dist/cjs/stats.d.ts.map +1 -0
  21. package/dist/cjs/stats.js +243 -0
  22. package/dist/cjs/stats.js.map +1 -0
  23. package/dist/esm/chat.d.ts +2 -0
  24. package/dist/esm/chat.d.ts.map +1 -1
  25. package/dist/esm/chat.js +23 -4
  26. package/dist/esm/chat.js.map +1 -1
  27. package/dist/esm/device.d.ts +85 -0
  28. package/dist/esm/device.d.ts.map +1 -0
  29. package/dist/esm/device.js +122 -0
  30. package/dist/esm/device.js.map +1 -0
  31. package/dist/esm/index.d.ts +153 -2
  32. package/dist/esm/index.d.ts.map +1 -1
  33. package/dist/esm/index.js +507 -37
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/quality.d.ts +35 -6
  36. package/dist/esm/quality.d.ts.map +1 -1
  37. package/dist/esm/quality.js +49 -19
  38. package/dist/esm/quality.js.map +1 -1
  39. package/dist/esm/stats.d.ts +151 -0
  40. package/dist/esm/stats.d.ts.map +1 -0
  41. package/dist/esm/stats.js +232 -0
  42. package/dist/esm/stats.js.map +1 -0
  43. package/package.json +1 -1
  44. package/src/chat.ts +21 -4
  45. package/src/device.ts +168 -0
  46. package/src/index.ts +582 -42
  47. package/src/quality.ts +54 -19
  48. package/src/stats.ts +328 -0
package/src/index.ts CHANGED
@@ -13,12 +13,44 @@ import { Device, type types as ms } from 'mediasoup-client';
13
13
  type Consumer = ms.Consumer;
14
14
  type Producer = ms.Producer;
15
15
  type RtpCapabilities = ms.RtpCapabilities;
16
+ type RtpCodecCapability = ms.RtpCodecCapability;
16
17
  type Transport = ms.Transport;
17
18
  type TransportOptions = ms.TransportOptions;
18
19
 
19
20
  import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
21
+ import {
22
+ chooseCodec,
23
+ currentEnv,
24
+ defaultLayerCount,
25
+ findCodec,
26
+ h264HardwareEncode,
27
+ isAppleMobile,
28
+ isPhoneOrTablet,
29
+ normalizeCodec,
30
+ type CodecPreference,
31
+ } from './device.js';
20
32
  import { MusicSource, type MusicInput, type MusicOptions, type TrackSource } from './music.js';
21
- import { allowedQuality, DEFAULT_VIDEO_POLICY, videoEncodings, type VideoPolicy, type VideoQuality } from './quality.js';
33
+ import {
34
+ allowedQuality,
35
+ DEFAULT_VIDEO_POLICY,
36
+ sameLayout,
37
+ topLayerSize,
38
+ videoEncodings,
39
+ type LayerCount,
40
+ type VideoEncoding,
41
+ type VideoPolicy,
42
+ type VideoQuality,
43
+ } from './quality.js';
44
+ import {
45
+ parseReceiveStats,
46
+ parseSendStats,
47
+ pruneBaseline,
48
+ VideoLimitWatch,
49
+ type StatsBaseline,
50
+ type VideoLimitedEvent,
51
+ type VideoReceiveStats,
52
+ type VideoSendStats,
53
+ } from './stats.js';
22
54
 
23
55
  export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
24
56
  export { AudioMixer, MusicSource } from './music.js';
@@ -26,7 +58,9 @@ export type { MusicInput, MusicOptions, TrackSource } from './music.js';
26
58
  export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
27
59
  export { ChatClient, ChatRpcError } from './chat.js';
28
60
  export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
29
- export type { VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
61
+ export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
62
+ export type { CodecPreference } from './device.js';
63
+ export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
30
64
  export type {
31
65
  ChatClientEvents,
32
66
  ChatEvents,
@@ -76,6 +110,34 @@ export interface PublishOptions {
76
110
  * higher, and to the camera's own size if that is smaller.
77
111
  */
78
112
  quality?: VideoQuality;
113
+ /**
114
+ * The most simulcast layers to send: 1, 2 or 3. Defaults to 2 on phones and
115
+ * tablets and 3 elsewhere. Each layer is a separate encode. When a phone is
116
+ * too busy, the browser shrinks the picture and then sends fewer layers,
117
+ * dropping the top one: from three, viewers were left with a quarter of the
118
+ * picture; from two, the full picture survives every step down to 480×270.
119
+ * Never more than the picture allows: 3 from 960×540 up, 2 from 480×270,
120
+ * else 1. Two layers are half and full size. One is full size only, and
121
+ * every viewer then gets the same stream, whatever their connection.
122
+ */
123
+ layers?: LayerCount;
124
+ /**
125
+ * The video codec. `'auto'` (the default) sends H.264 on phones and tablets
126
+ * that encode it in hardware and VP8 everywhere else. `'h264'` and `'vp8'`
127
+ * force one; a forced H.264 the browser cannot send falls back to VP8 and
128
+ * fires `error`. A viewer whose browser cannot decode the codec cannot
129
+ * watch, so force VP8 if your audience uses such browsers.
130
+ */
131
+ codec?: CodecPreference;
132
+ /**
133
+ * What the encoder gives up first when the device cannot keep up:
134
+ * `'maintain-resolution'` lowers the frame rate, `'maintain-framerate'` the
135
+ * picture size, `'balanced'` some of each. Unset leaves the browser's
136
+ * default. Chrome also takes it from the track's `contentHint`: `'detail'`
137
+ * keeps resolution, `'motion'` keeps frame rate. Browsers that do not
138
+ * support it ignore it.
139
+ */
140
+ degradationPreference?: 'maintain-resolution' | 'maintain-framerate' | 'balanced';
79
141
  }
80
142
 
81
143
  export interface SlotClaim {
@@ -148,6 +210,15 @@ export interface ClientEvents extends ChatEvents {
148
210
  * account is topped up first -- tell the user, and stop asking them to wait.
149
211
  */
150
212
  usageLimit: (e: UsageLimitNotice) => void;
213
+ /**
214
+ * Your camera video is being held back by this device (`cpu`: too busy to
215
+ * encode it), by the upload (`bandwidth`), or by something else (`other`);
216
+ * or that has cleared (`none`). Fires when the reason changes and has held
217
+ * for about 4 s, so brief spikes stay quiet; never `none` first. Tell the
218
+ * publisher: "your device is busy" or "your connection is slow". Checked
219
+ * every 2 s, only while publishing a camera and someone listens.
220
+ */
221
+ videoLimited: (e: VideoLimitedEvent) => void;
151
222
  }
152
223
 
153
224
  type Handler = (...args: never[]) => void;
@@ -195,6 +266,23 @@ const CLOSE_ENDED = 4003;
195
266
 
196
267
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
197
268
 
269
+ /** What setPreferredLayers sends; no temporal layer means the full frame rate. */
270
+ interface PreferredLayers {
271
+ spatialLayer: number;
272
+ temporalLayer?: number;
273
+ }
274
+
275
+ const isLayer = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;
276
+
277
+ /**
278
+ * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
279
+ * otherwise starts low and takes many seconds to climb to the top layer.
280
+ */
281
+ const VIDEO_START_BITRATE_KBPS = 1500;
282
+
283
+ /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
284
+ const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
285
+
198
286
  export class StreamClient {
199
287
  private ws: WebSocket | null = null;
200
288
  private device: Device | null = null;
@@ -226,9 +314,34 @@ export class StreamClient {
226
314
  private musicProducerId: string | null = null;
227
315
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
228
316
  private readonly remoteSources = new Map<string, TrackSource>();
317
+ /**
318
+ * The layers asked for each remote video, by producerId. A reconnect
319
+ * consumes everything again, and the new consumer would otherwise start at
320
+ * the full picture.
321
+ */
322
+ private readonly preferredLayers = new Map<string, PreferredLayers>();
229
323
  private closedByUs = false;
230
324
  private reconnecting = false;
231
325
  private reconnectAttempt = 0;
326
+ /** Set once a connect() has joined and set up. Only such a session is reconnected. */
327
+ private joinedOnce = false;
328
+ /** The camera producer's layers, to tell whether a new track fits them. */
329
+ private cameraEncodings: VideoEncoding[] | null = null;
330
+ // Changes to the camera producer run one at a time. Two overlapping swaps
331
+ // (an effect toggled on and off quickly) would otherwise both close the
332
+ // same producer and both produce a new one.
333
+ private cameraQueue: Promise<unknown> = Promise.resolve();
334
+ // Bumped by unpublish(). A producer still being made when it runs (camera,
335
+ // microphone or music) belongs to the publish it ended: it is closed, not
336
+ // kept.
337
+ private publishGeneration = 0;
338
+ private codecFallbackLogged = false;
339
+ private readonly sendStatsBaseline: StatsBaseline = new Map();
340
+ private readonly recvStatsBaseline: StatsBaseline = new Map();
341
+ private readonly limitWatch = new VideoLimitWatch(
342
+ (baseline) => this.collectVideoStats(baseline),
343
+ (event) => this.emit('videoLimited', event),
344
+ );
232
345
 
233
346
  constructor(private readonly wsUrl: string, token: string) {
234
347
  this.token = token;
@@ -241,7 +354,16 @@ export class StreamClient {
241
354
  this.listeners.set(event, set);
242
355
  }
243
356
  set.add(fn as Handler);
244
- return () => set!.delete(fn as Handler);
357
+ // videoLimited costs a getStats every 2 s, so it runs only while heard.
358
+ if (event === 'videoLimited') this.syncLimitWatch();
359
+ return () => {
360
+ set!.delete(fn as Handler);
361
+ if (event === 'videoLimited') this.syncLimitWatch();
362
+ };
363
+ }
364
+
365
+ private listenerCount(event: keyof ClientEvents): number {
366
+ return this.listeners.get(event)?.size ?? 0;
245
367
  }
246
368
 
247
369
  private emit<K extends keyof ClientEvents>(event: K, ...args: Parameters<ClientEvents[K]>): void {
@@ -257,15 +379,37 @@ export class StreamClient {
257
379
  // ------------------------------------------------------------- connection
258
380
 
259
381
  async connect(): Promise<JoinResult> {
260
- await this.openSocket();
261
- const result = (await this.rpc('join', { token: this.token })) as JoinResult;
262
- this.joinResult = result;
382
+ let result: JoinResult;
383
+ try {
384
+ await this.openSocket();
385
+ result = (await this.rpc('join', { token: this.token })) as JoinResult;
386
+ this.joinResult = result;
263
387
 
264
- this.device = new Device();
265
- await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
388
+ this.device = this.createDevice();
389
+ await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
390
+ } catch (err) {
391
+ // A session that never got going: the app has the error. Left open,
392
+ // the socket would be closed by the server's join deadline instead, and
393
+ // that close would start reconnecting a session that never began.
394
+ if (!this.joinedOnce) this.detachSocket();
395
+ throw err;
396
+ }
397
+ // Joined and set up: the next drop gets every reconnect attempt again.
398
+ // Not when the socket opens -- a join refused with a code worth retrying
399
+ // (no-capacity) never used them up, and every client of a full cluster
400
+ // retried about twice a second, forever.
401
+ this.reconnectAttempt = 0;
402
+ this.joinedOnce = true;
266
403
 
267
404
  this.emit('joined', result);
268
405
 
406
+ // A video that ended while this client was away sends it no
407
+ // producerClosed: forget the layers asked for it.
408
+ const present = new Set(result.producers.map((p) => p.producerId));
409
+ for (const producerId of this.preferredLayers.keys()) {
410
+ if (!present.has(producerId)) this.preferredLayers.delete(producerId);
411
+ }
412
+
269
413
  // Subscribe to everything already publishing before we announce readiness.
270
414
  for (const p of result.producers) {
271
415
  await this.consume(p).catch((err) => this.emit('error', err as Error));
@@ -280,6 +424,11 @@ export class StreamClient {
280
424
  return result;
281
425
  }
282
426
 
427
+ /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
428
+ private createDevice(): Device {
429
+ return new Device();
430
+ }
431
+
283
432
  private openSocket(): Promise<void> {
284
433
  // Abandon any previous socket first. Its close handler checks identity
285
434
  // against `this.ws`, so once it is detached it can no longer trigger a
@@ -293,7 +442,6 @@ export class StreamClient {
293
442
  this.ws = ws;
294
443
 
295
444
  const onOpen = () => {
296
- this.reconnectAttempt = 0;
297
445
  this.emit('connected');
298
446
  resolve();
299
447
  };
@@ -318,6 +466,9 @@ export class StreamClient {
318
466
  // displace it, and it would displace us right back, forever.
319
467
  this.emit('error', new Error('this identity connected from somewhere else'));
320
468
  this.closedByUs = true;
469
+ // The producers outlive this close until the app calls close(); the
470
+ // videoLimited sampling must not, or it reports after `closed`.
471
+ this.limitWatch.reset();
321
472
  this.emit('closed', 'replaced');
322
473
  return;
323
474
  }
@@ -325,9 +476,13 @@ export class StreamClient {
325
476
  // Normally roomClosed has already ended the session; this covers a
326
477
  // close that arrived without it.
327
478
  this.closedByUs = true;
479
+ this.limitWatch.reset();
328
480
  this.emit('closed', 'room-closed');
329
481
  return;
330
482
  }
483
+ // Before a first successful join the app is still waiting on
484
+ // connect(), which reports the failure; there is nothing to restore.
485
+ if (!this.joinedOnce) return;
331
486
  void this.scheduleReconnect();
332
487
  });
333
488
  });
@@ -370,8 +525,12 @@ export class StreamClient {
370
525
  if (this.closedByUs) return;
371
526
  try {
372
527
  this.teardownMedia();
373
- await this.connect();
374
- return; // connect() resets the attempt counter once the socket opens
528
+ await this.connect(); // which resets the attempt counter once joined
529
+ // The socket can drop again while the session is being restored,
530
+ // and its close handler then finds this loop still running and
531
+ // leaves it to us. Returning would leave the client disconnected
532
+ // for good, with no `closed`.
533
+ if (this.ws) return;
375
534
  } catch (err) {
376
535
  this.emit('error', err as Error);
377
536
  // Stop on failures a retry cannot resolve. Hammering a room that has
@@ -396,6 +555,9 @@ export class StreamClient {
396
555
  this.closedByUs = true;
397
556
  this.wasPublishing = false;
398
557
  this.teardownMedia();
558
+ this.preferredLayers.clear();
559
+ // Nothing is emitted after closing, so not even a clearing videoLimited.
560
+ this.limitWatch.reset();
399
561
  // Producers no longer stop their tracks, so releasing the camera and
400
562
  // microphone is ours to do -- otherwise the device light stays on.
401
563
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -412,6 +574,10 @@ export class StreamClient {
412
574
  for (const p of this.producers.values()) p.close();
413
575
  this.producers.clear();
414
576
  this.musicProducerId = null;
577
+ this.cameraEncodings = null;
578
+ // Paused, not reset: a reconnect's republish starts it again, and a
579
+ // limitation already reported is not reported twice.
580
+ this.syncLimitWatch();
415
581
  this.sendTransport?.close();
416
582
  this.recvTransport?.close();
417
583
  this.sendTransport = null;
@@ -484,6 +650,7 @@ export class StreamClient {
484
650
  break;
485
651
  case 'producerClosed': {
486
652
  const { producerId, identity } = data as { producerId: string; identity: string };
653
+ this.preferredLayers.delete(producerId);
487
654
  const consumer = this.consumers.get(producerId);
488
655
  if (!consumer) break; // never consumed it, or already cleaned up
489
656
  consumer.close();
@@ -664,9 +831,16 @@ export class StreamClient {
664
831
  *
665
832
  * `options.quality` picks 1080p, 720p or 480p (see `videoPolicy` for what
666
833
  * the project allows). Capture at least that size for the full picture.
834
+ * `options.layers`, `codec` and `degradationPreference` tune how the video
835
+ * is encoded; the defaults suit both phones and computers. The options are
836
+ * kept for a reconnect's republish.
667
837
  */
668
838
  async publish(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
839
+ const generation = this.publishGeneration;
669
840
  const transport = await this.ensureSendTransport();
841
+ // unpublish() ran first: this publish is over before it began, and must
842
+ // leave nothing behind that a reconnect would publish again.
843
+ if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('stream'));
670
844
  this.localStream = stream;
671
845
  this.wasPublishing = true;
672
846
  if (constraints) this.publishConstraints = constraints;
@@ -682,11 +856,17 @@ export class StreamClient {
682
856
  codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
683
857
  appData: { source: 'mic' },
684
858
  });
685
- this.producers.set(producer.id, producer);
859
+ await this.keepProducer(producer, generation, 'microphone');
860
+ // A microphone handed over switched off, or muted before a reconnect.
861
+ await this.pauseOnServer(producer);
686
862
  }
687
863
 
688
864
  const video = stream.getVideoTracks()[0];
689
- if (video) await this.produceCamera(transport, video);
865
+ if (video) {
866
+ // The track is read when the camera's turn comes: one handed to
867
+ // replaceVideoTrack while this publish was under way is the one to send.
868
+ await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
869
+ }
690
870
  }
691
871
 
692
872
  /** What this project may publish. Known once joined; 720p before that. */
@@ -707,39 +887,355 @@ export class StreamClient {
707
887
  */
708
888
  async setVideoQuality(quality: VideoQuality): Promise<VideoQuality> {
709
889
  this.publishOptions = { ...this.publishOptions, quality };
710
- const current = [...this.producers.values()].find(
711
- (p) => p.kind === 'video' && (p.appData as { source?: string }).source === 'camera',
712
- );
713
- const track = current?.track;
714
- if (!current || !track || track.readyState !== 'live') return this.videoQuality;
890
+ await this.cameraTask(async () => {
891
+ const current = this.cameraProducer();
892
+ const track = current?.track;
893
+ if (!current || !track || track.readyState !== 'live') return;
894
+ await this.reproduceCamera(current, track);
895
+ });
896
+ return this.videoQuality;
897
+ }
898
+
899
+ /**
900
+ * Send a different video track from now on: a canvas with effects drawn on
901
+ * it, the raw camera again, another camera. When the new track needs the
902
+ * same layers as the current one (the same size, or another size the
903
+ * project's quality scales alike), viewers see no pause, and the layers
904
+ * take the new picture's bitrates. Otherwise the camera producer is
905
+ * replaced, as `setVideoQuality` does. Either way a paused camera stays
906
+ * paused.
907
+ *
908
+ * Your own stream is left as it is: the client keeps a stream of its own
909
+ * (`local`, announced by `localStreamChanged`) with the new track and your
910
+ * microphone, and a reconnect republishes that. The track you replaced is
911
+ * not stopped; it is yours again, switched back on if pausing the camera
912
+ * had switched it off. Rejects when no camera is being published.
913
+ */
914
+ async replaceVideoTrack(track: MediaStreamTrack): Promise<void> {
915
+ if (!track || track.kind !== 'video') throw new TypeError('replaceVideoTrack needs a video track');
916
+ if (track.readyState === 'ended') throw new Error('replaceVideoTrack: that track has ended');
917
+ await this.cameraTask(async () => {
918
+ const current = this.cameraProducer();
919
+ // Between a dropped connection and the republish there is no producer,
920
+ // but the camera is still being published: the republish sends this.
921
+ const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
922
+ if (!current && !reconnecting) throw new Error('replaceVideoTrack: no camera is being published');
923
+ if (!current) {
924
+ // The republish reads the camera's state off the track it sends, and
925
+ // the track being replaced is switched off exactly when the camera is
926
+ // paused. Hand that state on, or a camera that was off comes back on;
927
+ // and the old track back switched on, as below.
928
+ const replaced = this.localStream?.getVideoTracks()[0];
929
+ if (replaced && replaced !== track) {
930
+ track.enabled = replaced.enabled;
931
+ replaced.enabled = true;
932
+ }
933
+ }
934
+ this.adoptVideoTrack(track);
935
+ if (!current || current.track === track) return;
936
+ const previous = current.track;
937
+ const paused = current.paused;
938
+ try {
939
+ const { width = 0, height = 0 } = track.getSettings();
940
+ const next = videoEncodings(width, height, this.videoQuality, this.layerCount());
941
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next)) {
942
+ await current.replaceTrack({ track });
943
+ await this.applyLayerBitrates(current, next);
944
+ return;
945
+ }
946
+ await this.reproduceCamera(current, track);
947
+ } finally {
948
+ // Pausing switched the old track off (mediasoup-client pauses that
949
+ // way), and nothing switches it back on once it is replaced. It is the
950
+ // app's again, often the camera its canvas draws from: handed back
951
+ // off, that canvas stays black after the camera is resumed.
952
+ if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
953
+ }
954
+ });
955
+ }
956
+
957
+ /**
958
+ * Give a camera producer whose track was swapped in place the bitrates of
959
+ * its new picture. The layout matched, but a bigger picture at the same
960
+ * scales (a 1280×720 canvas for a 960×540 camera) would otherwise stay
961
+ * capped at the smaller one's. Best effort: a browser that refuses keeps
962
+ * the old caps.
963
+ */
964
+ private async applyLayerBitrates(producer: Producer, encodings: VideoEncoding[]): Promise<void> {
965
+ const before = this.cameraEncodings;
966
+ if (before && before.every((encoding, i) => encoding.maxBitrate === encodings[i]?.maxBitrate)) {
967
+ this.cameraEncodings = encodings;
968
+ return;
969
+ }
970
+ const sender = producer.rtpSender;
971
+ if (!sender) return;
972
+ try {
973
+ const parameters = sender.getParameters();
974
+ // One entry per layer, smallest first, as they were produced. A browser
975
+ // that reports them some other way is left with its own.
976
+ if (parameters.encodings.length !== encodings.length) return;
977
+ parameters.encodings.forEach((encoding, i) => {
978
+ encoding.maxBitrate = encodings[i].maxBitrate;
979
+ });
980
+ await sender.setParameters(parameters);
981
+ this.cameraEncodings = encodings;
982
+ } catch {
983
+ // Unsupported here; the layers keep the caps they were produced with.
984
+ }
985
+ }
986
+
987
+ /**
988
+ * What your camera video is sending now, layer by layer, or null when no
989
+ * camera is being published. The top-level fields describe the biggest
990
+ * layer going out. `qualityLimitation` says why it is less than asked:
991
+ * `cpu` is this device, `bandwidth` the upload, `none` neither (then check
992
+ * the camera and the light). Bitrates are averaged since the previous call,
993
+ * so call it every second or two; the first call reports 0.
994
+ */
995
+ async getVideoStats(): Promise<VideoSendStats | null> {
996
+ return this.collectVideoStats(this.sendStatsBaseline);
997
+ }
998
+
999
+ /**
1000
+ * What this client receives of a remote video (by its `producerId`, from
1001
+ * the `track` event), or null when it is not receiving that video. Bitrates
1002
+ * are averaged since the previous call for it; the first call reports 0.
1003
+ */
1004
+ async getReceiveStats(producerId: string): Promise<VideoReceiveStats | null> {
1005
+ const consumer = this.consumers.get(producerId);
1006
+ if (!consumer || consumer.kind !== 'video') return null;
1007
+ let report: RTCStatsReport;
1008
+ try {
1009
+ report = await consumer.getStats();
1010
+ } catch (err) {
1011
+ if (consumer.closed) return null;
1012
+ throw err;
1013
+ }
1014
+ const live = new Set([...this.consumers.values()].map((c) => c.id));
1015
+ pruneBaseline(this.recvStatsBaseline, (key) => live.has(key));
1016
+ return parseReceiveStats(report.values(), { producerId, baseline: this.recvStatsBaseline, key: consumer.id });
1017
+ }
1018
+
1019
+ /**
1020
+ * Receive a smaller copy of someone's video (by its `producerId`, from the
1021
+ * `track` event), for a tile that does not need the full picture. Spatial
1022
+ * layer 0 is the smallest copy the publisher sends: a computer sends three
1023
+ * (at 720p 320×180, 640×360 and 1280×720), a phone two (640×360 and
1024
+ * 1280×720), and a layer above the biggest means the biggest. Leave
1025
+ * `temporalLayer` out to keep the full frame rate. It is the most this
1026
+ * client is sent: a connection that cannot carry it still gets less. The
1027
+ * choice is kept across reconnects and forgotten when the track ends.
1028
+ * Rejects for a producer whose video this client is not receiving.
1029
+ */
1030
+ async setPreferredLayers(producerId: string, spatialLayer: number, temporalLayer?: number): Promise<void> {
1031
+ if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
1032
+ throw new RangeError('setPreferredLayers: layers are whole numbers from 0');
1033
+ }
1034
+ const consumer = this.consumers.get(producerId);
1035
+ if (!consumer || consumer.kind !== 'video') {
1036
+ throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
1037
+ }
1038
+ const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
1039
+ // Kept first: a reconnect that interrupts the request still applies it.
1040
+ this.preferredLayers.set(producerId, layers);
1041
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1042
+ }
1043
+
1044
+ private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
1045
+ const producer = this.cameraProducer();
1046
+ if (!producer) return null;
1047
+ let report: RTCStatsReport;
1048
+ try {
1049
+ report = await producer.getStats();
1050
+ } catch (err) {
1051
+ if (producer.closed) return null;
1052
+ throw err;
1053
+ }
1054
+ const prefix = `${producer.id}:`;
1055
+ pruneBaseline(baseline, (key) => key.startsWith(prefix));
1056
+ return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
1057
+ }
1058
+
1059
+ private cameraProducer(): Producer | undefined {
1060
+ for (const p of this.producers.values()) {
1061
+ if (p.kind === 'video' && (p.appData as { source?: string }).source === 'camera') return p;
1062
+ }
1063
+ return undefined;
1064
+ }
1065
+
1066
+ private cameraTask<T>(task: () => Promise<T>): Promise<T> {
1067
+ const run = this.cameraQueue.then(task, task);
1068
+ this.cameraQueue = run.catch(() => undefined);
1069
+ return run;
1070
+ }
1071
+
1072
+ /** Swap the video in the client's own stream, never the app's. */
1073
+ private adoptVideoTrack(track: MediaStreamTrack): void {
1074
+ const previous = this.localStream;
1075
+ if (previous && previous.getVideoTracks()[0] === track) return;
1076
+ const others = previous ? previous.getTracks().filter((t) => t.kind !== 'video') : [];
1077
+ this.localStream = new MediaStream([...others, track]);
1078
+ this.emit('localStreamChanged', this.localStream);
1079
+ }
1080
+
1081
+ /** Close the camera producer and produce `track` in its place, paused on both ends if it was. */
1082
+ private async reproduceCamera(current: Producer, track: MediaStreamTrack): Promise<Producer> {
1083
+ const generation = this.publishGeneration;
715
1084
  const paused = current.paused;
716
1085
  current.close();
717
1086
  this.producers.delete(current.id);
1087
+ this.cameraEncodings = null;
1088
+ this.syncLimitWatch();
718
1089
  await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
719
- const producer = await this.produceCamera(await this.ensureSendTransport(), track);
720
- if (paused) {
721
- producer.pause();
722
- await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
723
- }
724
- return this.videoQuality;
1090
+ return this.produceCamera(await this.ensureSendTransport(), track, generation, paused);
725
1091
  }
726
1092
 
727
- private async produceCamera(transport: Transport, track: MediaStreamTrack): Promise<Producer> {
1093
+ /** `options.layers` when valid, else 2 on phones and tablets and 3 elsewhere. */
1094
+ private layerCount(): LayerCount {
1095
+ const asked = this.publishOptions.layers;
1096
+ return asked === 1 || asked === 2 || asked === 3 ? asked : defaultLayerCount();
1097
+ }
1098
+
1099
+ /**
1100
+ * Produce the camera from `track`, paused on both ends when `paused`. By
1101
+ * default the track says: one handed over switched off is a camera that is
1102
+ * off, and so is one a pause switched off before a reconnect.
1103
+ */
1104
+ private async produceCamera(
1105
+ transport: Transport,
1106
+ track: MediaStreamTrack,
1107
+ generation: number,
1108
+ paused = !track.enabled,
1109
+ ): Promise<Producer> {
1110
+ if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('camera'));
1111
+ // mediasoup-client starts a producer paused exactly when its track is
1112
+ // off. Setting the track to the camera's state first makes the producer
1113
+ // start in it, and a camera that is off never sends a frame of the new
1114
+ // track; the server is told below.
1115
+ track.enabled = !paused;
728
1116
  // Simulcast from day one: it is what lets one node serve far more viewers.
729
- // The layers follow the picture's real size and the project's quality, so
730
- // the browser sends every layer asked for (see videoEncodings).
1117
+ // The layers follow the picture's real size, the project's quality and
1118
+ // the device, so the browser sends every layer asked for (see
1119
+ // videoEncodings).
731
1120
  const { width = 0, height = 0 } = track.getSettings();
732
- const producer = await transport.produce({
733
- track,
734
- stopTracks: false,
735
- encodings: videoEncodings(width, height, this.videoQuality),
736
- codecOptions: { videoGoogleStartBitrate: 1000 },
737
- appData: { source: 'camera' },
738
- });
739
- this.producers.set(producer.id, producer);
1121
+ const quality = this.videoQuality;
1122
+ const encodings = videoEncodings(width, height, quality, this.layerCount());
1123
+ let codecs: RtpCodecCapability[] | undefined;
1124
+ try {
1125
+ codecs = this.device?.sendRtpCapabilities.codecs;
1126
+ } catch {
1127
+ codecs = undefined;
1128
+ }
1129
+ const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
1130
+ const produce = (codec: RtpCodecCapability | undefined) =>
1131
+ transport.produce({
1132
+ track,
1133
+ stopTracks: false,
1134
+ encodings,
1135
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1136
+ ...(codec ? { codec } : {}),
1137
+ appData: { source: 'camera' },
1138
+ });
1139
+ let producer: Producer;
1140
+ try {
1141
+ producer = await produce(chosen.capability);
1142
+ } catch (err) {
1143
+ // Never fail a publish over the codec: H.264 refused is VP8 instead.
1144
+ // A closed transport (a reconnect under way) fails either way.
1145
+ if (chosen.codec !== 'h264' || transport.closed) throw err;
1146
+ this.reportCodecFallback(`H.264 was refused (${(err as Error).message}): publishing VP8 instead`);
1147
+ producer = await produce(findCodec(codecs, 'vp8'));
1148
+ }
1149
+ // unpublish() may have run while this was being made (a publish, a
1150
+ // quality change or a track swap under way).
1151
+ await this.keepProducer(producer, generation, 'camera');
1152
+ this.cameraEncodings = encodings;
1153
+ await this.pauseOnServer(producer);
1154
+ await this.applyDegradationPreference(producer);
1155
+ this.syncLimitWatch();
740
1156
  return producer;
741
1157
  }
742
1158
 
1159
+ /**
1160
+ * Keep a producer just made -- unless unpublish() ran while it was being
1161
+ * made. Kept, it would publish again what was just unpublished, so it is
1162
+ * closed instead, on both ends.
1163
+ */
1164
+ private async keepProducer(producer: Producer, generation: number, what: string): Promise<void> {
1165
+ if (generation !== this.publishGeneration) {
1166
+ producer.close();
1167
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1168
+ throw new Error(unpublishedWhile(what));
1169
+ }
1170
+ this.producers.set(producer.id, producer);
1171
+ }
1172
+
1173
+ /**
1174
+ * mediasoup-client makes a producer paused when its track is off, and the
1175
+ * server makes every producer live. Unless the server is told, viewers get
1176
+ * black video or silence from a camera or microphone they are told is on.
1177
+ */
1178
+ private async pauseOnServer(producer: Producer): Promise<void> {
1179
+ if (producer.paused) await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
1180
+ }
1181
+
1182
+ /**
1183
+ * H.264 or VP8 for the camera (see chooseCodec), with the device's own
1184
+ * capability entry for it: produce() must only ever get one of those.
1185
+ */
1186
+ private async cameraCodec(
1187
+ codecs: RtpCodecCapability[] | undefined,
1188
+ encodings: VideoEncoding[],
1189
+ width: number,
1190
+ height: number,
1191
+ quality: VideoQuality,
1192
+ ): Promise<{ codec: 'vp8' | 'h264'; capability?: RtpCodecCapability }> {
1193
+ const requested = normalizeCodec(this.publishOptions.codec);
1194
+ const env = currentEnv();
1195
+ const phoneOrTablet = isPhoneOrTablet(env);
1196
+ const h264 = findCodec(codecs, 'h264');
1197
+ let h264Hardware: boolean | null = null;
1198
+ if (requested === 'auto' && phoneOrTablet && h264) {
1199
+ const top = topLayerSize(width, height, quality);
1200
+ h264Hardware = await h264HardwareEncode({ width: top.width, height: top.height, bitrate: encodings[encodings.length - 1].maxBitrate });
1201
+ }
1202
+ const choice = chooseCodec({ requested, phoneOrTablet, appleMobile: isAppleMobile(env), h264Available: Boolean(h264), h264Hardware });
1203
+ if (choice.warning) this.reportCodecFallback(choice.warning);
1204
+ else if (requested === 'auto' && phoneOrTablet && !h264) this.reportCodecFallback('this browser cannot send H.264 to this server: publishing VP8');
1205
+ if (choice.codec === 'h264' && h264) return { codec: 'h264', capability: h264 };
1206
+ return { codec: 'vp8', capability: findCodec(codecs, 'vp8') };
1207
+ }
1208
+
1209
+ /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
1210
+ private reportCodecFallback(message: string): void {
1211
+ if (normalizeCodec(this.publishOptions.codec) === 'h264') {
1212
+ this.emit('error', new Error(message));
1213
+ } else if (!this.codecFallbackLogged) {
1214
+ this.codecFallbackLogged = true;
1215
+ console.warn(`[voltkit-rtc] ${message}`);
1216
+ }
1217
+ }
1218
+
1219
+ /** Best effort: a browser that ignores or refuses it keeps its own default. */
1220
+ private async applyDegradationPreference(producer: Producer): Promise<void> {
1221
+ const preference = this.publishOptions.degradationPreference;
1222
+ const sender = producer.rtpSender;
1223
+ if (!sender) return;
1224
+ if (preference !== 'maintain-resolution' && preference !== 'maintain-framerate' && preference !== 'balanced') return;
1225
+ try {
1226
+ const parameters = sender.getParameters();
1227
+ parameters.degradationPreference = preference;
1228
+ await sender.setParameters(parameters);
1229
+ } catch {
1230
+ // Unsupported here; the encoder keeps the browser's own trade-off.
1231
+ }
1232
+ }
1233
+
1234
+ /** videoLimited sampling runs while a camera is published and someone listens. */
1235
+ private syncLimitWatch(): void {
1236
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
1237
+ }
1238
+
743
1239
  /**
744
1240
  * Re-establish this client's broadcast after a reconnect.
745
1241
  *
@@ -748,9 +1244,13 @@ export class StreamClient {
748
1244
  * when the tracks have genuinely ended.
749
1245
  */
750
1246
  private async republish(): Promise<void> {
1247
+ // unpublish() can run while this is under way, and wins: nothing it
1248
+ // stopped is published again.
1249
+ const generation = this.publishGeneration;
751
1250
  // Music is not a device and cannot be "gone": publish it again whatever
752
1251
  // happens to the camera and microphone below.
753
- if (this.music) await this.produceMusic(this.music).catch((err) => this.emit('error', err as Error));
1252
+ if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
1253
+ if (generation !== this.publishGeneration) return;
754
1254
 
755
1255
  let stream = this.localStream;
756
1256
  if (!stream && !this.publishConstraints) return; // music was all that was being published
@@ -761,7 +1261,18 @@ export class StreamClient {
761
1261
  this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
762
1262
  return;
763
1263
  }
1264
+ const ended = stream;
764
1265
  stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
1266
+ if (generation !== this.publishGeneration) {
1267
+ for (const track of stream.getTracks()) track.stop();
1268
+ return;
1269
+ }
1270
+ // New devices come back switched on. A camera or microphone the user
1271
+ // had switched off stays off: a dropped connection is no reason to
1272
+ // switch it back on.
1273
+ for (const track of stream.getTracks()) {
1274
+ if (ended?.getTracks().some((t) => t.kind === track.kind && !t.enabled)) track.enabled = false;
1275
+ }
765
1276
  this.localStream = stream;
766
1277
  this.emit('localStreamChanged', stream);
767
1278
  }
@@ -783,10 +1294,15 @@ export class StreamClient {
783
1294
  * the user has not interacted with.
784
1295
  */
785
1296
  async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
1297
+ const generation = this.publishGeneration;
786
1298
  if (this.music) await this.stopMusic();
787
1299
  const music = new MusicSource(input, options);
788
1300
  try {
789
- await this.produceMusic(music);
1301
+ await this.produceMusic(music, generation);
1302
+ // Checked again here: an unpublish() after the producer was kept has
1303
+ // already closed it, and the music must not be remembered for a
1304
+ // reconnect to publish again.
1305
+ if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
790
1306
  } catch (err) {
791
1307
  music.close();
792
1308
  throw err;
@@ -796,8 +1312,9 @@ export class StreamClient {
796
1312
  this.emitLocalMedia();
797
1313
  }
798
1314
 
799
- private async produceMusic(music: MusicSource): Promise<void> {
1315
+ private async produceMusic(music: MusicSource, generation: number): Promise<void> {
800
1316
  const transport = await this.ensureSendTransport();
1317
+ if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
801
1318
  const producer = await transport.produce({
802
1319
  track: music.track,
803
1320
  stopTracks: false,
@@ -806,8 +1323,10 @@ export class StreamClient {
806
1323
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
807
1324
  appData: { source: 'music' },
808
1325
  });
809
- this.producers.set(producer.id, producer);
1326
+ await this.keepProducer(producer, generation, 'music');
810
1327
  this.musicProducerId = producer.id;
1328
+ // Music switched off before a reconnect comes back paused here.
1329
+ await this.pauseOnServer(producer);
811
1330
  }
812
1331
 
813
1332
  /** True while music is published, whether or not it is switched on. */
@@ -874,6 +1393,14 @@ export class StreamClient {
874
1393
  const consumer = await transport.consume(params);
875
1394
  this.consumers.set(remote.producerId, consumer);
876
1395
 
1396
+ // Layers asked for before a reconnect: the new consumer gets them before
1397
+ // its first frame instead of starting at the full picture. Not getting
1398
+ // them costs data, not the track, so a refusal is only reported.
1399
+ const layers = this.preferredLayers.get(remote.producerId);
1400
+ if (layers) {
1401
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
1402
+ }
1403
+
877
1404
  // The server created it paused. Resume only now that our end exists,
878
1405
  // otherwise the first frames arrive before there is anything to decode them.
879
1406
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
@@ -940,20 +1467,33 @@ export class StreamClient {
940
1467
 
941
1468
  /** Stop publishing entirely and release the local devices. */
942
1469
  async unpublish(): Promise<void> {
1470
+ // All of it takes effect now; only telling the server is waited for. So
1471
+ // publish() and unpublish() act in the order they are called: a publish
1472
+ // called while this still waits is not undone by it, and one already
1473
+ // under way stops at its next step (keepProducer).
1474
+ this.publishGeneration += 1;
943
1475
  const ids = [...this.producers.keys()];
944
1476
  for (const producer of this.producers.values()) producer.close();
945
1477
  this.producers.clear();
946
- for (const id of ids) {
947
- await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
948
- }
1478
+ this.cameraEncodings = null;
1479
+ // Start over for the next publish, and clear a limitation still showing:
1480
+ // nothing is being held back once nothing is being sent.
1481
+ const cleared = this.limitWatch.reset();
1482
+ if (cleared) this.emit('videoLimited', cleared);
949
1483
  this.localStream?.getTracks().forEach((t) => t.stop());
950
1484
  this.localStream = null;
1485
+ // Forgotten with the stream: kept, a reconnect would ask for the camera
1486
+ // and microphone again and publish them, after the app unpublished them.
1487
+ this.publishConstraints = null;
951
1488
  this.music?.close();
952
1489
  this.music = null;
953
1490
  this.musicProducerId = null;
954
1491
  this.wasPublishing = false;
955
1492
  this.emit('localStreamChanged', null);
956
1493
  this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
1494
+ for (const id of ids) {
1495
+ await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
1496
+ }
957
1497
  }
958
1498
 
959
1499
  get local(): MediaStream | null {