@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/esm/index.js CHANGED
@@ -202,6 +202,8 @@ export class StreamClient {
202
202
  // server only remembers the last one, rejecting consumes on the first.
203
203
  sendTransportPending = null;
204
204
  recvTransportPending = null;
205
+ /** Transports the join made (P3-6), each used once by the first transport of its direction. */
206
+ preparedTransports = null;
205
207
  pending = new Map();
206
208
  listeners = new Map();
207
209
  consumers = new Map();
@@ -298,6 +300,13 @@ export class StreamClient {
298
300
  healthTimer = null;
299
301
  healthSampling = false;
300
302
  interruptions = new Map();
303
+ /** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
304
+ audioProcessor = null;
305
+ micRaw = null;
306
+ videoProcessor = null;
307
+ /** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
308
+ camRaw = null;
309
+ camOut = null;
301
310
  /** getDiagnostics(): the SDK's recent events, for a support ticket. */
302
311
  diagnosticsLog = new DiagnosticsLog();
303
312
  /** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
@@ -555,8 +564,13 @@ export class StreamClient {
555
564
  const opening = this.openSocket();
556
565
  socket = this.ws;
557
566
  await opening;
558
- result = (await this.rpc('join', { token: this.token }));
567
+ // The transports come with the join where the server can make them (P3-6): the first publish and the first
568
+ // video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
569
+ const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] }));
570
+ const { transports, ...joinResult } = joined;
571
+ result = joinResult;
559
572
  this.joinResult = result;
573
+ this.preparedTransports = transports ?? null;
560
574
  this.device = this.createDevice();
561
575
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
562
576
  // close() ran while the Device loaded: going on would set the state
@@ -813,6 +827,14 @@ export class StreamClient {
813
827
  }
814
828
  close() {
815
829
  void this.stopRecording().catch(() => undefined);
830
+ // The processor's audio graph goes with the session.
831
+ const processor = this.audioProcessor;
832
+ this.audioProcessor = null;
833
+ this.micRaw = null;
834
+ void Promise.resolve(processor?.destroy()).catch(() => undefined);
835
+ const videoProcessor = this.videoProcessor;
836
+ this.videoProcessor = null;
837
+ void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
816
838
  // Adaptive stream stops watching elements: nothing is received any more.
817
839
  this.adaptive.dispose();
818
840
  this.adaptivePaused.clear();
@@ -835,7 +857,7 @@ export class StreamClient {
835
857
  this.skippedVideos.clear();
836
858
  // Producers no longer stop their tracks, so releasing the camera and
837
859
  // microphone is ours to do -- otherwise the device light stays on.
838
- this.localStream?.getTracks().forEach((t) => t.stop());
860
+ this.stopLocalTracks();
839
861
  this.localStream = null;
840
862
  const ws = this.ws;
841
863
  this.ws = null;
@@ -849,6 +871,9 @@ export class StreamClient {
849
871
  this.emit('closed', 'left');
850
872
  }
851
873
  teardownMedia() {
874
+ this.micRaw = null;
875
+ // Made for the session that is going; a rejoin brings new ones.
876
+ this.preparedTransports = null;
852
877
  this.mediaPaths.reset();
853
878
  // The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
854
879
  // app's "poor connection" overlay does not outlive it.
@@ -1332,18 +1357,14 @@ export class StreamClient {
1332
1357
  throw new RpcError('not-connected', 'not connected');
1333
1358
  const device = this.device;
1334
1359
  const socket = this.ws;
1335
- const params = (await this.rpc('createTransport', { direction: 'send' }));
1360
+ const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' }));
1336
1361
  if (this.closedByUs || device !== this.device || socket !== this.ws)
1337
1362
  throw closedWhileConnecting();
1338
1363
  const transport = device.createSendTransport({
1339
1364
  ...params,
1340
1365
  iceServers: this.joinResult?.iceServers,
1341
1366
  });
1342
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1343
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1344
- .then(() => ok())
1345
- .catch(fail);
1346
- });
1367
+ this.connectEarly(transport);
1347
1368
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
1348
1369
  this.rpc('produce', {
1349
1370
  transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
@@ -1356,6 +1377,33 @@ export class StreamClient {
1356
1377
  this.watchTransport(transport);
1357
1378
  return transport;
1358
1379
  }
1380
+ /**
1381
+ * A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
1382
+ * remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
1383
+ * trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
1384
+ * them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
1385
+ * role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
1386
+ * sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
1387
+ * straight after. A refusal means the transport can never carry media: the session rejoins.
1388
+ */
1389
+ connectEarly(transport) {
1390
+ transport.on('connect', ({ dtlsParameters }, ok) => {
1391
+ ok();
1392
+ this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
1393
+ if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport))
1394
+ return;
1395
+ this.emit('error', err);
1396
+ this.recoverConnection(`connectTransport failed: ${err?.message ?? err}`);
1397
+ });
1398
+ });
1399
+ }
1400
+ /** The transport the join made for this direction, once: a later one is created as before. */
1401
+ takePreparedTransport(direction) {
1402
+ const prepared = this.preparedTransports?.[direction] ?? null;
1403
+ if (this.preparedTransports)
1404
+ this.preparedTransports[direction] = undefined;
1405
+ return prepared;
1406
+ }
1359
1407
  ensureRecvTransport() {
1360
1408
  if (this.recvTransport)
1361
1409
  return Promise.resolve(this.recvTransport);
@@ -1372,18 +1420,14 @@ export class StreamClient {
1372
1420
  throw new RpcError('not-connected', 'not connected');
1373
1421
  const device = this.device;
1374
1422
  const socket = this.ws;
1375
- const params = (await this.rpc('createTransport', { direction: 'recv' }));
1423
+ const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' }));
1376
1424
  if (this.closedByUs || device !== this.device || socket !== this.ws)
1377
1425
  throw closedWhileConnecting();
1378
1426
  const transport = device.createRecvTransport({
1379
1427
  ...params,
1380
1428
  iceServers: this.joinResult?.iceServers,
1381
1429
  });
1382
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1383
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1384
- .then(() => ok())
1385
- .catch(fail);
1386
- });
1430
+ this.connectEarly(transport);
1387
1431
  this.recvTransport = transport;
