@voltkit-rtc/client 0.7.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +346 -2
  3. package/dist/cjs/adaptive.d.ts +104 -0
  4. package/dist/cjs/adaptive.d.ts.map +1 -0
  5. package/dist/cjs/adaptive.js +263 -0
  6. package/dist/cjs/adaptive.js.map +1 -0
  7. package/dist/cjs/devices.d.ts +42 -0
  8. package/dist/cjs/devices.d.ts.map +1 -0
  9. package/dist/cjs/devices.js +56 -0
  10. package/dist/cjs/devices.js.map +1 -0
  11. package/dist/cjs/diagnostics.d.ts +65 -0
  12. package/dist/cjs/diagnostics.d.ts.map +1 -0
  13. package/dist/cjs/diagnostics.js +69 -0
  14. package/dist/cjs/diagnostics.js.map +1 -0
  15. package/dist/cjs/effects-core.d.ts +95 -0
  16. package/dist/cjs/effects-core.d.ts.map +1 -0
  17. package/dist/cjs/effects-core.js +154 -0
  18. package/dist/cjs/effects-core.js.map +1 -0
  19. package/dist/cjs/effects-gl.d.ts +79 -0
  20. package/dist/cjs/effects-gl.d.ts.map +1 -0
  21. package/dist/cjs/effects-gl.js +351 -0
  22. package/dist/cjs/effects-gl.js.map +1 -0
  23. package/dist/cjs/fallback.d.ts +81 -0
  24. package/dist/cjs/fallback.d.ts.map +1 -0
  25. package/dist/cjs/fallback.js +148 -0
  26. package/dist/cjs/fallback.js.map +1 -0
  27. package/dist/cjs/health.d.ts +70 -0
  28. package/dist/cjs/health.d.ts.map +1 -0
  29. package/dist/cjs/health.js +116 -0
  30. package/dist/cjs/health.js.map +1 -0
  31. package/dist/cjs/index.d.ts +396 -0
  32. package/dist/cjs/index.d.ts.map +1 -1
  33. package/dist/cjs/index.js +1367 -81
  34. package/dist/cjs/index.js.map +1 -1
  35. package/dist/cjs/link.d.ts +99 -0
  36. package/dist/cjs/link.d.ts.map +1 -0
  37. package/dist/cjs/link.js +219 -0
  38. package/dist/cjs/link.js.map +1 -0
  39. package/dist/cjs/noise-suppression.d.ts +44 -0
  40. package/dist/cjs/noise-suppression.d.ts.map +1 -0
  41. package/dist/cjs/noise-suppression.js +103 -0
  42. package/dist/cjs/noise-suppression.js.map +1 -0
  43. package/dist/cjs/preflight.d.ts +79 -0
  44. package/dist/cjs/preflight.d.ts.map +1 -0
  45. package/dist/cjs/preflight.js +159 -0
  46. package/dist/cjs/preflight.js.map +1 -0
  47. package/dist/cjs/processors.d.ts +34 -0
  48. package/dist/cjs/processors.d.ts.map +1 -0
  49. package/dist/cjs/processors.js +10 -0
  50. package/dist/cjs/processors.js.map +1 -0
  51. package/dist/cjs/speakers.d.ts +34 -0
  52. package/dist/cjs/speakers.d.ts.map +1 -0
  53. package/dist/cjs/speakers.js +71 -0
  54. package/dist/cjs/speakers.js.map +1 -0
  55. package/dist/cjs/video-effects.d.ts +93 -0
  56. package/dist/cjs/video-effects.d.ts.map +1 -0
  57. package/dist/cjs/video-effects.js +493 -0
  58. package/dist/cjs/video-effects.js.map +1 -0
  59. package/dist/esm/adaptive.d.ts +104 -0
  60. package/dist/esm/adaptive.d.ts.map +1 -0
  61. package/dist/esm/adaptive.js +256 -0
  62. package/dist/esm/adaptive.js.map +1 -0
  63. package/dist/esm/devices.d.ts +42 -0
  64. package/dist/esm/devices.d.ts.map +1 -0
  65. package/dist/esm/devices.js +50 -0
  66. package/dist/esm/devices.js.map +1 -0
  67. package/dist/esm/diagnostics.d.ts +65 -0
  68. package/dist/esm/diagnostics.d.ts.map +1 -0
  69. package/dist/esm/diagnostics.js +65 -0
  70. package/dist/esm/diagnostics.js.map +1 -0
  71. package/dist/esm/effects-core.d.ts +95 -0
  72. package/dist/esm/effects-core.d.ts.map +1 -0
  73. package/dist/esm/effects-core.js +142 -0
  74. package/dist/esm/effects-core.js.map +1 -0
  75. package/dist/esm/effects-gl.d.ts +79 -0
  76. package/dist/esm/effects-gl.d.ts.map +1 -0
  77. package/dist/esm/effects-gl.js +347 -0
  78. package/dist/esm/effects-gl.js.map +1 -0
  79. package/dist/esm/fallback.d.ts +81 -0
  80. package/dist/esm/fallback.d.ts.map +1 -0
  81. package/dist/esm/fallback.js +142 -0
  82. package/dist/esm/fallback.js.map +1 -0
  83. package/dist/esm/health.d.ts +70 -0
  84. package/dist/esm/health.d.ts.map +1 -0
  85. package/dist/esm/health.js +111 -0
  86. package/dist/esm/health.js.map +1 -0
  87. package/dist/esm/index.d.ts +396 -0
  88. package/dist/esm/index.d.ts.map +1 -1
  89. package/dist/esm/index.js +1365 -81
  90. package/dist/esm/index.js.map +1 -1
  91. package/dist/esm/link.d.ts +99 -0
  92. package/dist/esm/link.d.ts.map +1 -0
  93. package/dist/esm/link.js +214 -0
  94. package/dist/esm/link.js.map +1 -0
  95. package/dist/esm/noise-suppression.d.ts +44 -0
  96. package/dist/esm/noise-suppression.d.ts.map +1 -0
  97. package/dist/esm/noise-suppression.js +99 -0
  98. package/dist/esm/noise-suppression.js.map +1 -0
  99. package/dist/esm/preflight.d.ts +79 -0
  100. package/dist/esm/preflight.d.ts.map +1 -0
  101. package/dist/esm/preflight.js +153 -0
  102. package/dist/esm/preflight.js.map +1 -0
  103. package/dist/esm/processors.d.ts +34 -0
  104. package/dist/esm/processors.d.ts.map +1 -0
  105. package/dist/esm/processors.js +9 -0
  106. package/dist/esm/processors.js.map +1 -0
  107. package/dist/esm/speakers.d.ts +34 -0
  108. package/dist/esm/speakers.d.ts.map +1 -0
  109. package/dist/esm/speakers.js +67 -0
  110. package/dist/esm/speakers.js.map +1 -0
  111. package/dist/esm/video-effects.d.ts +93 -0
  112. package/dist/esm/video-effects.d.ts.map +1 -0
  113. package/dist/esm/video-effects.js +488 -0
  114. package/dist/esm/video-effects.js.map +1 -0
  115. package/package.json +31 -1
  116. package/src/adaptive.ts +295 -0
  117. package/src/devices.ts +70 -0
  118. package/src/diagnostics.ts +94 -0
  119. package/src/effects-core.ts +177 -0
  120. package/src/effects-gl.ts +384 -0
  121. package/src/fallback.ts +169 -0
  122. package/src/health.ts +127 -0
  123. package/src/index.ts +1364 -81
  124. package/src/link.ts +255 -0
  125. package/src/noise-suppression.ts +135 -0
  126. package/src/preflight.ts +191 -0
  127. package/src/processors.ts +35 -0
  128. package/src/speakers.ts +66 -0
  129. package/src/video-effects.ts +552 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,91 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ - **AI noise suppression: `@voltkit-rtc/client/noise-suppression`** (Agora AI Denoiser, LiveKit Krisp, ZEGOCLOUD
6
+ AI noise suppression). `createNoiseSuppressor({ workletUrl?, wasmUrl?, simdWasmUrl? })` is RNNoise in an
7
+ AudioWorklet (`@sapphi-red/web-noise-suppressor` 0.4.1, MIT, loaded only when used); files from jsDelivr at the
8
+ pinned version unless self-hosted. In Chromium, rumble-like noise went down by more than 60 dB.
9
+ - **`setAudioProcessor(processor | null)` and `audioProcessorInUse`** (LiveKit `setProcessor`): the microphone goes
10
+ through an `AudioTrackProcessor` (`{ name, process(track), destroy() }`) before it is sent. Swapped in place while
11
+ live; kept across device switches, a recovered microphone, reconnects and audio-quality changes. A failing
12
+ processor sends the microphone unprocessed and emits `error`. `mic-silent` is not checked while one is on.
13
+ - **Virtual background and beauty: `@voltkit-rtc/client/video-effects`** (LiveKit track processors, Agora/ZEGOCLOUD
14
+ virtual background and beauty, TRTC video effects). `createVideoEffects({ background: 'none' | 'blur' | { image },
15
+ blur, beauty, wasmPath?, modelUrl?, maxFrameRate?, onOverload? })`, `update()` live, `isVideoEffectsSupported()`.
16
+ WebGL2: mask-weighted blur (no halo), edges refined against the picture, cover-fitted images, beauty without a
17
+ face model. MediaPipe's selfie segmenter (landscape model for wide pictures, square for tall), from the optional
18
+ peer `@mediapipe/tasks-vision` at exactly 0.10.35 (later versions report usage to Google); nothing behind the
19
+ person shows until it is found. Insertable streams in Chrome/Edge, a hidden video on a worker timer elsewhere;
20
+ Chime-style adaptive segmentation on slow devices.
21
+ - **`setVideoProcessor(processor | null)` and `videoProcessorInUse`**: the camera goes through a `VideoTrackProcessor`
22
+ (`{ name, process(track), destroy() }`, same size out) before it is sent; the processed picture is also what `local`
23
+ shows. Swapped in place while live; kept across `switchDevice`, `replaceVideoTrack`, reconnects and quality changes,
24
+ without restarting the processor. The camera behind it is the one switched, constrained, watched and released.
25
+ A failing processor sends the camera unprocessed and emits `error`. `camera-fps-low` is not checked while one is on.
26
+ - **A faster first picture** (Agora/TRTC fast first frame). The join asks for the media transports and uses the ones
27
+ the server made with it (`join {transports}`, controllers from 2026-10-20; older ones are asked as before). The
28
+ DTLS parameters are sent without waiting for the reply, since mediasoup takes them after its handshake has begun:
29
+ ICE starts a round trip sooner, and a refusal rejoins. A video's first layers no longer wait before its resume.
30
+ Up to three round trips fewer before the first publish or the first video.
31
+
32
+ ## 0.8.0
33
+
34
+ - **Media recovery:** send and receive transports restart ICE after failure or
35
+ 3 s disconnected, before a full rejoin. The entire recovery is bounded to
36
+ 10 s; unsupported servers rejoin. `mediaReconnecting` reports recovery.
37
+ - **Audio-only fallback and `streamFallback` (Agora AUDIO_ONLY, Chime received-audio health, Stream Video's
38
+ paused tracks).** When the received sound breaks up badly (25 % of the audio lost over ~15 s), remote cameras are
39
+ paused at the server so the audio keeps going (screen shares stay), and come back after 20 s of recovery (longer
40
+ after a relapse). The media server also reports videos it cut for this viewer's bandwidth. Both arrive as
41
+ `streamFallback {producerId, identity, source, fallback, reason: 'bandwidth' | 'audio-protection'}`. On by
42
+ default: `ClientOptions.audioOnlyFallback: false` or `setAudioOnlyFallback(false)`; `audioOnlyFallbackActive`.
43
+ - **`getDiagnostics()` and `SDK_VERSION`**: a JSON snapshot for a support ticket (session, transport paths, network,
44
+ published/received, the last 200 notable events); no token, no media.
45
+ - **`play(element)` and `autoplayFailed {element, resume}`** (Agora autoplay-failed, TRTC AUTOPLAY_FAILED): a refused
46
+ play is reported and retried on the viewer's next tap, click or key press.
47
+ - **`setPlayoutDelay(ms | null)`** (TRTC playoutDelay): `RTCRtpReceiver.jitterBufferTarget` on every remote track,
48
+ now and later; 300-500 ms for an audience watching a stream, null (default) for conversation.
49
+ - **Faster joins.** The producers already live are subscribed to together on join (as `newProducer` events and the
50
+ Flutter SDK already were), and `StreamClient.prepareConnection(wsUrl)` warms DNS and TLS before `connect()`.
51
+ - **`StreamClient.preflight(wsUrl, token, {media?, durationMs?})`** (Twilio runPreflight, LiveKit ConnectionCheck):
52
+ joins a test room, publishes a synthetic tone and picture (or the app's media, cloned) for a few seconds, leaves,
53
+ and reports steps, media path (UDP/TCP/relayed), round trip, loss, upload and warnings. Never rejects.
54
+ - **`mediaWarning {code, active}`** (Agora exception/recover pairs, LiveKit silence and system-mute handling):
55
+ `mic-silent`, `camera-fps-low` (under 15 fps when 24+ asked; usually low light), `camera-interrupted`,
56
+ `mic-interrupted`; each once when it starts and once when it clears; checked every 2 s while listened to.
57
+ - **`audioLevels` and `activeSpeakers`** (Agora volume-indicator, TRTC AUDIO_VOLUME, Amazon Chime's active speaker
58
+ ranking): levels every ~250 ms while listened to (remote audio from `getSynchronizationSources`, the own microphone
59
+ from its media-source stats; `speaking` above -35 dBov, `SPEAKING_LEVEL`), and the speaker order when it changes
60
+ (microphones only). Nothing is read while nobody listens.
61
+ - **Cameras and microphones.** `listDevices(kind?)`, `switchDevice('videoinput' | 'audioinput', deviceId)` (same
62
+ constraints, the replaced device released, a reconnect keeps the choice; refuses a canvas/processed video), and
63
+ `devicesChanged {devices, added, removed}` while listened to. A published device that stops from outside raises
64
+ `localTrackEnded {kind, deviceId, recovered}`: a microphone comes back from the system default, a camera is
65
+ switched off and never swapped on its own (Amazon Chime's and LiveKit's behaviour).
66
+ - **Publisher-side audio-only fallback (opt-in)**: `setPublishFallback(true)` pauses the camera while the server reports
67
+ this client's own sound breaking up (Agora's setLocalPublishFallbackOption), and resumes it after recovery;
68
+ `publishFallback {fallback}`. A camera switched off by the user or a moderator meanwhile stays off.
69
+ - **Socket liveness:** ping after 15 s idle, reconnect after 8 s without any
70
+ response (checked every 5 s). Browser `online` wakes a pending backoff.
71
+ Late restart/setup results cannot change a newer session; close cancels watches.
72
+
73
+ Performance on busy devices (owner's demo test, 2026-10-19: three cameras with effects and reactions hung phones
74
+ and made laptops stutter). Ideas from LiveKit (adaptive stream, dynacast, CpuConstrained), Tencent TRTC (small
75
+ stream, receiveWhenViewVisible) and Agora (automatic low stream). Every 0.7 call works as before.
76
+
77
+ - **Adaptive stream: `attachVideo(producerId, element)`.** A remote video is received at the size its element is
78
+ shown (smallest layer that fills it sharply, devicePixelRatio up to 2) and paused at the server while the element
79
+ cannot be seen (off screen, `display: none`, background tab). Bigger at once, smaller after ~1 s, paused after
80
+ 1.5 s hidden. `setReceiveQuality`/`setPreferredLayers` stay a ceiling. Returns `detach()`. A server without
81
+ `pauseConsumer` gets the smallest layer instead of a pause.
82
+ - **Dynacast.** The server's new `producerLayers` event says which camera layers any viewer needs; the SDK
83
+ switches the unused encodings off (the smallest stays on). Nothing to call. Older servers never send it.
84
+ - **`cpuConstrained` event and `prioritizePerformance(on = true)`.** `cpuConstrained` reports when the camera is
85
+ CPU-limited (held ~4 s) and when that clears; `prioritizePerformance()` stops encoding the camera's largest
86
+ layer until `prioritizePerformance(false)`, kept across publishes and reconnects. `performancePrioritized`.
87
+ - New wire calls (additive): `pauseConsumer`; new wire event: `producerLayers`.
88
+
3
89
  ## 0.7.3
