@voltkit-rtc/client 0.8.0 → 0.9.1

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 (71) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +102 -0
  3. package/dist/cjs/diagnostics.d.ts +1 -1
  4. package/dist/cjs/diagnostics.js +1 -1
  5. package/dist/cjs/effects-core.d.ts +95 -0
  6. package/dist/cjs/effects-core.d.ts.map +1 -0
  7. package/dist/cjs/effects-core.js +154 -0
  8. package/dist/cjs/effects-core.js.map +1 -0
  9. package/dist/cjs/effects-gl.d.ts +79 -0
  10. package/dist/cjs/effects-gl.d.ts.map +1 -0
  11. package/dist/cjs/effects-gl.js +351 -0
  12. package/dist/cjs/effects-gl.js.map +1 -0
  13. package/dist/cjs/index.d.ts +57 -0
  14. package/dist/cjs/index.d.ts.map +1 -1
  15. package/dist/cjs/index.js +278 -64
  16. package/dist/cjs/index.js.map +1 -1
  17. package/dist/cjs/noise-suppression.d.ts +44 -0
  18. package/dist/cjs/noise-suppression.d.ts.map +1 -0
  19. package/dist/cjs/noise-suppression.js +103 -0
  20. package/dist/cjs/noise-suppression.js.map +1 -0
  21. package/dist/cjs/processors.d.ts +34 -0
  22. package/dist/cjs/processors.d.ts.map +1 -0
  23. package/dist/cjs/processors.js +10 -0
  24. package/dist/cjs/processors.js.map +1 -0
  25. package/dist/cjs/quality.d.ts +7 -1
  26. package/dist/cjs/quality.d.ts.map +1 -1
  27. package/dist/cjs/quality.js +2 -1
  28. package/dist/cjs/quality.js.map +1 -1
  29. package/dist/cjs/video-effects.d.ts +93 -0
  30. package/dist/cjs/video-effects.d.ts.map +1 -0
  31. package/dist/cjs/video-effects.js +493 -0
  32. package/dist/cjs/video-effects.js.map +1 -0
  33. package/dist/esm/diagnostics.d.ts +1 -1
  34. package/dist/esm/diagnostics.js +1 -1
  35. package/dist/esm/effects-core.d.ts +95 -0
  36. package/dist/esm/effects-core.d.ts.map +1 -0
  37. package/dist/esm/effects-core.js +142 -0
  38. package/dist/esm/effects-core.js.map +1 -0
  39. package/dist/esm/effects-gl.d.ts +79 -0
  40. package/dist/esm/effects-gl.d.ts.map +1 -0
  41. package/dist/esm/effects-gl.js +347 -0
  42. package/dist/esm/effects-gl.js.map +1 -0
  43. package/dist/esm/index.d.ts +57 -0
  44. package/dist/esm/index.d.ts.map +1 -1
  45. package/dist/esm/index.js +278 -64
  46. package/dist/esm/index.js.map +1 -1
  47. package/dist/esm/noise-suppression.d.ts +44 -0
  48. package/dist/esm/noise-suppression.d.ts.map +1 -0
  49. package/dist/esm/noise-suppression.js +99 -0
  50. package/dist/esm/noise-suppression.js.map +1 -0
  51. package/dist/esm/processors.d.ts +34 -0
  52. package/dist/esm/processors.d.ts.map +1 -0
  53. package/dist/esm/processors.js +9 -0
  54. package/dist/esm/processors.js.map +1 -0
  55. package/dist/esm/quality.d.ts +7 -1
  56. package/dist/esm/quality.d.ts.map +1 -1
  57. package/dist/esm/quality.js +2 -1
  58. package/dist/esm/quality.js.map +1 -1
  59. package/dist/esm/video-effects.d.ts +93 -0
  60. package/dist/esm/video-effects.d.ts.map +1 -0
  61. package/dist/esm/video-effects.js +488 -0
  62. package/dist/esm/video-effects.js.map +1 -0
  63. package/package.json +31 -1
  64. package/src/diagnostics.ts +1 -1
  65. package/src/effects-core.ts +177 -0
  66. package/src/effects-gl.ts +384 -0
  67. package/src/index.ts +276 -63
  68. package/src/noise-suppression.ts +135 -0
  69. package/src/processors.ts +35 -0
  70. package/src/quality.ts +9 -2
  71. package/src/video-effects.ts +552 -0
