@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/src/index.ts CHANGED
@@ -17,6 +17,8 @@ type RtpCapabilities = ms.RtpCapabilities;
17
17
  type RtpCodecCapability = ms.RtpCodecCapability;
18
18
  type Transport = ms.Transport;
19
19
  type TransportOptions = ms.TransportOptions;
20
+ /** The transports a join made when asked (`join {transports}`, P3-6): exactly what createTransport returns. */
21
+ type PreparedTransports = { send?: TransportOptions; recv?: TransportOptions };
20
22
 
21
23
  import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
22
24
  import { isTerminalError, RpcError } from './errors.js';
@@ -36,6 +38,7 @@ import { AdaptiveVideos, pageIsVisible, type AdaptiveWant } from './adaptive.js'
36
38
  import { AudioProtection, PROTECT_SAMPLE_MS, readInboundAudio, readOutboundAudio } from './fallback.js';
37
39
  import { ActiveSpeakers, AUDIO_LEVEL_INTERVAL_MS, SPEAKING_LEVEL } from './speakers.js';
38
40
  import { connectionPath, runPreflight, syntheticMedia, type PreflightOptions, type PreflightReport } from './preflight.js';
41
+ import type { AudioTrackProcessor, VideoTrackProcessor } from './processors.js';
39
42
  import { DiagnosticsLog, SDK_VERSION, type Diagnostics, type DiagnosticsTransport } from './diagnostics.js';
40
43
  import { HEALTH_SAMPLE_MS, HealthDecisions, INTERRUPTED_AFTER_MS, readMediaSource, type MediaWarning } from './health.js';
41
44
  import { constraintsFor, deviceIdOf, diffDevices, enumerate, type DeviceKind, type DevicesChangedEvent, type MediaDevice } from './devices.js';
@@ -102,6 +105,7 @@ export { SPEAKING_LEVEL } from './speakers.js';
102
105
  export type { MediaWarning, MediaWarningCode } from './health.js';
103
106
  export type { PreflightOptions, PreflightReport, PreflightStep, PreflightStepName } from './preflight.js';
104
107
  export { SDK_VERSION } from './diagnostics.js';
108
+ export type { AudioTrackProcessor, VideoTrackProcessor } from './processors.js';
105
109
  export type { Diagnostics, DiagnosticsEntry, DiagnosticsTransport } from './diagnostics.js';
106
110
  export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