4
90
 
5
91
  Bug fixes found by checking this SDK for the bug classes a frontend tester
package/README.md CHANGED
@@ -122,6 +122,34 @@ modified: the client keeps one of its own (`client.local`, announced by
122
122
  track you replaced is yours again: it is not stopped, and if pausing the camera
123
123
  had switched it off, it is switched back on.
124
124
 
125
+ ### Cameras and microphones (0.8.0)
126
+
127
+ ```ts
128
+ const cameras = await client.listDevices('videoinput'); // also 'audioinput', 'audiooutput'
129
+ await client.switchDevice('videoinput', cameras[1].deviceId);
130
+ await client.switchDevice('audioinput', headsetId);
131
+
132
+ client.on('devicesChanged', ({ added, removed }) => refreshDevicePicker());
133
+ client.on('localTrackEnded', ({ kind, recovered }) => {
134
+ if (kind === 'video') showCameraPicker(); // the camera was switched off: offer another, then setCamEnabled(true)
135
+ });
136
+ ```
137
+
138
+ `switchDevice` captures the chosen device with the constraints `publish` was
139
+ given (size, frame rate, echo cancellation), swaps it in (viewers see no pause
140
+ for a camera of the same size) and stops the one it replaces. A reconnect
141
+ captures the chosen device again. If you publish a canvas or another processed
142
+ track, capture the new camera yourself and pass your track to
143
+ `replaceVideoTrack`; `switchDevice` refuses rather than replace your picture
144
+ with the raw camera.
145
+
146
+ When a published device stops from outside (unplugged, a Bluetooth headset
147
+ switched off, the permission revoked), as Amazon Chime and LiveKit do: the
148
+ **microphone** is captured again from the system default and keeps publishing
149
+ (`recovered: true`), or is switched off if there is none; the **camera** is
150
+ switched off, and never swapped for another camera on its own. Labels in
151
+ `listDevices` are empty until the page has camera or microphone access.
152
+
125
153
  ### Video quality
126
154
 
127
155
  Choose 1080p, 720p or 480p when publishing, or change it while live:
@@ -217,6 +245,102 @@ off only for headphones or line-in music, or listeners hear their own echo.
217
245
  Music (`publishMusic`) is a separate 128 kbps stereo track and is not affected:
218
246
  it is HD and free whatever the microphone's quality (see [Music](#music)).
219
247
 
248
+ ### Noise suppression (0.9.0)
249
+
250
+ AI noise suppression for the microphone, as Agora's AI Denoiser, LiveKit's
251
+ Krisp filter and ZEGOCLOUD's AI noise suppression do: RNNoise, a small neural
252
+ network, runs in the browser's audio thread and takes fans, traffic, air
253
+ conditioning and room rumble out of the voice. It is a separate import, so apps
254
+ that do not use it never download it:
255
+
256
+ ```ts
257
+ import { createNoiseSuppressor } from '@voltkit-rtc/client/noise-suppression';
258
+
259
+ await client.setAudioProcessor(createNoiseSuppressor()); // before or after publish
260
+ client.audioProcessorInUse; // the processor, or null
261
+ await client.setAudioProcessor(null); // send the microphone as it is again
262
+ ```
263
+
264
+ - It applies to the microphone published now (swapped in place: listeners hear
265
+ no gap) and to every one after it: `switchDevice('audioinput', …)`, a
266
+ microphone recovered after unplugging, a reconnect, `setAudioQuality`.
267
+ Music and screen-share audio are not processed.
268
+ - The worklet (64 kB) and the model (150 kB) load the first time it runs, from
269
+ jsDelivr at the exact version this SDK was tested with. Under a strict
270
+ Content-Security-Policy, copy `rnnoise/workletProcessor.js`, `rnnoise.wasm`
271
+ and `rnnoise_simd.wasm` from `node_modules/@sapphi-red/web-noise-suppressor/dist`
272
+ to your site and pass their URLs:
273
+ `createNoiseSuppressor({ workletUrl, wasmUrl, simdWasmUrl })`.
274
+ - If it cannot start (the files are blocked, an old browser), the microphone
275
+ still goes out, unprocessed, and `error` says why. The next microphone tries
276
+ again.
277
+ - Turn it on from a click (a toggle, the join button): browsers start audio
278
+ processing only after the page has been touched, and the SDK resumes it on
279
+ the first tap or key press.
280
+ - `mic-silent` is not reported while a processor is on: a quiet room is
281
+ near-silence after denoising. Your own level in `audioLevels` is what
282
+ listeners hear.
283
+ - Measured in Chromium: rumble-like noise (fans, traffic, AC) went down by more
284
+ than 60 dB; steady white hiss, which it was not trained on, by 7-12 dB. Keep
285
+ the browser's own `echoCancellation`; its `noiseSuppression` can stay on too.
286
+
287
+ Your own processor works the same way: anything with `name`,
288
+ `process(track) → Promise<MediaStreamTrack>` and `destroy()`
289
+ (`AudioTrackProcessor`), such as a licensed Krisp build.
290
+
291
+ ### Virtual background and beauty (0.9.0)
292
+
293
+ A blurred or replaced background and beauty for the camera, as LiveKit's track
294
+ processors, Agora's and ZEGOCLOUD's virtual background and beauty, and TRTC's
295
+ video effects do: in the browser, on the GPU. A separate import, so apps that do
296
+ not use it never download it:
297
+
298
+ ```ts
299
+ import { createVideoEffects, isVideoEffectsSupported } from '@voltkit-rtc/client/video-effects';
300
+
301
+ if (isVideoEffectsSupported()) {
302
+ const effects = createVideoEffects({ background: 'blur', beauty: true });
303
+ await client.setVideoProcessor(effects); // before or after publish
304
+ client.videoProcessorInUse; // the processor, or null
305
+
306
+ await effects.update({ background: { image: '/office.jpg' } }); // live: no pause for viewers
307
+ await effects.update({ blur: 8, beauty: { smooth: 0.7, brighten: 0.4 } });
308
+ await client.setVideoProcessor(null); // the camera as it is again
309
+ }
310
+ ```
311
+
312
+ - `background`: `'none'`, `'blur'` (`blur` 1-10, default 5) or `{ image }` (a
313
+ URL, Blob, image, canvas or ImageBitmap), fitted like `object-fit: cover`.
314
+ - `beauty`: `true`, or `smooth`, `brighten`, `redness`, `sharpen` (0-1;
315
+ defaults 0.5, 0.3, 0.1, 0.3). Skin is smoothed with an edge-preserving filter
316
+ (eyes, lips and teeth stay sharp) and lightly brightened.
317
+ - What you see in `local` is what viewers get. Switching cameras
318
+ (`switchDevice`), `replaceVideoTrack`, reconnects and quality changes keep the
319
+ effects without restarting them. A paused camera costs no processing.
320
+ - **Background effects need MediaPipe**: `npm install @mediapipe/tasks-vision@0.10.35`
321
+ (exactly that version: later ones report usage to Google). Its wasm (3 MB
322
+ compressed) and the person model (250 kB) load the first time a background is
323
+ used, from jsDelivr and Google. To serve them yourself (a strict CSP), copy
324
+ `node_modules/@mediapipe/tasks-vision/wasm` to your site and pass `wasmPath`,
325
+ and `modelUrl` for the model. Beauty alone needs none of this.
326
+ - Nothing behind you shows while the model loads: the background is fully
327
+ blurred or replaced until you are found. If the effects cannot start, the
328
+ camera goes out as it is and `error` says why, so watch `error` when privacy
329
+ matters.
330
+ - Webcams (wide pictures) use MediaPipe's landscape model and phones (tall
331
+ pictures) the square one, which is what each traces best.
332
+ - A slow device finds you only every few frames and reuses the outline (the
333
+ picture keeps the camera's frame rate). `onOverload` tells you when even that
334
+ is not enough: turn the background off or lower the camera's size.
335
+ - Chrome and Edge process each frame as it arrives and keep going in a
336
+ background tab. Firefox and Safari draw from a hidden video on a timer, which
337
+ a background tab can slow down.
338
+
339
+ Your own processor works the same way: anything with `name`,
340
+ `process(track) → Promise<MediaStreamTrack>` (same size and frame rate) and
341
+ `destroy()` (`VideoTrackProcessor`). The SDK stops a picture it no longer sends;
342
+ stop drawing it once its `readyState` is `'ended'`.
343
+
220
344
  ### Project media settings
221
345
 
222
346
  A project's owner chooses what it may publish: audio and video on or off, the
@@ -297,11 +421,61 @@ for their own connection; if every viewer is, look at the publisher's
297
421
  The [troubleshooting guide](https://voltkit.in/docs/troubleshooting) lists the
298
422
  usual causes and what to do about each.
299
423
 
424
+ ### Smooth on busy devices (0.8.0)
425
+
426
+ A room with several cameras, effects and reactions can overload a phone or a
427
+ modest laptop: every viewer decoding every co-host's full-size picture, every
428
+ publisher encoding layers nobody watches. Three things keep it smooth (the
429
+ ideas behind LiveKit's adaptive stream and dynacast, Tencent TRTC's small
430
+ stream with `receiveWhenViewVisible`, and Agora's automatic low stream):
431
+
432
+ **Adaptive stream: each video at the size it is shown.** Attach the element
433
+ that shows a remote video:
434
+
435
+ ```ts
436
+ client.on('track', ({ kind, producerId, track }) => {
437
+ if (kind !== 'video') return;
438
+ const video = showVideo(producerId, track); // your <video> element
439
+ const detach = client.attachVideo(producerId, video); // call detach() when the element goes away
440
+ });
441
+ ```
442
+
443
+ The SDK then receives the smallest layer that fills the element sharply (its
444
+ size on screen × devicePixelRatio, at most 2) and pauses the video at the
445
+ server while the element cannot be seen (scrolled away, `display: none`, the tab
446
+ in the background): no bandwidth, no decoding. Bigger is applied at once,
447
+ smaller after about a second, and a hidden video pauses after 1.5 s, so
448
+ scrolling past a tile does not stop it. `setReceiveQuality` and
449
+ `setPreferredLayers` still apply as a ceiling.
450
+
451
+ **Dynacast: publishers stop encoding layers nobody receives.** Nothing to
452
+ call: the server tells the publishing client which of its camera layers any
453
+ viewer needs, and the SDK switches the others off (the smallest always stays
454
+ on, so a new viewer starts at once). With adaptive stream on the viewers, a host
455
+ shown in small tiles stops spending its CPU on its largest layer.
456
+
457
+ **When the publisher's device itself is overloaded:**
458
+
459
+ ```ts
460
+ client.on('cpuConstrained', ({ constrained }) => {
461
+ if (constrained) {
462
+ void client.prioritizePerformance(); // the camera's largest layer stops being encoded
463
+ turnOffEffects(); // and your own savings: effects off, smaller remote videos
464
+ }
465
+ });
466
+ await client.prioritizePerformance(false); // later, when the user wants full quality back
467
+ ```
468
+
469
+ `cpuConstrained` fires when the browser reports the camera limited by the CPU
470
+ for about 4 seconds (`videoLimited` with `cpu`), and again when that clears.
471
+ `performancePrioritized` tells whether the lighter mode is on.
472
+
300
473
  ### Small tiles
301
474
 
302
475
  Each viewer gets the biggest layer their connection carries, which a thumbnail
303
- or a row of co-host tiles does not need. Ask for a smaller one with the
304
- `producerId` from the `track` event:
476
+ or a row of co-host tiles does not need. `attachVideo` (above) chooses for you;
477
+ to choose yourself, ask for a smaller one with the `producerId` from the `track`
478
+ event:
305
479
 
306
480
  ```ts
307
481
  client.on('track', ({ kind, producerId, track }) => {
@@ -526,6 +700,176 @@ apps. The room closes with `roomClosed { reason: 'quota-exhausted' }` when the
526
700
  funds reach zero unless the wallet is recharged first; a recharge sends
527
701
  `walletLow { cleared: true }`, which also cancels the `usageLimit` countdown.
528
702
 
703
+ ### Media and socket recovery (0.8.0)
704
+
705
+ Recovery runs automatically. A send or receive transport that fails, or stays
706
+ disconnected for 3 seconds, requests new ICE credentials and restarts on the
707
+ same transport. A recovery still unfinished after 10 seconds (including the
708
+ request and browser call), or an older server without `restartIce`, uses the
709
+ normal reconnect and republish path.
710
+
711
+ `mediaReconnecting` carries `{ transportId, reconnecting }`: `true` when ICE
712
+ recovery starts and `false` when that recovery ends or its transport is discarded.
713
+ Use `connectionStateChanged` to distinguish recovery from a full reconnect.
714
+
715
+ After 15 seconds without any socket frame the SDK sends `ping`. If no frame
716
+ arrives for 8 seconds after it, the next 5-second check reconnects. Any reply
717
+ counts, including an older server's `unknown-method`. Background tabs may
718
+ delay timers. A browser `online` event wakes a pending reconnect immediately;
719
+ while connected it probes the socket. Closing releases the timers and listener.
720
+
721
+ ### Diagnostics for support (0.8.0)
722
+
723
+ ```ts
724
+ const report = await client.getDiagnostics();
725
+ sendToSupport(JSON.stringify(report));
726
+ ```
727
+
728
+ What Agora's log upload and Twilio's Insights are used for, kept on the device:
729
+ the SDK version (`SDK_VERSION`) and browser, the room, identity, role and
730
+ connection state, how each media transport is connected (UDP, TCP or relayed),
731
+ the round trip and upload now, what is published and received, and the last
732
+ 200 notable events (state changes, errors, recoveries, fallbacks, warnings) with
733
+ their times. It holds no token and no media.
734
+
735
+ ### Sound that browsers block until a tap (0.8.0)
736
+
737
+ ```ts
738
+ client.on('track', ({ kind, track }) => {
739
+ const el = kind === 'audio' ? new Audio() : videoFor(track);
740
+ el.srcObject = new MediaStream([track]);
741
+ void client.play(el); // instead of el.play()
742
+ });
743
+ client.on('autoplayFailed', ({ resume }) => showTapToUnmute(resume)); // optional
744
+ ```
745
+
746
+ Browsers often refuse sound until the viewer interacts with the page. `play()`
747
+ reports that (`autoplayFailed`, like Agora's and Tencent TRTC's), and plays
748
+ every refused element by itself on the viewer's next tap, click or key press,
749
+ so a viewer who touches anything hears the room.
750
+
751
+ ### Smoother watching on shaky networks (0.8.0)
752
+
753
+ ```ts
754
+ client.setPlayoutDelay(400); // an audience watching a stream: a little later, far fewer freezes
755
+ client.setPlayoutDelay(null); // people talking with each other: as little delay as the browser can keep
756
+ ```
757
+
758
+ Like Tencent TRTC's `playoutDelay`: every remote track is buffered about that
759
+ long before it plays, which rides out a mobile network's jitter. It applies to
760
+ what is received now and later; 0 to 4000 ms. Browsers before Chrome 114 keep
761
+ their own buffer.
762
+
763
+ ### Faster joins (0.8.0)
764
+
765
+ Everything already live in the room is subscribed to at once when the client
766
+ joins (it used to be one after another), so a room with six cameras shows them
767
+ together. Warm the connection while the user is still on the way in, as
768
+ LiveKit's `prepareConnection` does:
769
+
770
+ ```ts
771
+ StreamClient.prepareConnection(wsUrl); // on the room screen; never rejects
772
+ // ... later
773
+ await client.connect();
774
+ ```
775
+
776
+ From 0.9.0 the first picture comes sooner, with nothing to change in your app:
777
+ the join brings the media transports with it (controllers from 2026-10-20), and
778
+ neither the DTLS handshake nor a video's first layer waits for an
779
+ acknowledgement any more. Each is a round trip saved, 50-150 ms on a mobile
780
+ network, for the first publish and the first video alike.
781
+
782
+ ### Check before going live (0.8.0)
783
+
784
+ ```ts
785
+ // Your backend mints a host token for a test room nobody else is in.
786
+ const { wsUrl, token } = await fetch('/api/precall-token').then((r) => r.json());
787
+ const report = await StreamClient.preflight(wsUrl, token); // or { media: myStream, durationMs: 5000 }
788
+ if (!report.ok) showProblem(report.steps.find((s) => !s.ok)?.error);
789
+ else if (report.path?.relayed || report.path?.protocol === 'tcp') showHint('A firewall slows your connection');
790
+ else if (report.warnings.includes('mic-silent')) showHint('Your microphone sends no sound');
791
+ ```
792
+
793
+ Like Twilio's `runPreflight` and LiveKit's `ConnectionCheck`, it joins the
794
+ room, publishes for a few seconds (a tone and a moving picture it makes, or your
795
+ `media`, cloned so your tracks keep running) and leaves. The report has each
796
+ step (`join`, `media`) with its time or error, how media travelled (`path`: UDP
797
+ is best; TCP or a relay means a firewall in the way), `rttMs`, `lossPct`,
798
+ `uploadKbps`, `level`, and any camera or microphone `warnings`. It never
799
+ rejects. The session is real and billed like any other for its few seconds.
800
+
801
+ ### Camera and microphone warnings (0.8.0)
802
+
803
+ ```ts
804
+ client.on('mediaWarning', ({ code, active }) => {
805
+ if (code === 'mic-silent') showBanner(active, 'Your microphone sends no sound: check its mute switch');
806
+ if (code === 'camera-fps-low') showBanner(active, 'Your camera is dark: add light in front of you');
807
+ });
808
+ ```
809
+
810
+ Like Agora's exception events, each warning comes once when it starts
811
+ (`active: true`) and once when it clears:
812
+
813
+ | `code` | When |
814
+ | --- | --- |
815
+ | `mic-silent` | the microphone is on but sends exact silence for about 6 s: a mute switch, a broken or wrong device (a quiet room still has a noise floor) |
816
+ | `camera-fps-low` | the camera delivers under 15 fps for about 6 s when 24 or more were asked: usually a dark room, where cameras expose each frame longer. 20 fps is not a warning |
817
+ | `camera-interrupted`, `mic-interrupted` | the system took the device for more than 2 s: a phone call, another app |
818
+
819
+ Checked every 2 s while listened to.
820
+
821
+ ### Who is speaking (0.8.0)
822
+
823
+ ```ts
824
+ client.on('audioLevels', (levels) => {
825
+ for (const { identity, speaking } of levels) tileFor(identity).ring(speaking);
826
+ });
827
+ client.on('activeSpeakers', ({ speakers }) => showLarge(speakers[0])); // most active first
828
+ ```
829
+
830
+ `audioLevels` comes about four times a second while listened to, for every
831
+ remote microphone, music and shared audio this client receives and its own
832
+ microphone while on: `{ identity, producerId, source, level, speaking, local }`.
833
+ `level` is WebRTC's audio level (0 silence, 1 full scale); `speaking` is above
834
+ -35 dBov (`SPEAKING_LEVEL`). `activeSpeakers` ranks microphones the way Amazon
835
+ Chime does, so a short cough does not steal the large tile and the person who
836
+ keeps talking does; music never counts. Both are read in the browser from what
837
+ it receives, and nothing is read while nobody listens.
838
+
839
+ ### Poor connections: audio keeps going (0.8.0)
840
+
841
+ When a viewer's connection cannot carry video, the audio comes first, as in
842
+ Agora's audio-only stream fallback:
843
+
844
+ - **The media server cuts a video** this viewer's bandwidth cannot carry even
845
+ at its smallest size, while the sound keeps flowing, and says so.
846
+ - **The SDK pauses remote cameras** when the received sound itself breaks up
847
+ badly (a quarter of it lost over about 15 seconds), so what bandwidth there is
848
+ goes to the audio. Screen shares stay. The cameras come back once the
849
+ connection has recovered for 20 seconds, longer if it falls back again soon.
850
+
851
+ Either way the app is told, per video, so it can show the person's name instead
852
+ of a frozen picture:
853
+
854
+ ```ts
855
+ client.on('streamFallback', ({ producerId, identity, fallback, reason }) => {
856
+ tileFor(producerId).showPoorConnection(fallback); // reason: 'bandwidth' or 'audio-protection'
857
+ });
858
+ ```
859
+
860
+ The pause is on by default. Turn it off with `new StreamClient(url, token,
861
+ { audioOnlyFallback: false })` or `client.setAudioOnlyFallback(false)`, which
862
+ also brings back any camera held at that moment.
863
+
864
+ **The host's side (opt-in).** `client.setPublishFallback(true)` does the same
865
+ for what this client sends (Agora's `setLocalPublishFallbackOption`): when the
866
+ server reports this client's own sound breaking up on the way in, its camera is
867
+ paused (viewers see it paused) so the upload carries the voice, and resumed once
868
+ it recovers. `publishFallback { fallback }` says when. A camera the user or a
869
+ moderator switches off meanwhile stays off. `audioOnlyFallbackActive`
870
+ says whether it is holding cameras now. The server's bandwidth reports need the
871
+ controller and media node from 2026-10-20; older ones never send them.
872
+
529
873
  ### Network quality
530
874
 
531
875
  ```ts
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Adaptive stream: receive each remote video at the size it is shown, and not
3
+ * at all while nobody can see it.
4
+ *
5
+ * The app attaches the element that shows a video (`attachVideo`). From that
6
+ * element's size on screen (CSS pixels × devicePixelRatio, at most 2) the
7
+ * smallest simulcast layer that fills it sharply is chosen; while no attached
8
+ * element is visible (scrolled away, `display: none`, the tab in the
9
+ * background) the video is paused at the media server, so it costs no
10
+ * bandwidth and no decoding. This is what LiveKit calls adaptiveStream and
11
+ * Tencent TRTC "small stream + receiveWhenViewVisible": the single biggest
12
+ * saving on a viewer with several tiles, where every tile otherwise decodes
13
+ * the publisher's full-size layer.
14
+ *
15
+ * Pure decisions are exported for tests; the browser parts (observers,
16
+ * timers) are injected.
17
+ */
18
+ import type { VideoLayer } from './quality.js';
19
+ /** Where a video is drawn, in CSS pixels, and how it is fitted into that box. */
20
+ export interface ViewBox {
21
+ width: number;
22
+ height: number;
23
+ fit: 'cover' | 'contain';
24
+ }
25
+ /** Never ask for more than twice the CSS size: a 3× phone screen gains nothing visible from it. */
26
+ export declare const MAX_PIXEL_RATIO = 2;
27
+ /** A layer shown at most this much bigger than it is still looks sharp: avoids a whole layer for a few pixels. */
28
+ export declare const MAX_UPSCALE = 1.15;
29
+ /**
30
+ * The smallest layer that fills `box` sharply, or null when the layers are
31
+ * not known (an older publisher): then the server chooses, as before.
32
+ *
33
+ * With `cover` the picture is scaled until it fills the box (a part is
34
+ * cropped), with `contain` until it fits. Either way the scale is measured
35
+ * against each layer's own size, so a portrait layer list works too.
36
+ */
37
+ export declare function layerForBox(layers: readonly VideoLayer[], box: ViewBox, pixelRatio?: number): number | null;
38
+ /** What one video should get now: paused, or a layer (null = let the server choose). */
39
+ export interface AdaptiveWant {
40
+ visible: boolean;
41
+ spatialLayer: number | null;
42
+ }
43
+ /**
44
+ * The largest view among the visible ones decides; any visible view keeps the video flowing. A view not measured
45
+ * yet (just attached, before layout) counts as shown at the server's choice, so attaching never pauses a video
46
+ * for the moment before the browser has sized the element.
47
+ */
48
+ export declare function wantFor(views: readonly {
49
+ box: ViewBox;
50
+ visible: boolean;
51
+ measured?: boolean;
52
+ }[], layers: readonly VideoLayer[], pixelRatio: number, pageVisible: boolean): AdaptiveWant;
53
+ /** Pause a hidden video only after this long, so scrolling past or a quick tab switch does not stop it. */
54
+ export declare const PAUSE_AFTER_MS = 1500;
55
+ /** Go down a layer only after this long (going up is immediate): a tile shrinking for a moment keeps its picture. */
56
+ export declare const LOWER_AFTER_MS = 1200;
57
+ export interface AdaptiveHost {
58
+ /** The layers this producer's publisher declared (smallest first or any order); [] when unknown. */
59
+ layers(producerId: string): readonly VideoLayer[];
60
+ /** Apply a decision for one video. */
61
+ apply(producerId: string, want: AdaptiveWant): void;
62
+ /** For tests: the environment. */
63
+ pixelRatio?(): number;
64
+ pageVisible?(): boolean;
65
+ now?(): number;
66
+ setTimer?(fn: () => void, ms: number): unknown;
67
+ clearTimer?(handle: unknown): void;
68
+ /** For tests: stand-ins for ResizeObserver / IntersectionObserver / document visibility. */
69
+ observe?(element: Element, onChange: (box: ViewBox | null, visible: boolean | null) => void): () => void;
70
+ onPageVisibility?(listener: () => void): () => void;
71
+ }
72
+ /**
73
+ * The videos being shown, by producer id. Decisions are applied through the
74
+ * host; going up (or becoming visible) is applied at once, going down (or
75
+ * hiding) after a short delay.
76
+ */
77
+ export declare class AdaptiveVideos {
78
+ private readonly host;
79
+ private readonly entries;
80
+ private stopPage;
81
+ constructor(host: AdaptiveHost);
82
+ /** Show `producerId` in `element`; returns the function that stops. */
83
+ attach(producerId: string, element: Element): () => void;
84
+ detach(producerId: string, element: Element): void;
85
+ /** Whether the app shows this video through attachVideo (its decisions then apply to new consumers too). */
86
+ has(producerId: string): boolean;
87
+ /** The decision in force, for a consumer made (or made again) after it: null when the app does not attach it. */
88
+ current(producerId: string): AdaptiveWant | null;
89
+ /**
90
+ * A new consumer was made with `current()` applied (its layers, and paused when hidden): record that as applied,
91
+ * so nothing is sent twice, and drop a pending change made for the old one.
92
+ */
93
+ consumed(producerId: string): void;
94
+ /** Stop everything (the session closed). */
95
+ dispose(): void;
96
+ private decide;
97
+ private update;
98
+ private clear;
99
+ private observe;
100
+ private watchPage;
101
+ }
102
+ /** The page is visible (not a background tab). */
103
+ export declare function pageIsVisible(): boolean;
104
+ //# sourceMappingURL=adaptive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adaptive.d.ts","sourceRoot":"","sources":["../../src/adaptive.ts"],"names":[],"mappings":"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"}