1388
1432
  this.watchTransport(transport);
1389
1433
  return transport;
@@ -1496,7 +1540,8 @@ export class StreamClient {
1496
1540
  this.audioCapReported = false;
1497
1541
  await this.micTask(async () => {
1498
1542
  const current = this.micProducer();
1499
- const track = current?.track;
1543
+ // The device's track: the processed one is the old processor graph's, let go when it is processed again.
1544
+ const track = current ? this.deviceTrackOf(current) : null;
1500
1545
  if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1501
1546
  return;
1502
1547
  await this.reproduceMic(current, track);
@@ -1537,8 +1582,10 @@ export class StreamClient {
1537
1582
  throw unpublishedWhile('microphone');
1538
1583
  const quality = this.audioQualityFor();
1539
1584
  const settings = AUDIO_SETTINGS[quality];
1585
+ // Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
1586
+ const sent = await this.processMic(track);
1540
1587
  const producer = await transport.produce({
1541
- track,
1588
+ track: sent,
1542
1589
  // Keep the track alive when the producer closes: a reconnect closes
1543
1590
  // every producer, and a stopped track cannot be republished.
1544
1591
  stopTracks: false,
@@ -1551,6 +1598,7 @@ export class StreamClient {
1551
1598
  appData: { source: 'mic', audioQuality: quality },
1552
1599
  });
1553
1600
  await this.keepProducer(producer, generation, 'microphone');
1601
+ this.micRaw = sent !== track ? track : null;
1554
1602
  this.watchTrackEnded(producer);
1555
1603
  this.micQuality = quality;
1556
1604
  // A microphone handed over switched off, or muted before a reconnect.
@@ -1623,8 +1671,9 @@ export class StreamClient {
1623
1671
  const track = current?.track;
1624
1672
  if (!current || !track || track.readyState !== 'live')
1625
1673
  return;
1674
+ const camera = this.cameraSource(track);
1626
1675
  try {
1627
- await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1676
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1628
1677
  }
1629
1678
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
1630
1679
  await this.reproduceCamera(current, track);
@@ -1674,43 +1723,56 @@ export class StreamClient {
1674
1723
  const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
1675
1724
  if (!current && !reconnecting)
1676
1725
  throw new Error('replaceVideoTrack: no camera is being published');
1677
- if (!current) {
1678
- // The republish reads the camera's state off the track it sends, and
1679
- // the track being replaced is switched off exactly when the camera is
1680
- // paused. Hand that state on, or a camera that was off comes back on;
1681
- // and the old track back switched on, as below.
1682
- const replaced = this.localStream?.getVideoTracks()[0];
1683
- if (replaced && replaced !== track) {
1684
- track.enabled = replaced.enabled;
1685
- replaced.enabled = true;
1686
- }
1687
- }
1688
- this.adoptVideoTrack(track);
1689
- if (!current || current.track === track)
1690
- return;
1691
- const previous = current.track;
1692
- const paused = current.paused;
1726
+ // The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
1727
+ // processor too, when there is one.
1728
+ const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
1729
+ const previousSource = shown ? this.cameraSource(shown) : null;
1730
+ const before = this.camOut;
1731
+ const sent = await this.processCamera(track);
1693
1732
  try {
1694
- const { width = 0, height = 0 } = track.getSettings();
1695
- const frameRate = this.frameRateFor(track);
1696
- const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1697
- const previousSize = current.track?.getSettings();
1698
- if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1699
- previousSize?.width === width && previousSize?.height === height) {
1700
- await current.replaceTrack({ track });
1701
- this.watchTrackEnded(current);
1702
- await this.applyLayerBitrates(current, next);
1733
+ if (!current) {
1734
+ // The republish reads the camera's state off the track it sends, and
1735
+ // the track being replaced is switched off exactly when the camera is
1736
+ // paused. Hand that state on, or a camera that was off comes back on;
1737
+ // and the old track back switched on, as below.
1738
+ const replaced = this.localStream?.getVideoTracks()[0];
1739
+ if (replaced && replaced !== sent) {
1740
+ sent.enabled = replaced.enabled;
1741
+ replaced.enabled = true;
1742
+ }
1743
+ }
1744
+ this.adoptVideoTrack(sent);
1745
+ if (!current || current.track === sent)
1703
1746
  return;
1747
+ const previous = current.track;
1748
+ const paused = current.paused;
1749
+ try {
1750
+ const { width = 0, height = 0 } = track.getSettings();
1751
+ const frameRate = this.frameRateFor(track);
1752
+ const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1753
+ const previousSize = previousSource?.getSettings();
1754
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1755
+ previousSize?.width === width && previousSize?.height === height) {
1756
+ await current.replaceTrack({ track: sent });
1757
+ this.watchTrackEnded(current);
1758
+ await this.applyLayerBitrates(current, next);
1759
+ return;
1760
+ }
1761
+ await this.reproduceCamera(current, sent);
1762
+ }
1763
+ finally {
1764
+ // Pausing switched the old track off (mediasoup-client pauses that
1765
+ // way), and nothing switches it back on once it is replaced. It is the
1766
+ // app's again, often the camera its canvas draws from: handed back
1767
+ // off, that canvas stays black after the camera is resumed.
1768
+ if (paused && previous && previous !== this.cameraProducer()?.track)
1769
+ previous.enabled = true;
1704
1770
  }
1705
- await this.reproduceCamera(current, track);
1706
1771
  }
1707
1772
  finally {
1708
- // Pausing switched the old track off (mediasoup-client pauses that
1709
- // way), and nothing switches it back on once it is replaced. It is the
1710
- // app's again, often the camera its canvas draws from: handed back
1711
- // off, that canvas stays black after the camera is resumed.
1712
- if (paused && previous && previous !== this.cameraProducer()?.track)
1713
- previous.enabled = true;
1773
+ // The processor's previous picture is no longer sent or shown.
1774
+ if (before && before !== this.camOut)
1775
+ before.stop();
1714
1776
  }
1715
1777
  });
1716
1778
  }
@@ -1891,18 +1953,29 @@ export class StreamClient {
1891
1953
  async produceCamera(transport, track, generation, paused = !track.enabled) {
1892
1954
  if (generation !== this.publishGeneration)
1893
1955
  throw unpublishedWhile('camera');
1956
+ // With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
1957
+ // picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
1958
+ const source = this.cameraSource(track);
1959
+ const before = this.camOut;
1960
+ const beforeSource = this.camRaw;
1961
+ const sent = await this.processCamera(source);
1962
+ if (generation !== this.publishGeneration) {
1963
+ if (sent !== source && sent !== before)
1964
+ sent.stop();
1965
+ throw unpublishedWhile('camera');
1966
+ }
1894
1967
  // mediasoup-client starts a producer paused exactly when its track is
1895
1968
  // off. Setting the track to the camera's state first makes the producer
1896
1969
  // start in it, and a camera that is off never sends a frame of the new
1897
1970
  // track; the server is told below.
1898
- track.enabled = !paused;
1971
+ sent.enabled = !paused;
1899
1972
  // Simulcast from day one: it is what lets one node serve far more viewers.
1900
1973
  // The layers follow the picture's real size, the project's quality and
1901
1974
  // the device, so the browser sends every layer asked for (see
1902
1975
  // videoEncodings).
1903
- const { width = 0, height = 0 } = track.getSettings();
1976
+ const { width = 0, height = 0 } = source.getSettings();
1904
1977
  // The frame rate first: the size the project allows depends on it.
1905
- this.cameraFrameRate = this.frameRateFor(track);
1978
+ this.cameraFrameRate = this.frameRateFor(source);
1906
1979
  const quality = this.cameraQuality(this.cameraFrameRate);
1907
1980
  const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
1908
1981
  let codecs;
@@ -1914,7 +1987,7 @@ export class StreamClient {
1914
1987
  }
1915
1988
  const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
1916
1989
  const produce = (codec) => transport.produce({
1917
- track,
1990
+ track: sent,
1918
1991
  stopTracks: false,
1919
1992
  encodings,
1920
1993
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
@@ -1936,6 +2009,13 @@ export class StreamClient {
1936
2009
  // unpublish() may have run while this was being made (a publish, a
1937
2010
  // quality change or a track swap under way).
1938
2011
  await this.keepProducer(producer, generation, 'camera');
2012
+ if (sent !== track)
2013
+ this.adoptVideoTrack(sent);
2014
+ if (before && before !== sent)
2015
+ before.stop();
2016
+ // A second publish brought another camera: the one the processor drew from before is no longer published.
2017
+ if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw)
2018
+ beforeSource.stop();
1939
2019
  this.watchTrackEnded(producer);
1940
2020
  this.syncPublishProtection();
1941
2021
  this.cameraEncodings = encodings;
@@ -2307,7 +2387,8 @@ export class StreamClient {
2307
2387
  if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
2308
2388
  return;
2309
2389
  this.frameRateFallback = true;
2310
- await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2390
+ const camera = this.cameraSource(producer.track);
2391
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2311
2392
  await this.reproduceCamera(producer, producer.track);
2312
2393
  this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
2313
2394
  }).catch((error) => this.emit('error', error));
@@ -2553,8 +2634,10 @@ export class StreamClient {
2553
2634
  layers = this.effectiveLayers(remote.producerId, adaptive);
2554
2635
  // A camera arriving while the audio-only fallback holds cameras back starts held back too.
2555
2636
  const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
2637
+ // Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
2638
+ // frame is already the right size without a round trip in front of it (P3-6).
2556
2639
  if (layers) {
2557
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2640
+ void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2558
2641
  }
2559
2642
  current(consumer);
2560
2643
  // The server created it paused. Resume only now that our end exists,
@@ -2623,6 +2706,130 @@ export class StreamClient {
2623
2706
  return { spatialLayer: adaptive };
2624
2707
  return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
2625
2708
  }
2709
+ // ------------------------------------------------------- audio processor
2710
+ /**
2711
+ * Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
2712
+ * suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
2713
+ * published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
2714
+ * new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
2715
+ * keeps the microphone off: it is sent as it is, and `error` says why.
2716
+ */
2717
+ async setAudioProcessor(processor) {
2718
+ await this.micTask(async () => {
2719
+ const previous = this.audioProcessor;
2720
+ this.audioProcessor = processor;
2721
+ const producer = this.micProducer();
2722
+ const device = producer ? this.deviceTrackOf(producer) : null;
2723
+ if (producer && device && !producer.closed) {
2724
+ const sent = await this.processMic(device);
2725
+ await producer.replaceTrack({ track: sent });
2726
+ this.micRaw = sent !== device ? device : null;
2727
+ }
2728
+ if (previous && previous !== processor)
2729
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2730
+ });
2731
+ }
2732
+ /** The processor in use now, or null. */
2733
+ get audioProcessorInUse() {
2734
+ return this.audioProcessor;
2735
+ }
2736
+ /** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
2737
+ async processMic(track) {
2738
+ const processor = this.audioProcessor;
2739
+ if (!processor)
2740
+ return track;
2741
+ try {
2742
+ return await processor.process(track);
2743
+ }
2744
+ catch (err) {
2745
+ this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${err?.message ?? err}`));
2746
+ return track;
2747
+ }
2748
+ }
2749
+ /** The device's own track behind a producer: for the microphone, the one before any processor. */
2750
+ deviceTrackOf(producer) {
2751
+ const source = producer.appData.source;
2752
+ if (source === 'mic')
2753
+ return this.micRaw ?? producer.track;
2754
+ return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
2755
+ }
2756
+ /**
2757
+ * Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
2758
+ * or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
2759
+ * picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
2760
+ * same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
2761
+ * new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
2762
+ * the camera off: it is sent as it is, and `error` says why.
2763
+ */
2764
+ async setVideoProcessor(processor) {
2765
+ await this.cameraTask(async () => {
2766
+ const previous = this.videoProcessor;
2767
+ this.videoProcessor = processor;
2768
+ const producer = this.cameraProducer();
2769
+ const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2770
+ if (shown && shown.readyState === 'live') {
2771
+ const source = this.cameraSource(shown);
2772
+ const before = this.camOut;
2773
+ const paused = producer ? producer.paused : !shown.enabled;
2774
+ // Made afresh, by the new processor.
2775
+ this.camRaw = null;
2776
+ this.camOut = null;
2777
+ const sent = await this.processCamera(source);
2778
+ sent.enabled = !paused;
2779
+ if (producer && !producer.closed && producer.track !== sent)
2780
+ await producer.replaceTrack({ track: sent });
2781
+ this.adoptVideoTrack(sent);
2782
+ if (before && before !== sent)
2783
+ before.stop();
2784
+ }
2785
+ if (previous && previous !== processor)
2786
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2787
+ });
2788
+ }
2789
+ /** The video processor in use now, or null. */
2790
+ get videoProcessorInUse() {
2791
+ return this.videoProcessor;
2792
+ }
2793
+ /** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
2794
+ cameraSource(track) {
2795
+ return track === this.camOut && this.camRaw ? this.camRaw : track;
2796
+ }
2797
+ /**
2798
+ * The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
2799
+ * already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
2800
+ */
2801
+ async processCamera(source) {
2802
+ const processor = this.videoProcessor;
2803
+ if (!processor) {
2804
+ this.camRaw = null;
2805
+ this.camOut = null;
2806
+ return source;
2807
+ }
2808
+ if (this.camRaw === source && this.camOut?.readyState === 'live')
2809
+ return this.camOut;
2810
+ try {
2811
+ const out = await processor.process(source);
2812
+ // The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
2813
+ source.enabled = true;
2814
+ this.camRaw = source;
2815
+ this.camOut = out;
2816
+ return out;
2817
+ }
2818
+ catch (err) {
2819
+ this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${err?.message ?? err}`));
2820
+ this.camRaw = null;
2821
+ this.camOut = null;
2822
+ return source;
2823
+ }
2824
+ }
2825
+ /** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
2826
+ stopLocalTracks() {
2827
+ this.localStream?.getTracks().forEach((t) => t.stop());
2828
+ this.camRaw?.stop();
2829
+ this.camOut?.stop();
2830
+ this.camRaw = null;
2831
+ this.camOut = null;
2832
+ }
2626
2833
  // ------------------------------------------------------- diagnostics
2627
2834
  /**
2628
2835
  * A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
@@ -2842,8 +3049,10 @@ export class StreamClient {
2842
3049
  try {
2843
3050
  const mic = this.micProducer();
2844
3051
  const camera = this.cameraProducer();
2845
- const micLevel = mic && !mic.paused ? readMediaSource((await mic.getStats()).values()).level : null;
2846
- const cameraFps = camera && !camera.paused ? readMediaSource((await camera.getStats()).values()).fps : null;
3052
+ // A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
3053
+ const micLevel = mic && !mic.paused && !this.audioProcessor ? readMediaSource((await mic.getStats()).values()).level : null;
3054
+ // A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
3055
+ const cameraFps = camera && !camera.paused && !this.videoProcessor ? readMediaSource((await camera.getStats()).values()).fps : null;
2847
3056
  if (!this.healthTimer)
2848
3057
  return;
2849
3058
  this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
@@ -2884,7 +3093,9 @@ export class StreamClient {
2884
3093
  if (!md?.getUserMedia)
2885
3094
  throw new Error('switchDevice: this browser cannot capture media');
2886
3095
  if (kind === 'videoinput') {
2887
- const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3096
+ const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3097
+ // With a video processor, the camera it draws from.
3098
+ const current = shown ? this.cameraSource(shown) : null;
2888
3099
  if (!current)
2889
3100
  throw new Error('switchDevice: no camera is being published');
2890
3101
  if (!deviceIdOf(current)) {
@@ -2924,8 +3135,10 @@ export class StreamClient {
2924
3135
  }
2925
3136
  /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
2926
3137
  async adoptAudioTrack(producer, track) {
2927
- const previous = producer.track;
2928
- await producer.replaceTrack({ track });
3138
+ const previous = this.micRaw ?? producer.track;
3139
+ const sent = await this.processMic(track);
3140
+ await producer.replaceTrack({ track: sent });
3141
+ this.micRaw = sent !== track ? track : null;
2929
3142
  const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
2930
3143
  this.localStream = new MediaStream([track, ...video]);
2931
3144
  this.emit('localStreamChanged', this.localStream);
@@ -2935,7 +3148,8 @@ export class StreamClient {
2935
3148
  }
2936
3149
  /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
2937
3150
  watchTrackEnded(producer) {
2938
- const track = producer.track;
3151
+ // The device's track, not the processed one a processor sends: that is the one a device ends.
3152
+ const track = this.deviceTrackOf(producer);
2939
3153
  if (!track || typeof track.addEventListener !== 'function')
2940
3154
  return;
2941
3155
  track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
@@ -2944,7 +3158,7 @@ export class StreamClient {
2944
3158
  if (!which)
2945
3159
  return;
2946
3160
  const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
2947
- const current = () => !this.closedByUs && !producer.closed && producer.track === track;
3161
+ const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
2948
3162
  track.addEventListener('mute', () => {
2949
3163
  if (!current() || this.interruptions.has(which))
2950
3164
  return;
@@ -2969,7 +3183,7 @@ export class StreamClient {
2969
3183
  * swapped for another camera on its own (Chime stops it the same way).
2970
3184
  */
2971
3185
  async onTrackEnded(producer, track) {
2972
- const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
3186
+ const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
2973
3187
  if (!live())
2974
3188
  return;
2975
3189
  const deviceId = deviceIdOf(track);
@@ -3377,7 +3591,7 @@ export class StreamClient {
3377
3591
  const cleared = this.limitWatch.reset();
3378
3592
  if (cleared)
3379
3593
  this.emit('videoLimited', cleared);
3380
- this.localStream?.getTracks().forEach((t) => t.stop());
3594
+ this.stopLocalTracks();
3381
3595
  this.localStream = null;
3382
3596
  // Forgotten with the stream: kept, a reconnect would ask for the camera
3383
3597
  // and microphone again and publish them, after the app unpublished them.