107
111
  export type {
@@ -678,6 +682,8 @@ export class StreamClient {
678
682
  // server only remembers the last one, rejecting consumes on the first.
679
683
  private sendTransportPending: Promise<Transport> | null = null;
680
684
  private recvTransportPending: Promise<Transport> | null = null;
685
+ /** Transports the join made (P3-6), each used once by the first transport of its direction. */
686
+ private preparedTransports: PreparedTransports | null = null;
681
687
  private readonly pending = new Map<number, Pending>();
682
688
  private readonly listeners = new Map<string, Set<Handler>>();
683
689
  private readonly consumers = new Map<string, Consumer>();
@@ -774,6 +780,13 @@ export class StreamClient {
774
780
  private healthTimer: ReturnType<typeof setInterval> | null = null;
775
781
  private healthSampling = false;
776
782
  private readonly interruptions = new Map<'camera' | 'mic', ReturnType<typeof setTimeout>>();
783
+ /** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
784
+ private audioProcessor: AudioTrackProcessor | null = null;
785
+ private micRaw: MediaStreamTrack | null = null;
786
+ private videoProcessor: VideoTrackProcessor | null = null;
787
+ /** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
788
+ private camRaw: MediaStreamTrack | null = null;
789
+ private camOut: MediaStreamTrack | null = null;
777
790
  /** getDiagnostics(): the SDK's recent events, for a support ticket. */
778
791
  private readonly diagnosticsLog = new DiagnosticsLog();
779
792
  /** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
@@ -1033,8 +1046,13 @@ export class StreamClient {
1033
1046
  const opening = this.openSocket();
1034
1047
  socket = this.ws;
1035
1048
  await opening;
1036
- result = (await this.rpc('join', { token: this.token })) as JoinResult;
1049
+ // The transports come with the join where the server can make them (P3-6): the first publish and the first
1050
+ // video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
1051
+ const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] })) as JoinResult & { transports?: PreparedTransports };
1052
+ const { transports, ...joinResult } = joined;
1053
+ result = joinResult;
1037
1054
  this.joinResult = result;
1055
+ this.preparedTransports = transports ?? null;
1038
1056
 
1039
1057
  this.device = this.createDevice();
1040
1058
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
@@ -1283,6 +1301,14 @@ export class StreamClient {
1283
1301
 
1284
1302
  close(): void {
1285
1303
  void this.stopRecording().catch(() => undefined);
1304
+ // The processor's audio graph goes with the session.
1305
+ const processor = this.audioProcessor;
1306
+ this.audioProcessor = null;
1307
+ this.micRaw = null;
1308
+ void Promise.resolve(processor?.destroy()).catch(() => undefined);
1309
+ const videoProcessor = this.videoProcessor;
1310
+ this.videoProcessor = null;
1311
+ void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
1286
1312
  // Adaptive stream stops watching elements: nothing is received any more.
1287
1313
  this.adaptive.dispose();
1288
1314
  this.adaptivePaused.clear();
@@ -1305,7 +1331,7 @@ export class StreamClient {
1305
1331
  this.skippedVideos.clear();
1306
1332
  // Producers no longer stop their tracks, so releasing the camera and
1307
1333
  // microphone is ours to do -- otherwise the device light stays on.
1308
- this.localStream?.getTracks().forEach((t) => t.stop());
1334
+ this.stopLocalTracks();
1309
1335
  this.localStream = null;
1310
1336
  const ws = this.ws;
1311
1337
  this.ws = null;
@@ -1319,6 +1345,9 @@ export class StreamClient {
1319
1345
  }
1320
1346
 
1321
1347
  private teardownMedia(): void {
1348
+ this.micRaw = null;
1349
+ // Made for the session that is going; a rejoin brings new ones.
1350
+ this.preparedTransports = null;
1322
1351
  this.mediaPaths.reset();
1323
1352
  // The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
1324
1353
  // app's "poor connection" overlay does not outlive it.
@@ -1788,18 +1817,14 @@ export class StreamClient {
1788
1817
  if (!this.device) throw new RpcError('not-connected', 'not connected');
1789
1818
  const device = this.device;
1790
1819
  const socket = this.ws;
1791
- const params = (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
1820
+ const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
1792
1821
  if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
1793
1822
  const transport = device.createSendTransport({
1794
1823
  ...params,
1795
1824
  iceServers: this.joinResult?.iceServers,
1796
1825
  });
1797
1826
 
1798
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1799
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1800
- .then(() => ok())
1801
- .catch(fail);
1802
- });
1827
+ this.connectEarly(transport);
1803
1828
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
1804
1829
  this.rpc('produce', {
1805
1830
  transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
@@ -1814,6 +1839,33 @@ export class StreamClient {
1814
1839
  return transport;
1815
1840
  }
1816
1841
 
1842
+ /**
1843
+ * A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
1844
+ * remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
1845
+ * trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
1846
+ * them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
1847
+ * role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
1848
+ * sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
1849
+ * straight after. A refusal means the transport can never carry media: the session rejoins.
1850
+ */
1851
+ private connectEarly(transport: Transport): void {
1852
+ transport.on('connect', ({ dtlsParameters }, ok) => {
1853
+ ok();
1854
+ this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
1855
+ if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport)) return;
1856
+ this.emit('error', err as Error);
1857
+ this.recoverConnection(`connectTransport failed: ${(err as Error)?.message ?? err}`);
1858
+ });
1859
+ });
1860
+ }
1861
+
1862
+ /** The transport the join made for this direction, once: a later one is created as before. */
1863
+ private takePreparedTransport(direction: 'send' | 'recv'): TransportOptions | null {
1864
+ const prepared = this.preparedTransports?.[direction] ?? null;
1865
+ if (this.preparedTransports) this.preparedTransports[direction] = undefined;
1866
+ return prepared;
1867
+ }
1868
+
1817
1869
  private ensureRecvTransport(): Promise<Transport> {
1818
1870
  if (this.recvTransport) return Promise.resolve(this.recvTransport);
1819
1871
  if (this.recvTransportPending) return this.recvTransportPending;
@@ -1827,17 +1879,13 @@ export class StreamClient {
1827
1879
  if (!this.device) throw new RpcError('not-connected', 'not connected');
1828
1880
  const device = this.device;
1829
1881
  const socket = this.ws;
1830
- const params = (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
1882
+ const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
1831
1883
  if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
1832
1884
  const transport = device.createRecvTransport({
1833
1885
  ...params,
1834
1886
  iceServers: this.joinResult?.iceServers,
1835
1887
  });
1836
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1837
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1838
- .then(() => ok())
1839
- .catch(fail);
1840
- });
1888
+ this.connectEarly(transport);
1841
1889
 
1842
1890
  this.recvTransport = transport;
1843
1891
  this.watchTransport(transport);
@@ -1951,7 +1999,8 @@ export class StreamClient {
1951
1999
  this.audioCapReported = false;
1952
2000
  await this.micTask(async () => {
1953
2001
  const current = this.micProducer();
1954
- const track = current?.track;
2002
+ // The device's track: the processed one is the old processor graph's, let go when it is processed again.
2003
+ const track = current ? this.deviceTrackOf(current) : null;
1955
2004
  if (!current || !track || track.readyState !== 'live' || this.micQuality === quality) return;
1956
2005
  await this.reproduceMic(current, track);
1957
2006
  });
@@ -1991,8 +2040,10 @@ export class StreamClient {
1991
2040
  if (generation !== this.publishGeneration) throw unpublishedWhile('microphone');
1992
2041
  const quality = this.audioQualityFor();
1993
2042
  const settings = AUDIO_SETTINGS[quality];
2043
+ // Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
2044
+ const sent = await this.processMic(track);
1994
2045
  const producer = await transport.produce({
1995
- track,
2046
+ track: sent,
1996
2047
  // Keep the track alive when the producer closes: a reconnect closes
1997
2048
  // every producer, and a stopped track cannot be republished.
1998
2049
  stopTracks: false,
@@ -2005,6 +2056,7 @@ export class StreamClient {
2005
2056
  appData: { source: 'mic', audioQuality: quality },
2006
2057
  });
2007
2058
  await this.keepProducer(producer, generation, 'microphone');
2059
+ this.micRaw = sent !== track ? track : null;
2008
2060
  this.watchTrackEnded(producer);
2009
2061
  this.micQuality = quality;
2010
2062
  // A microphone handed over switched off, or muted before a reconnect.
@@ -2080,7 +2132,8 @@ export class StreamClient {
2080
2132
  const current = this.cameraProducer();
2081
2133
  const track = current?.track;
2082
2134
  if (!current || !track || track.readyState !== 'live') return;
2083
- try { await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } }); }
2135
+ const camera = this.cameraSource(track);
2136
+ try { await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } }); }
2084
2137
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
2085
2138
  await this.reproduceCamera(current, track);