package/dist/cjs/index.js CHANGED
@@ -235,6 +235,8 @@ class StreamClient {
235
235
  // server only remembers the last one, rejecting consumes on the first.
236
236
  sendTransportPending = null;
237
237
  recvTransportPending = null;
238
+ /** Transports the join made (P3-6), each used once by the first transport of its direction. */
239
+ preparedTransports = null;
238
240
  pending = new Map();
239
241
  listeners = new Map();
240
242
  consumers = new Map();
@@ -331,6 +333,13 @@ class StreamClient {
331
333
  healthTimer = null;
332
334
  healthSampling = false;
333
335
  interruptions = new Map();
336
+ /** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
337
+ audioProcessor = null;
338
+ micRaw = null;
339
+ videoProcessor = null;
340
+ /** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
341
+ camRaw = null;
342
+ camOut = null;
334
343
  /** getDiagnostics(): the SDK's recent events, for a support ticket. */
335
344
  diagnosticsLog = new diagnostics_js_1.DiagnosticsLog();
336
345
  /** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
@@ -588,8 +597,13 @@ class StreamClient {
588
597
  const opening = this.openSocket();
589
598
  socket = this.ws;
590
599
  await opening;
591
- result = (await this.rpc('join', { token: this.token }));
600
+ // The transports come with the join where the server can make them (P3-6): the first publish and the first
601
+ // video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
602
+ const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] }));
603
+ const { transports, ...joinResult } = joined;
604
+ result = joinResult;
592
605
  this.joinResult = result;
606
+ this.preparedTransports = transports ?? null;
593
607
  this.device = this.createDevice();
594
608
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
595
609
  // close() ran while the Device loaded: going on would set the state
@@ -846,6 +860,14 @@ class StreamClient {
846
860
  }
847
861
  close() {
848
862
  void this.stopRecording().catch(() => undefined);
863
+ // The processor's audio graph goes with the session.
864
+ const processor = this.audioProcessor;
865
+ this.audioProcessor = null;
866
+ this.micRaw = null;
867
+ void Promise.resolve(processor?.destroy()).catch(() => undefined);
868
+ const videoProcessor = this.videoProcessor;
869
+ this.videoProcessor = null;
870
+ void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
849
871
  // Adaptive stream stops watching elements: nothing is received any more.
850
872
  this.adaptive.dispose();
851
873
  this.adaptivePaused.clear();
@@ -868,7 +890,7 @@ class StreamClient {
868
890
  this.skippedVideos.clear();
869
891
  // Producers no longer stop their tracks, so releasing the camera and
870
892
  // microphone is ours to do -- otherwise the device light stays on.
871
- this.localStream?.getTracks().forEach((t) => t.stop());
893
+ this.stopLocalTracks();
872
894
  this.localStream = null;
873
895
  const ws = this.ws;
874
896
  this.ws = null;
@@ -882,6 +904,9 @@ class StreamClient {
882
904
  this.emit('closed', 'left');
883
905
  }
884
906
  teardownMedia() {
907
+ this.micRaw = null;
908
+ // Made for the session that is going; a rejoin brings new ones.
909
+ this.preparedTransports = null;
885
910
  this.mediaPaths.reset();
886
911
  // The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
887
912
  // app's "poor connection" overlay does not outlive it.
@@ -1365,18 +1390,14 @@ class StreamClient {
1365
1390
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1366
1391
  const device = this.device;
1367
1392
  const socket = this.ws;
1368
- const params = (await this.rpc('createTransport', { direction: 'send' }));
1393
+ const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' }));
1369
1394
  if (this.closedByUs || device !== this.device || socket !== this.ws)
1370
1395
  throw closedWhileConnecting();
1371
1396
  const transport = device.createSendTransport({
1372
1397
  ...params,
1373
1398
  iceServers: this.joinResult?.iceServers,
1374
1399
  });
1375
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1376
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1377
- .then(() => ok())
1378
- .catch(fail);
1379
- });
1400
+ this.connectEarly(transport);
1380
1401
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
1381
1402
  this.rpc('produce', {
1382
1403
  transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
@@ -1389,6 +1410,33 @@ class StreamClient {
1389
1410
  this.watchTransport(transport);
1390
1411
  return transport;
1391
1412
  }
1413
+ /**
1414
+ * A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
1415
+ * remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
1416
+ * trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
1417
+ * them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
1418
+ * role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
1419
+ * sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
1420
+ * straight after. A refusal means the transport can never carry media: the session rejoins.
1421
+ */
1422
+ connectEarly(transport) {
1423
+ transport.on('connect', ({ dtlsParameters }, ok) => {
1424
+ ok();
1425
+ this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
1426
+ if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport))
1427
+ return;
1428
+ this.emit('error', err);
1429
+ this.recoverConnection(`connectTransport failed: ${err?.message ?? err}`);
1430
+ });
1431
+ });
1432
+ }
1433
+ /** The transport the join made for this direction, once: a later one is created as before. */
1434
+ takePreparedTransport(direction) {
1435
+ const prepared = this.preparedTransports?.[direction] ?? null;
1436
+ if (this.preparedTransports)
1437
+ this.preparedTransports[direction] = undefined;
1438
+ return prepared;
1439
+ }
1392
1440
  ensureRecvTransport() {
1393
1441
  if (this.recvTransport)
1394
1442
  return Promise.resolve(this.recvTransport);
@@ -1405,18 +1453,14 @@ class StreamClient {
1405
1453
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1406
1454
  const device = this.device;
1407
1455
  const socket = this.ws;
1408
- const params = (await this.rpc('createTransport', { direction: 'recv' }));
1456
+ const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' }));
1409
1457
  if (this.closedByUs || device !== this.device || socket !== this.ws)
1410
1458
  throw closedWhileConnecting();
1411
1459
  const transport = device.createRecvTransport({
1412
1460
  ...params,
1413
1461
  iceServers: this.joinResult?.iceServers,
1414
1462
  });
1415
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1416
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1417
- .then(() => ok())
1418
- .catch(fail);
1419
- });
1463
+ this.connectEarly(transport);
1420
1464
  this.recvTransport = transport;