2086
2139
  });
@@ -2130,40 +2183,51 @@ export class StreamClient {
2130
2183
  // but the camera is still being published: the republish sends this.
2131
2184
  const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
2132
2185
  if (!current && !reconnecting) throw new Error('replaceVideoTrack: no camera is being published');
2133
- if (!current) {
2134
- // The republish reads the camera's state off the track it sends, and
2135
- // the track being replaced is switched off exactly when the camera is
2136
- // paused. Hand that state on, or a camera that was off comes back on;
2137
- // and the old track back switched on, as below.
2138
- const replaced = this.localStream?.getVideoTracks()[0];
2139
- if (replaced && replaced !== track) {
2140
- track.enabled = replaced.enabled;
2141
- replaced.enabled = true;
2142
- }
2143
- }
2144
- this.adoptVideoTrack(track);
2145
- if (!current || current.track === track) return;
2146
- const previous = current.track;
2147
- const paused = current.paused;
2186
+ // The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
2187
+ // processor too, when there is one.
2188
+ const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2189
+ const previousSource = shown ? this.cameraSource(shown) : null;
2190
+ const before = this.camOut;
2191
+ const sent = await this.processCamera(track);
2148
2192
  try {
2149
- const { width = 0, height = 0 } = track.getSettings();
2150
- const frameRate = this.frameRateFor(track);
2151
- const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
2152
- const previousSize = current.track?.getSettings();
2153
- if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
2154
- previousSize?.width === width && previousSize?.height === height) {
2155
- await current.replaceTrack({ track });
2156
- this.watchTrackEnded(current);
2157
- await this.applyLayerBitrates(current, next);
2158
- return;
2193
+ if (!current) {
2194
+ // The republish reads the camera's state off the track it sends, and
2195
+ // the track being replaced is switched off exactly when the camera is
2196
+ // paused. Hand that state on, or a camera that was off comes back on;
2197
+ // and the old track back switched on, as below.
2198
+ const replaced = this.localStream?.getVideoTracks()[0];
2199
+ if (replaced && replaced !== sent) {
2200
+ sent.enabled = replaced.enabled;
2201
+ replaced.enabled = true;
2202
+ }
2203
+ }
2204
+ this.adoptVideoTrack(sent);
2205
+ if (!current || current.track === sent) return;
2206
+ const previous = current.track;
2207
+ const paused = current.paused;
2208
+ try {
2209
+ const { width = 0, height = 0 } = track.getSettings();
2210
+ const frameRate = this.frameRateFor(track);
2211
+ const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
2212
+ const previousSize = previousSource?.getSettings();
2213
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
2214
+ previousSize?.width === width && previousSize?.height === height) {
2215
+ await current.replaceTrack({ track: sent });
2216
+ this.watchTrackEnded(current);
2217
+ await this.applyLayerBitrates(current, next);
2218
+ return;
2219
+ }
2220
+ await this.reproduceCamera(current, sent);
2221
+ } finally {
2222
+ // Pausing switched the old track off (mediasoup-client pauses that
2223
+ // way), and nothing switches it back on once it is replaced. It is the
2224
+ // app's again, often the camera its canvas draws from: handed back
2225
+ // off, that canvas stays black after the camera is resumed.
2226
+ if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
2159
2227
  }
2160
- await this.reproduceCamera(current, track);
2161
2228
  } finally {
2162
- // Pausing switched the old track off (mediasoup-client pauses that
2163
- // way), and nothing switches it back on once it is replaced. It is the
2164
- // app's again, often the camera its canvas draws from: handed back
2165
- // off, that canvas stays black after the camera is resumed.
2166
- if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
2229
+ // The processor's previous picture is no longer sent or shown.
2230
+ if (before && before !== this.camOut) before.stop();
2167
2231
  }