1421
1465
  this.watchTransport(transport);
1422
1466
  return transport;
@@ -1529,7 +1573,8 @@ class StreamClient {
1529
1573
  this.audioCapReported = false;
1530
1574
  await this.micTask(async () => {
1531
1575
  const current = this.micProducer();
1532
- const track = current?.track;
1576
+ // The device's track: the processed one is the old processor graph's, let go when it is processed again.
1577
+ const track = current ? this.deviceTrackOf(current) : null;
1533
1578
  if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1534
1579
  return;
1535
1580
  await this.reproduceMic(current, track);
@@ -1570,8 +1615,10 @@ class StreamClient {
1570
1615
  throw unpublishedWhile('microphone');
1571
1616
  const quality = this.audioQualityFor();
1572
1617
  const settings = media_permissions_js_1.AUDIO_SETTINGS[quality];
1618
+ // Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
1619
+ const sent = await this.processMic(track);
1573
1620
  const producer = await transport.produce({
1574
- track,
1621
+ track: sent,
1575
1622
  // Keep the track alive when the producer closes: a reconnect closes
1576
1623
  // every producer, and a stopped track cannot be republished.
1577
1624
  stopTracks: false,
@@ -1584,6 +1631,7 @@ class StreamClient {
1584
1631
  appData: { source: 'mic', audioQuality: quality },
1585
1632
  });
1586
1633
  await this.keepProducer(producer, generation, 'microphone');
1634
+ this.micRaw = sent !== track ? track : null;
1587
1635
  this.watchTrackEnded(producer);
1588
1636
  this.micQuality = quality;
1589
1637
  // A microphone handed over switched off, or muted before a reconnect.
@@ -1656,8 +1704,9 @@ class StreamClient {
1656
1704
  const track = current?.track;
1657
1705
  if (!current || !track || track.readyState !== 'live')
1658
1706
  return;
1707
+ const camera = this.cameraSource(track);
1659
1708
  try {
1660
- await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1709
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1661
1710
  }
1662
1711
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
1663
1712
  await this.reproduceCamera(current, track);
@@ -1707,43 +1756,56 @@ class StreamClient {
1707
1756
  const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
1708
1757
  if (!current && !reconnecting)
1709
1758
  throw new Error('replaceVideoTrack: no camera is being published');
1710
- if (!current) {
1711
- // The republish reads the camera's state off the track it sends, and
1712
- // the track being replaced is switched off exactly when the camera is
1713
- // paused. Hand that state on, or a camera that was off comes back on;
1714
- // and the old track back switched on, as below.
1715
- const replaced = this.localStream?.getVideoTracks()[0];
1716
- if (replaced && replaced !== track) {
1717
- track.enabled = replaced.enabled;
1718
- replaced.enabled = true;
1719
- }
1720
- }
1721
- this.adoptVideoTrack(track);
1722
- if (!current || current.track === track)
1723
- return;
1724
- const previous = current.track;
1725
- const paused = current.paused;
1759
+ // The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
1760
+ // processor too, when there is one.
1761
+ const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
1762
+ const previousSource = shown ? this.cameraSource(shown) : null;
1763
+ const before = this.camOut;
1764
+ const sent = await this.processCamera(track);
1726
1765
  try {
1727
- const { width = 0, height = 0 } = track.getSettings();
1728
- const frameRate = this.frameRateFor(track);
1729
- const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1730
- const previousSize = current.track?.getSettings();
1731
- if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
1732
- previousSize?.width === width && previousSize?.height === height) {
1733
- await current.replaceTrack({ track });
1734
- this.watchTrackEnded(current);
1735
- await this.applyLayerBitrates(current, next);
1766
+ if (!current) {
1767
+ // The republish reads the camera's state off the track it sends, and
1768
+ // the track being replaced is switched off exactly when the camera is
1769
+ // paused. Hand that state on, or a camera that was off comes back on;
1770
+ // and the old track back switched on, as below.
1771
+ const replaced = this.localStream?.getVideoTracks()[0];
1772
+ if (replaced && replaced !== sent) {
1773
+ sent.enabled = replaced.enabled;
1774
+ replaced.enabled = true;
1775
+ }
1776
+ }
1777
+ this.adoptVideoTrack(sent);
1778
+ if (!current || current.track === sent)
1736
1779
  return;
1780
+ const previous = current.track;
1781
+ const paused = current.paused;
1782
+ try {
1783
+ const { width = 0, height = 0 } = track.getSettings();
1784
+ const frameRate = this.frameRateFor(track);
1785
+ const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1786
+ const previousSize = previousSource?.getSettings();
1787
+ if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
1788
+ previousSize?.width === width && previousSize?.height === height) {
1789
+ await current.replaceTrack({ track: sent });
1790
+ this.watchTrackEnded(current);
1791
+ await this.applyLayerBitrates(current, next);
1792
+ return;
1793
+ }
1794
+ await this.reproduceCamera(current, sent);
1795
+ }
1796
+ finally {
1797
+ // Pausing switched the old track off (mediasoup-client pauses that
1798
+ // way), and nothing switches it back on once it is replaced. It is the
1799
+ // app's again, often the camera its canvas draws from: handed back
1800
+ // off, that canvas stays black after the camera is resumed.
1801
+ if (paused && previous && previous !== this.cameraProducer()?.track)
1802
+ previous.enabled = true;
1737
1803
  }
1738
- await this.reproduceCamera(current, track);
1739
1804
  }
1740
1805
  finally {
1741
- // Pausing switched the old track off (mediasoup-client pauses that
1742
- // way), and nothing switches it back on once it is replaced. It is the
1743
- // app's again, often the camera its canvas draws from: handed back
1744
- // off, that canvas stays black after the camera is resumed.
1745
- if (paused && previous && previous !== this.cameraProducer()?.track)
1746
- previous.enabled = true;
1806
+ // The processor's previous picture is no longer sent or shown.
1807
+ if (before && before !== this.camOut)
1808
+ before.stop();
1747
1809
  }
1748
1810
  });
1749
1811
  }
@@ -1924,18 +1986,29 @@ class StreamClient {
1924
1986
  async produceCamera(transport, track, generation, paused = !track.enabled) {
1925
1987
  if (generation !== this.publishGeneration)
1926
1988
  throw unpublishedWhile('camera');
1989
+ // With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
1990
+ // picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
1991
+ const source = this.cameraSource(track);
1992
+ const before = this.camOut;
1993
+ const beforeSource = this.camRaw;
1994
+ const sent = await this.processCamera(source);
1995
+ if (generation !== this.publishGeneration) {
1996
+ if (sent !== source && sent !== before)
1997
+ sent.stop();
1998
+ throw unpublishedWhile('camera');
1999
+ }
1927
2000
  // mediasoup-client starts a producer paused exactly when its track is
1928
2001
  // off. Setting the track to the camera's state first makes the producer
1929
2002
  // start in it, and a camera that is off never sends a frame of the new
1930
2003
  // track; the server is told below.
1931
- track.enabled = !paused;
2004
+ sent.enabled = !paused;
1932
2005
  // Simulcast from day one: it is what lets one node serve far more viewers.
1933
2006
  // The layers follow the picture's real size, the project's quality and
1934
2007
  // the device, so the browser sends every layer asked for (see
1935
2008
  // videoEncodings).
1936
- const { width = 0, height = 0 } = track.getSettings();
2009
+ const { width = 0, height = 0 } = source.getSettings();
1937
2010
  // The frame rate first: the size the project allows depends on it.
1938
- this.cameraFrameRate = this.frameRateFor(track);
2011
+ this.cameraFrameRate = this.frameRateFor(source);
1939
2012
  const quality = this.cameraQuality(this.cameraFrameRate);
1940
2013
  const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
1941
2014
  let codecs;
@@ -1947,7 +2020,7 @@ class StreamClient {
1947
2020
  }
1948
2021
  const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
1949
2022
  const produce = (codec) => transport.produce({
1950
- track,
2023
+ track: sent,
1951
2024
  stopTracks: false,
1952
2025
  encodings,
1953
2026
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
@@ -1969,6 +2042,13 @@ class StreamClient {
1969
2042
  // unpublish() may have run while this was being made (a publish, a
1970
2043
  // quality change or a track swap under way).
1971
2044
  await this.keepProducer(producer, generation, 'camera');
2045
+ if (sent !== track)
2046
+ this.adoptVideoTrack(sent);
2047
+ if (before && before !== sent)
2048
+ before.stop();
2049
+ // A second publish brought another camera: the one the processor drew from before is no longer published.
2050
+ if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw)
2051
+ beforeSource.stop();
1972
2052
  this.watchTrackEnded(producer);
1973
2053
  this.syncPublishProtection();
1974
2054
  this.cameraEncodings = encodings;
@@ -2340,7 +2420,8 @@ class StreamClient {
2340
2420
  if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
2341
2421
  return;
2342
2422
  this.frameRateFallback = true;
2343
- await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2423
+ const camera = this.cameraSource(producer.track);
2424
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2344
2425
  await this.reproduceCamera(producer, producer.track);
2345
2426
  this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
2346
2427
  }).catch((error) => this.emit('error', error));
@@ -2586,8 +2667,10 @@ class StreamClient {
2586
2667
  layers = this.effectiveLayers(remote.producerId, adaptive);
2587
2668
  // A camera arriving while the audio-only fallback holds cameras back starts held back too.
2588
2669
  const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
2670
+ // Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
2671
+ // frame is already the right size without a round trip in front of it (P3-6).
2589
2672
  if (layers) {
2590
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2673
+ void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2591
2674
  }
2592
2675
  current(consumer);
2593
2676
  // The server created it paused. Resume only now that our end exists,
@@ -2656,6 +2739,130 @@ class StreamClient {
2656
2739
  return { spatialLayer: adaptive };
2657
2740
  return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
2658
2741
  }
2742
+ // ------------------------------------------------------- audio processor
2743
+ /**
2744
+ * Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
2745
+ * suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
2746
+ * published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
2747
+ * new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
2748
+ * keeps the microphone off: it is sent as it is, and `error` says why.
2749
+ */
2750
+ async setAudioProcessor(processor) {
2751
+ await this.micTask(async () => {
2752
+ const previous = this.audioProcessor;
2753
+ this.audioProcessor = processor;
2754
+ const producer = this.micProducer();
2755
+ const device = producer ? this.deviceTrackOf(producer) : null;
2756
+ if (producer && device && !producer.closed) {
2757
+ const sent = await this.processMic(device);
2758
+ await producer.replaceTrack({ track: sent });
2759
+ this.micRaw = sent !== device ? device : null;
2760
+ }
2761
+ if (previous && previous !== processor)
2762
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2763
+ });
2764
+ }
2765
+ /** The processor in use now, or null. */
2766
+ get audioProcessorInUse() {
2767
+ return this.audioProcessor;
2768
+ }
2769
+ /** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
2770
+ async processMic(track) {
2771
+ const processor = this.audioProcessor;
2772
+ if (!processor)
2773
+ return track;
2774
+ try {
2775
+ return await processor.process(track);
2776
+ }
2777
+ catch (err) {
2778
+ this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${err?.message ?? err}`));
2779
+ return track;
2780
+ }
2781
+ }
2782
+ /** The device's own track behind a producer: for the microphone, the one before any processor. */
2783
+ deviceTrackOf(producer) {
2784
+ const source = producer.appData.source;
2785
+ if (source === 'mic')
2786
+ return this.micRaw ?? producer.track;
2787
+ return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
2788
+ }
2789
+ /**
2790
+ * Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
2791
+ * or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
2792
+ * picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
2793
+ * same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
2794
+ * new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
2795
+ * the camera off: it is sent as it is, and `error` says why.
2796
+ */
2797
+ async setVideoProcessor(processor) {
2798
+ await this.cameraTask(async () => {
2799
+ const previous = this.videoProcessor;
2800
+ this.videoProcessor = processor;
2801
+ const producer = this.cameraProducer();
2802
+ const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2803
+ if (shown && shown.readyState === 'live') {
2804
+ const source = this.cameraSource(shown);
2805
+ const before = this.camOut;
2806
+ const paused = producer ? producer.paused : !shown.enabled;
2807
+ // Made afresh, by the new processor.
2808
+ this.camRaw = null;
2809
+ this.camOut = null;
2810
+ const sent = await this.processCamera(source);
2811
+ sent.enabled = !paused;
2812
+ if (producer && !producer.closed && producer.track !== sent)
2813
+ await producer.replaceTrack({ track: sent });
2814
+ this.adoptVideoTrack(sent);
2815
+ if (before && before !== sent)
2816
+ before.stop();
2817
+ }
2818
+ if (previous && previous !== processor)
2819
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2820
+ });
2821
+ }
2822
+ /** The video processor in use now, or null. */
2823
+ get videoProcessorInUse() {
2824
+ return this.videoProcessor;
2825
+ }
2826
+ /** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
2827
+ cameraSource(track) {
2828
+ return track === this.camOut && this.camRaw ? this.camRaw : track;
2829
+ }
2830
+ /**
2831
+ * The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
2832
+ * already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
2833
+ */
2834
+ async processCamera(source) {
2835
+ const processor = this.videoProcessor;
2836
+ if (!processor) {
2837
+ this.camRaw = null;
2838
+ this.camOut = null;
2839
+ return source;
2840
+ }
2841
+ if (this.camRaw === source && this.camOut?.readyState === 'live')
2842
+ return this.camOut;
2843
+ try {
2844
+ const out = await processor.process(source);
2845
+ // The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
2846
+ source.enabled = true;
2847
+ this.camRaw = source;
2848
+ this.camOut = out;
2849
+ return out;
2850
+ }
2851
+ catch (err) {
2852
+ this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${err?.message ?? err}`));
2853
+ this.camRaw = null;
2854
+ this.camOut = null;
2855
+ return source;
2856
+ }
2857
+ }
2858
+ /** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
2859
+ stopLocalTracks() {
2860
+ this.localStream?.getTracks().forEach((t) => t.stop());
2861
+ this.camRaw?.stop();
2862
+ this.camOut?.stop();
2863
+ this.camRaw = null;
2864
+ this.camOut = null;
2865
+ }
2659
2866
  // ------------------------------------------------------- diagnostics
2660
2867
  /**
2661
2868
  * A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
@@ -2875,8 +3082,10 @@ class StreamClient {
2875
3082
  try {
2876
3083
  const mic = this.micProducer();
2877
3084
  const camera = this.cameraProducer();
2878
- const micLevel = mic && !mic.paused ? (0, health_js_1.readMediaSource)((await mic.getStats()).values()).level : null;
2879
- const cameraFps = camera && !camera.paused ? (0, health_js_1.readMediaSource)((await camera.getStats()).values()).fps : null;
3085
+ // A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
3086
+ const micLevel = mic && !mic.paused && !this.audioProcessor ? (0, health_js_1.readMediaSource)((await mic.getStats()).values()).level : null;
3087
+ // A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
3088
+ const cameraFps = camera && !camera.paused && !this.videoProcessor ? (0, health_js_1.readMediaSource)((await camera.getStats()).values()).fps : null;
2880
3089
  if (!this.healthTimer)
2881
3090
  return;
2882
3091
  this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
@@ -2917,7 +3126,9 @@ class StreamClient {
2917
3126
  if (!md?.getUserMedia)
2918
3127
  throw new Error('switchDevice: this browser cannot capture media');
2919
3128
  if (kind === 'videoinput') {
2920
- const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3129
+ const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3130
+ // With a video processor, the camera it draws from.
3131
+ const current = shown ? this.cameraSource(shown) : null;
2921
3132
  if (!current)
2922
3133
  throw new Error('switchDevice: no camera is being published');
2923
3134
  if (!(0, devices_js_1.deviceIdOf)(current)) {
@@ -2957,8 +3168,10 @@ class StreamClient {
2957
3168
  }
2958
3169
  /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
2959
3170
  async adoptAudioTrack(producer, track) {
2960
- const previous = producer.track;
2961
- await producer.replaceTrack({ track });
3171
+ const previous = this.micRaw ?? producer.track;
3172
+ const sent = await this.processMic(track);
3173
+ await producer.replaceTrack({ track: sent });
3174
+ this.micRaw = sent !== track ? track : null;
2962
3175
  const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
2963
3176
  this.localStream = new MediaStream([track, ...video]);
2964
3177
  this.emit('localStreamChanged', this.localStream);
@@ -2968,7 +3181,8 @@ class StreamClient {
2968
3181
  }
2969
3182
  /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
2970
3183
  watchTrackEnded(producer) {
2971
- const track = producer.track;
3184
+ // The device's track, not the processed one a processor sends: that is the one a device ends.
3185
+ const track = this.deviceTrackOf(producer);
2972
3186
  if (!track || typeof track.addEventListener !== 'function')
2973
3187
  return;
2974
3188
  track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
@@ -2977,7 +3191,7 @@ class StreamClient {
2977
3191
  if (!which)
2978
3192
  return;
2979
3193
  const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
2980
- const current = () => !this.closedByUs && !producer.closed && producer.track === track;
3194
+ const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
2981
3195
  track.addEventListener('mute', () => {
2982
3196
  if (!current() || this.interruptions.has(which))
2983
3197
  return;
@@ -3002,7 +3216,7 @@ class StreamClient {
3002
3216
  * swapped for another camera on its own (Chime stops it the same way).
3003
3217
  */
3004
3218
  async onTrackEnded(producer, track) {
3005
- const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
3219
+ const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
3006
3220
  if (!live())
3007
3221
  return;
3008
3222
  const deviceId = (0, devices_js_1.deviceIdOf)(track);
@@ -3410,7 +3624,7 @@ class StreamClient {
3410
3624
  const cleared = this.limitWatch.reset();
3411
3625
  if (cleared)
3412
3626
  this.emit('videoLimited', cleared);
3413
- this.localStream?.getTracks().forEach((t) => t.stop());
3627
+ this.stopLocalTracks();
3414
3628
  this.localStream = null;
3415
3629
  // Forgotten with the stream: kept, a reconnect would ask for the camera
3416
3630
  // and microphone again and publish them, after the app unpublished them.