2168
2232
  });
2169
2233
  }
@@ -2342,18 +2406,28 @@ export class StreamClient {
2342
2406
  paused = !track.enabled,
2343
2407
  ): Promise<Producer> {
2344
2408
  if (generation !== this.publishGeneration) throw unpublishedWhile('camera');
2409
+ // With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
2410
+ // picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
2411
+ const source = this.cameraSource(track);
2412
+ const before = this.camOut;
2413
+ const beforeSource = this.camRaw;
2414
+ const sent = await this.processCamera(source);
2415
+ if (generation !== this.publishGeneration) {
2416
+ if (sent !== source && sent !== before) sent.stop();
2417
+ throw unpublishedWhile('camera');
2418
+ }
2345
2419
  // mediasoup-client starts a producer paused exactly when its track is
2346
2420
  // off. Setting the track to the camera's state first makes the producer
2347
2421
  // start in it, and a camera that is off never sends a frame of the new
2348
2422
  // track; the server is told below.
2349
- track.enabled = !paused;
2423
+ sent.enabled = !paused;
2350
2424
  // Simulcast from day one: it is what lets one node serve far more viewers.
2351
2425
  // The layers follow the picture's real size, the project's quality and
2352
2426
  // the device, so the browser sends every layer asked for (see
2353
2427
  // videoEncodings).
2354
- const { width = 0, height = 0 } = track.getSettings();
2428
+ const { width = 0, height = 0 } = source.getSettings();
2355
2429
  // The frame rate first: the size the project allows depends on it.
2356
- this.cameraFrameRate = this.frameRateFor(track);
2430
+ this.cameraFrameRate = this.frameRateFor(source);
2357
2431
  const quality = this.cameraQuality(this.cameraFrameRate);
2358
2432
  const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
2359
2433
  let codecs: RtpCodecCapability[] | undefined;
@@ -2365,7 +2439,7 @@ export class StreamClient {
2365
2439
  const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
2366
2440
  const produce = (codec: RtpCodecCapability | undefined) =>
2367
2441
  transport.produce({
2368
- track,
2442
+ track: sent,
2369
2443
  stopTracks: false,
2370
2444
  encodings,
2371
2445
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
@@ -2385,6 +2459,10 @@ export class StreamClient {
2385
2459
  // unpublish() may have run while this was being made (a publish, a
2386
2460
  // quality change or a track swap under way).
2387
2461
  await this.keepProducer(producer, generation, 'camera');
2462
+ if (sent !== track) this.adoptVideoTrack(sent);
2463
+ if (before && before !== sent) before.stop();
2464
+ // A second publish brought another camera: the one the processor drew from before is no longer published.
2465
+ if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw) beforeSource.stop();
2388
2466
  this.watchTrackEnded(producer);
2389
2467
  this.syncPublishProtection();
2390
2468
  this.cameraEncodings = encodings;
@@ -2732,7 +2810,8 @@ export class StreamClient {
2732
2810
  const producer = this.cameraProducer();
2733
2811
  if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed) return;
2734
2812
  this.frameRateFallback = true;
2735
- await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2813
+ const camera = this.cameraSource(producer.track);
2814
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2736
2815
  await this.reproduceCamera(producer, producer.track);
2737
2816
  this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
2738
2817
  }).catch((error) => this.emit('error', error as Error));
@@ -2970,8 +3049,10 @@ export class StreamClient {
2970
3049
  if (adaptive) layers = this.effectiveLayers(remote.producerId, adaptive);
2971
3050
  // A camera arriving while the audio-only fallback holds cameras back starts held back too.
2972
3051
  const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
3052
+ // Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
3053
+ // frame is already the right size without a round trip in front of it (P3-6).
2973
3054
  if (layers) {
2974
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
3055
+ void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
2975
3056
  }
2976
3057
  current(consumer);
2977
3058
 
@@ -3035,6 +3116,131 @@ export class StreamClient {
3035
3116
  return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
3036
3117
  }
3037
3118
 
3119
+ // ------------------------------------------------------- audio processor
3120
+
3121
+ /**
3122
+ * Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
3123
+ * suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
3124
+ * published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
3125
+ * new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
3126
+ * keeps the microphone off: it is sent as it is, and `error` says why.
3127
+ */
3128
+ async setAudioProcessor(processor: AudioTrackProcessor | null): Promise<void> {
3129
+ await this.micTask(async () => {
3130
+ const previous = this.audioProcessor;
3131
+ this.audioProcessor = processor;
3132
+ const producer = this.micProducer();
3133
+ const device = producer ? this.deviceTrackOf(producer) : null;
3134
+ if (producer && device && !producer.closed) {
3135
+ const sent = await this.processMic(device);
3136
+ await producer.replaceTrack({ track: sent });
3137
+ this.micRaw = sent !== device ? device : null;
3138
+ }
3139
+ if (previous && previous !== processor) await Promise.resolve(previous.destroy()).catch(() => undefined);
3140
+ });
3141
+ }
3142
+
3143
+ /** The processor in use now, or null. */
3144
+ get audioProcessorInUse(): AudioTrackProcessor | null {
3145
+ return this.audioProcessor;
3146
+ }
3147
+
3148
+ /** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
3149
+ private async processMic(track: MediaStreamTrack): Promise<MediaStreamTrack> {
3150
+ const processor = this.audioProcessor;
3151
+ if (!processor) return track;
3152
+ try {
3153
+ return await processor.process(track);
3154
+ } catch (err) {
3155
+ this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${(err as Error)?.message ?? err}`));
3156
+ return track;
3157
+ }
3158
+ }
3159
+
3160
+ /** The device's own track behind a producer: for the microphone, the one before any processor. */
3161
+ private deviceTrackOf(producer: Producer): MediaStreamTrack | null {
3162
+ const source = producer.appData.source;
3163
+ if (source === 'mic') return this.micRaw ?? producer.track;
3164
+ return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
3165
+ }
3166
+
3167
+ /**
3168
+ * Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
3169
+ * or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
3170
+ * picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
3171
+ * same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
3172
+ * new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
3173
+ * the camera off: it is sent as it is, and `error` says why.
3174
+ */
3175
+ async setVideoProcessor(processor: VideoTrackProcessor | null): Promise<void> {
3176
+ await this.cameraTask(async () => {
3177
+ const previous = this.videoProcessor;
3178
+ this.videoProcessor = processor;
3179
+ const producer = this.cameraProducer();
3180
+ const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3181
+ if (shown && shown.readyState === 'live') {
3182
+ const source = this.cameraSource(shown);
3183
+ const before = this.camOut;
3184
+ const paused = producer ? producer.paused : !shown.enabled;
3185
+ // Made afresh, by the new processor.
3186
+ this.camRaw = null;
3187
+ this.camOut = null;
3188
+ const sent = await this.processCamera(source);
3189
+ sent.enabled = !paused;
3190
+ if (producer && !producer.closed && producer.track !== sent) await producer.replaceTrack({ track: sent });
3191
+ this.adoptVideoTrack(sent);
3192
+ if (before && before !== sent) before.stop();
3193
+ }
3194
+ if (previous && previous !== processor) await Promise.resolve(previous.destroy()).catch(() => undefined);
3195
+ });
3196
+ }
3197
+
3198
+ /** The video processor in use now, or null. */
3199
+ get videoProcessorInUse(): VideoTrackProcessor | null {
3200
+ return this.videoProcessor;
3201
+ }
3202
+
3203
+ /** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
3204
+ private cameraSource(track: MediaStreamTrack): MediaStreamTrack {
3205
+ return track === this.camOut && this.camRaw ? this.camRaw : track;
3206
+ }
3207
+
3208
+ /**
3209
+ * The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
3210
+ * already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
3211
+ */
3212
+ private async processCamera(source: MediaStreamTrack): Promise<MediaStreamTrack> {
3213
+ const processor = this.videoProcessor;
3214
+ if (!processor) {
3215
+ this.camRaw = null;
3216
+ this.camOut = null;
3217
+ return source;
3218
+ }
3219
+ if (this.camRaw === source && this.camOut?.readyState === 'live') return this.camOut;
3220
+ try {
3221
+ const out = await processor.process(source);
3222
+ // The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
3223
+ source.enabled = true;
3224
+ this.camRaw = source;
3225
+ this.camOut = out;
3226
+ return out;
3227
+ } catch (err) {
3228
+ this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${(err as Error)?.message ?? err}`));
3229
+ this.camRaw = null;
3230
+ this.camOut = null;
3231
+ return source;
3232
+ }
3233
+ }
3234
+
3235
+ /** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
3236
+ private stopLocalTracks(): void {
3237
+ this.localStream?.getTracks().forEach((t) => t.stop());
3238
+ this.camRaw?.stop();
3239
+ this.camOut?.stop();
3240
+ this.camRaw = null;
3241
+ this.camOut = null;
3242
+ }
3243
+
3038
3244
  // ------------------------------------------------------- diagnostics
3039
3245
 
3040
3246
  /**
@@ -3239,8 +3445,10 @@ export class StreamClient {
3239
3445
  try {
3240
3446
  const mic = this.micProducer();
3241
3447
  const camera = this.cameraProducer();
3242
- const micLevel = mic && !mic.paused ? readMediaSource((await mic.getStats()).values() as Iterable<Record<string, unknown>>).level : null;
3243
- const cameraFps = camera && !camera.paused ? readMediaSource((await camera.getStats()).values() as Iterable<Record<string, unknown>>).fps : null;
3448
+ // A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
3449
+ const micLevel = mic && !mic.paused && !this.audioProcessor ? readMediaSource((await mic.getStats()).values() as Iterable<Record<string, unknown>>).level : null;
3450
+ // A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
3451
+ const cameraFps = camera && !camera.paused && !this.videoProcessor ? readMediaSource((await camera.getStats()).values() as Iterable<Record<string, unknown>>).fps : null;
3244
3452
  if (!this.healthTimer) return;
3245
3453
  this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
3246
3454
  // An interrupted device that is no longer published never says it came back: that is over too.
@@ -3278,7 +3486,9 @@ export class StreamClient {
3278
3486
  const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
3279
3487
  if (!md?.getUserMedia) throw new Error('switchDevice: this browser cannot capture media');
3280
3488
  if (kind === 'videoinput') {
3281
- const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3489
+ const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3490
+ // With a video processor, the camera it draws from.
3491
+ const current = shown ? this.cameraSource(shown) : null;
3282
3492
  if (!current) throw new Error('switchDevice: no camera is being published');
3283
3493
  if (!deviceIdOf(current)) {
3284
3494
  throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
@@ -3313,8 +3523,10 @@ export class StreamClient {
3313
3523
 
3314
3524
  /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
3315
3525
  private async adoptAudioTrack(producer: Producer, track: MediaStreamTrack): Promise<void> {
3316
- const previous = producer.track;
3317
- await producer.replaceTrack({ track });
3526
+ const previous = this.micRaw ?? producer.track;
3527
+ const sent = await this.processMic(track);
3528
+ await producer.replaceTrack({ track: sent });
3529
+ this.micRaw = sent !== track ? track : null;
3318
3530
  const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
3319
3531
  this.localStream = new MediaStream([track, ...video]);
3320
3532
  this.emit('localStreamChanged', this.localStream);
@@ -3324,14 +3536,15 @@ export class StreamClient {
3324
3536
 
3325
3537
  /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
3326
3538
  private watchTrackEnded(producer: Producer): void {
3327
- const track = producer.track;
3539
+ // The device's track, not the processed one a processor sends: that is the one a device ends.
3540
+ const track = this.deviceTrackOf(producer);
3328
3541
  if (!track || typeof track.addEventListener !== 'function') return;
3329
3542
  track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
3330
3543
  // The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
3331
3544
  const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
3332
3545
  if (!which) return;
3333
3546
  const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
3334
- const current = () => !this.closedByUs && !producer.closed && producer.track === track;
3547
+ const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
3335
3548
  track.addEventListener('mute', () => {
3336
3549
  if (!current() || this.interruptions.has(which)) return;
3337
3550
  this.interruptions.set(which, setTimeout(() => {
@@ -3353,7 +3566,7 @@ export class StreamClient {
3353
3566
  * swapped for another camera on its own (Chime stops it the same way).
3354
3567
  */
3355
3568
  private async onTrackEnded(producer: Producer, track: MediaStreamTrack): Promise<void> {
3356
- const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
3569
+ const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
3357
3570
  if (!live()) return;
3358
3571
  const deviceId = deviceIdOf(track);
3359
3572
  if (producer.appData.source === 'mic') {
@@ -3729,7 +3942,7 @@ export class StreamClient {
3729
3942
  // nothing is being held back once nothing is being sent.
3730
3943
  const cleared = this.limitWatch.reset();
3731
3944
  if (cleared) this.emit('videoLimited', cleared);
3732
- this.localStream?.getTracks().forEach((t) => t.stop());
3945
+ this.stopLocalTracks();
3733
3946
  this.localStream = null;
3734
3947
  // Forgotten with the stream: kept, a reconnect would ask for the camera
3735
3948
  // and microphone again and publish them, after the app unpublished them.