@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
@@ -0,0 +1,295 @@
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
+
19
+ import type { VideoLayer } from './quality.js';
20
+
21
+ /** Where a video is drawn, in CSS pixels, and how it is fitted into that box. */
22
+ export interface ViewBox {
23
+ width: number;
24
+ height: number;
25
+ fit: 'cover' | 'contain';
26
+ }
27
+
28
+ /** Never ask for more than twice the CSS size: a 3× phone screen gains nothing visible from it. */
29
+ export const MAX_PIXEL_RATIO = 2;
30
+ /** A layer shown at most this much bigger than it is still looks sharp: avoids a whole layer for a few pixels. */
31
+ export const MAX_UPSCALE = 1.15;
32
+
33
+ /**
34
+ * The smallest layer that fills `box` sharply, or null when the layers are
35
+ * not known (an older publisher): then the server chooses, as before.
36
+ *
37
+ * With `cover` the picture is scaled until it fills the box (a part is
38
+ * cropped), with `contain` until it fits. Either way the scale is measured
39
+ * against each layer's own size, so a portrait layer list works too.
40
+ */
41
+ export function layerForBox(layers: readonly VideoLayer[], box: ViewBox, pixelRatio = 1): number | null {
42
+ const usable = layers.filter((l) => l.width > 0 && l.height > 0).sort((a, b) => a.width * a.height - b.width * b.height);
43
+ if (!usable.length || box.width <= 0 || box.height <= 0) return null;
44
+ const ratio = Math.min(MAX_PIXEL_RATIO, Math.max(1, pixelRatio || 1));
45
+ const w = box.width * ratio;
46
+ const h = box.height * ratio;
47
+ for (const layer of usable) {
48
+ const scale = box.fit === 'cover' ? Math.max(w / layer.width, h / layer.height) : Math.min(w / layer.width, h / layer.height);
49
+ // The layer is at most slightly upscaled on screen.
50
+ if (scale <= MAX_UPSCALE) return layer.spatialLayer;
51
+ }
52
+ return usable[usable.length - 1]!.spatialLayer;
53
+ }
54
+
55
+ /** What one video should get now: paused, or a layer (null = let the server choose). */
56
+ export interface AdaptiveWant {
57
+ visible: boolean;
58
+ spatialLayer: number | null;
59
+ }
60
+
61
+ /**
62
+ * The largest view among the visible ones decides; any visible view keeps the video flowing. A view not measured
63
+ * yet (just attached, before layout) counts as shown at the server's choice, so attaching never pauses a video
64
+ * for the moment before the browser has sized the element.
65
+ */
66
+ export function wantFor(views: readonly { box: ViewBox; visible: boolean; measured?: boolean }[], layers: readonly VideoLayer[], pixelRatio: number, pageVisible: boolean): AdaptiveWant {
67
+ if (pageVisible && views.some((v) => v.measured === false)) return { visible: true, spatialLayer: null };
68
+ const shown = pageVisible ? views.filter((v) => v.visible && v.box.width > 0 && v.box.height > 0) : [];
69
+ if (!shown.length) return { visible: false, spatialLayer: null };
70
+ let best: number | null = null;
71
+ for (const view of shown) {
72
+ const layer = layerForBox(layers, view.box, pixelRatio);
73
+ if (layer === null) return { visible: true, spatialLayer: null };
74
+ best = best === null ? layer : Math.max(best, layer);
75
+ }
76
+ return { visible: true, spatialLayer: best };
77
+ }
78
+
79
+ /** Pause a hidden video only after this long, so scrolling past or a quick tab switch does not stop it. */
80
+ export const PAUSE_AFTER_MS = 1500;
81
+ /** Go down a layer only after this long (going up is immediate): a tile shrinking for a moment keeps its picture. */
82
+ export const LOWER_AFTER_MS = 1200;
83
+
84
+ export interface AdaptiveHost {
85
+ /** The layers this producer's publisher declared (smallest first or any order); [] when unknown. */
86
+ layers(producerId: string): readonly VideoLayer[];
87
+ /** Apply a decision for one video. */
88
+ apply(producerId: string, want: AdaptiveWant): void;
89
+ /** For tests: the environment. */
90
+ pixelRatio?(): number;
91
+ pageVisible?(): boolean;
92
+ now?(): number;
93
+ setTimer?(fn: () => void, ms: number): unknown;
94
+ clearTimer?(handle: unknown): void;
95
+ /** For tests: stand-ins for ResizeObserver / IntersectionObserver / document visibility. */
96
+ observe?(element: Element, onChange: (box: ViewBox | null, visible: boolean | null) => void): () => void;
97
+ onPageVisibility?(listener: () => void): () => void;
98
+ }
99
+
100
+ interface View {
101
+ box: ViewBox;
102
+ visible: boolean;
103
+ measured: boolean;
104
+ stop: () => void;
105
+ }
106
+
107
+ interface Entry {
108
+ views: Map<Element, View>;
109
+ applied: AdaptiveWant | null;
110
+ timer: unknown;
111
+ }
112
+
113
+ const FLOWING: AdaptiveWant = { visible: true, spatialLayer: null };
114
+ const sameWant = (a: AdaptiveWant | null, b: AdaptiveWant) => a !== null && a.visible === b.visible && a.spatialLayer === b.spatialLayer;
115
+
116
+ /**
117
+ * The videos being shown, by producer id. Decisions are applied through the
118
+ * host; going up (or becoming visible) is applied at once, going down (or
119
+ * hiding) after a short delay.
120
+ */
121
+ export class AdaptiveVideos {
122
+ private readonly entries = new Map<string, Entry>();
123
+ private stopPage: (() => void) | null = null;
124
+
125
+ constructor(private readonly host: AdaptiveHost) {}
126
+
127
+ /** Show `producerId` in `element`; returns the function that stops. */
128
+ attach(producerId: string, element: Element): () => void {
129
+ let entry = this.entries.get(producerId);
130
+ if (!entry) {
131
+ entry = { views: new Map(), applied: null, timer: null };
132
+ this.entries.set(producerId, entry);
133
+ }
134
+ if (entry.views.has(element)) return () => this.detach(producerId, element);
135
+ const view: View = { box: { width: 0, height: 0, fit: 'contain' }, visible: true, measured: false, stop: () => undefined };
136
+ entry.views.set(element, view);
137
+ view.stop = this.observe(element, (box, visible) => {
138
+ if (box) {
139
+ view.box = box;
140
+ view.measured = true;
141
+ }
142
+ if (visible !== null) view.visible = visible;
143
+ this.update(producerId);
144
+ });
145
+ if (!this.stopPage) this.stopPage = this.watchPage(() => { for (const id of this.entries.keys()) this.update(id); });
146
+ this.update(producerId);
147
+ return () => this.detach(producerId, element);
148
+ }
149
+
150
+ detach(producerId: string, element: Element): void {
151
+ const entry = this.entries.get(producerId);
152
+ const view = entry?.views.get(element);
153
+ if (!entry || !view) return;
154
+ view.stop();
155
+ entry.views.delete(element);
156
+ if (entry.views.size) {
157
+ this.update(producerId);
158
+ return;
159
+ }
160
+ // Nothing shows it any more: back to how it was before (flowing, the server's choice).
161
+ this.clear(entry);
162
+ this.entries.delete(producerId);
163
+ if (entry.applied && !sameWant(entry.applied, { visible: true, spatialLayer: null })) this.host.apply(producerId, { visible: true, spatialLayer: null });
164
+ if (!this.entries.size) {
165
+ this.stopPage?.();
166
+ this.stopPage = null;
167
+ }
168
+ }
169
+
170
+ /** Whether the app shows this video through attachVideo (its decisions then apply to new consumers too). */
171
+ has(producerId: string): boolean {
172
+ return this.entries.has(producerId);
173
+ }
174
+
175
+ /** The decision in force, for a consumer made (or made again) after it: null when the app does not attach it. */
176
+ current(producerId: string): AdaptiveWant | null {
177
+ const entry = this.entries.get(producerId);
178
+ return entry ? this.decide(producerId, entry) : null;
179
+ }
180
+
181
+ /**
182
+ * A new consumer was made with `current()` applied (its layers, and paused when hidden): record that as applied,
183
+ * so nothing is sent twice, and drop a pending change made for the old one.
184
+ */
185
+ consumed(producerId: string): void {
186
+ const entry = this.entries.get(producerId);
187
+ if (!entry) return;
188
+ this.clear(entry);
189
+ entry.applied = this.decide(producerId, entry);
190
+ }
191
+
192
+ /** Stop everything (the session closed). */
193
+ dispose(): void {
194
+ for (const entry of this.entries.values()) {
195
+ this.clear(entry);
196
+ for (const view of entry.views.values()) view.stop();
197
+ }
198
+ this.entries.clear();
199
+ this.stopPage?.();
200
+ this.stopPage = null;
201
+ }
202
+
203
+ private decide(producerId: string, entry: Entry): AdaptiveWant {
204
+ return wantFor([...entry.views.values()], this.host.layers(producerId), this.host.pixelRatio?.() ?? 1, this.host.pageVisible?.() ?? true);
205
+ }
206
+
207
+ private update(producerId: string): void {
208
+ const entry = this.entries.get(producerId);
209
+ if (!entry) return;
210
+ const want = this.decide(producerId, entry);
211
+ // Nothing applied yet is the server's choice, flowing: asking for exactly that sends nothing.
212
+ if (sameWant(entry.applied ?? FLOWING, want)) {
213
+ this.clear(entry);
214
+ entry.applied ??= want;
215
+ return;
216
+ }
217
+ // Nothing applied yet means the video flows at the server's choice.
218
+ const prev = entry.applied ?? { visible: true, spatialLayer: null };
219
+ // Hiding, or a smaller layer than one applied, waits; anything else (showing, a bigger layer, a first layer) is at once.
220
+ const lower = (prev.visible && !want.visible) ||
221
+ (want.visible && prev.visible && want.spatialLayer !== null && prev.spatialLayer !== null && want.spatialLayer < prev.spatialLayer);
222
+ // Already waiting to lower: a repeated report must not restart the wait; it applies what is wanted when it runs out.
223
+ if (lower && entry.timer !== null) return;
224
+ this.clear(entry);
225
+ if (!lower) {
226
+ entry.applied = want;
227
+ this.host.apply(producerId, want);
228
+ return;
229
+ }
230
+ const delay = want.visible ? LOWER_AFTER_MS : PAUSE_AFTER_MS;
231
+ entry.timer = (this.host.setTimer ?? ((fn, ms) => setTimeout(fn, ms)))(() => {
232
+ entry.timer = null;
233
+ const now = this.decide(producerId, entry);
234
+ if (sameWant(entry.applied, now)) return;
235
+ entry.applied = now;
236
+ this.host.apply(producerId, now);
237
+ }, delay);
238
+ }
239
+
240
+ private clear(entry: Entry): void {
241
+ if (entry.timer !== null) (this.host.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>)))(entry.timer);
242
+ entry.timer = null;
243
+ }
244
+
245
+ private observe(element: Element, onChange: (box: ViewBox | null, visible: boolean | null) => void): () => void {
246
+ if (this.host.observe) return this.host.observe(element, onChange);
247
+ return observeElement(element, onChange);
248
+ }
249
+
250
+ private watchPage(listener: () => void): () => void {
251
+ if (this.host.onPageVisibility) return this.host.onPageVisibility(listener);
252
+ if (typeof document === 'undefined') return () => undefined;
253
+ document.addEventListener('visibilitychange', listener);
254
+ return () => document.removeEventListener('visibilitychange', listener);
255
+ }
256
+ }
257
+
258
+ /** How an element fits its picture: a video's object-fit, else contain. */
259
+ function fitOf(element: Element): 'cover' | 'contain' {
260
+ try {
261
+ const fit = typeof getComputedStyle === 'function' ? getComputedStyle(element).objectFit : '';
262
+ return fit === 'cover' || fit === 'fill' ? 'cover' : 'contain';
263
+ } catch {
264
+ return 'contain';
265
+ }
266
+ }
267
+
268
+ /** The browser's observers: size (ResizeObserver) and visibility on screen (IntersectionObserver). */
269
+ function observeElement(element: Element, onChange: (box: ViewBox | null, visible: boolean | null) => void): () => void {
270
+ const stops: (() => void)[] = [];
271
+ const measure = () => {
272
+ const rect = element.getBoundingClientRect();
273
+ onChange({ width: rect.width, height: rect.height, fit: fitOf(element) }, null);
274
+ };
275
+ if (typeof ResizeObserver !== 'undefined') {
276
+ const resize = new ResizeObserver(() => measure());
277
+ resize.observe(element);
278
+ stops.push(() => resize.disconnect());
279
+ }
280
+ if (typeof IntersectionObserver !== 'undefined') {
281
+ const seen = new IntersectionObserver((entries) => {
282
+ const last = entries[entries.length - 1];
283
+ if (last) onChange(null, last.isIntersecting);
284
+ });
285
+ seen.observe(element);
286
+ stops.push(() => seen.disconnect());
287
+ }
288
+ measure();
289
+ return () => stops.forEach((stop) => stop());
290
+ }
291
+
292
+ /** The page is visible (not a background tab). */
293
+ export function pageIsVisible(): boolean {
294
+ return typeof document === 'undefined' || document.visibilityState !== 'hidden';
295
+ }
package/src/devices.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Cameras, microphones and speakers (docs/implementation-progress/RTC_PERF_PROGRESS.md I1).
3
+ *
4
+ * What the big SDKs do, read from their code:
5
+ * - Amazon Chime (`DefaultDeviceController`): lists devices by kind, watches `devicechange` and tells observers
6
+ * which lists changed; a microphone that ends is replaced so audio keeps flowing, a camera that ends is stopped and
7
+ * reported (`audioInputStreamEnded` / `videoInputStreamEnded`) -- never swapped for another camera on its own.
8
+ * - LiveKit (`Room.handleDeviceChange`, `switchActiveDevice`): switches the published track to a chosen device; when
9
+ * the active audio device disappears it moves to the first available one.
10
+ * - Tencent TRTC: `DEVICE_CHANGED {type, action: add | remove | active}`.
11
+ *
12
+ * This module holds the pure parts: comparing device lists and building the constraints for a chosen device.
13
+ */
14
+
15
+ export type DeviceKind = 'audioinput' | 'videoinput' | 'audiooutput';
16
+
17
+ /** A camera, microphone or speaker, as the browser lists it. Labels are empty until a permission was granted. */
18
+ export interface MediaDevice {
19
+ deviceId: string;
20
+ groupId: string;
21
+ kind: DeviceKind;
22
+ label: string;
23
+ }
24
+
25
+ export interface DevicesChangedEvent {
26
+ /** Every device now. */
27
+ devices: MediaDevice[];
28
+ added: MediaDevice[];
29
+ removed: MediaDevice[];
30
+ }
31
+
32
+ /** The browser's device list, optionally of one kind. Empty outside a browser. */
33
+ export async function enumerate(kind?: DeviceKind): Promise<MediaDevice[]> {
34
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
35
+ if (!md?.enumerateDevices) return [];
36
+ const all = await md.enumerateDevices();
37
+ return all
38
+ .filter((d) => d.kind === 'audioinput' || d.kind === 'videoinput' || d.kind === 'audiooutput')
39
+ .filter((d) => !kind || d.kind === kind)
40
+ .map((d) => ({ deviceId: d.deviceId, groupId: d.groupId, kind: d.kind as DeviceKind, label: d.label }));
41
+ }
42
+
43
+ const key = (d: MediaDevice) => `${d.kind}:${d.deviceId}`;
44
+
45
+ /** What appeared and what went between two lists. */
46
+ export function diffDevices(before: readonly MediaDevice[], after: readonly MediaDevice[]): { added: MediaDevice[]; removed: MediaDevice[] } {
47
+ const was = new Set(before.map(key));
48
+ const now = new Set(after.map(key));
49
+ return {
50
+ added: after.filter((d) => !was.has(key(d))),
51
+ removed: before.filter((d) => !now.has(key(d))),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * The constraints for capturing `deviceId` (or the system default when null), keeping everything else the app asked
57
+ * for: size, frame rate, echo cancellation. `base` is what publish was given for that kind.
58
+ */
59
+ export function constraintsFor(base: boolean | MediaTrackConstraints | undefined, deviceId: string | null): MediaTrackConstraints {
60
+ const rest: MediaTrackConstraints = typeof base === 'object' && base !== null ? { ...base } : {};
61
+ delete rest.deviceId;
62
+ delete rest.groupId;
63
+ return deviceId === null ? rest : { ...rest, deviceId: { exact: deviceId } };
64
+ }
65
+
66
+ /** The device a live track captures, when it is a device capture at all (a canvas or a processed track has none). */
67
+ export function deviceIdOf(track: MediaStreamTrack | null | undefined): string | null {
68
+ const id = track?.getSettings?.().deviceId;
69
+ return typeof id === 'string' && id.length > 0 ? id : null;
70
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Diagnostics for support (docs/implementation-progress/RTC_PERF_PROGRESS.md S4).
3
+ *
4
+ * Agora uploads its SDK log on request (`enableLogUpload`), Twilio sends call telemetry to Insights, Stream Video
5
+ * traces session events to its servers. Shipping telemetry needs a backend; what helps today is a snapshot an app can
6
+ * attach to a support ticket: the SDK's recent events (a ring buffer) and its state now.
7
+ */
8
+
9
+ /** This package's version; kept equal to package.json by a test. */
10
+ export const SDK_VERSION = '0.9.0';
11
+
12
+ /** How many recent events the log keeps. */
13
+ export const DIAGNOSTICS_LOG_SIZE = 200;
14
+
15
+ export interface DiagnosticsEntry {
16
+ /** Unix ms. */
17
+ at: number;
18
+ event: string;
19
+ /** A short summary of what the event carried (no media objects). */
20
+ detail?: string;
21
+ }
22
+
23
+ /** The events worth keeping for support, and how to summarise each. */
24
+ const SUMMARY: Record<string, (args: unknown[]) => string | undefined> = {
25
+ connectionStateChanged: ([e]) => { const s = e as { previous?: string; state?: string }; return `${s.previous} → ${s.state}`; },
26
+ disconnected: ([reason]) => String(reason),
27
+ closed: ([reason]) => String(reason),
28
+ mustReconnect: ([e]) => (e as { reason?: string }).reason,
29
+ mediaReconnecting: ([e]) => { const m = e as { transportId?: string; reconnecting?: boolean }; return `${m.transportId} ${m.reconnecting ? 'recovering' : 'recovered'}`; },
30
+ error: ([e]) => {
31
+ const err = e as { code?: string; message?: string };
32
+ const message = err.message ?? '';
33
+ // RpcError's message already starts with its code.
34
+ return (err.code && !message.startsWith(`${err.code}:`) ? `${err.code}: ${message}` : message).slice(0, 300);
35
+ },
36
+ streamFallback: ([e]) => { const f = e as { producerId?: string; fallback?: boolean; reason?: string }; return `${f.producerId} ${f.fallback ? 'held' : 'back'} (${f.reason})`; },
37
+ mediaWarning: ([e]) => { const w = e as { code?: string; active?: boolean }; return `${w.code} ${w.active ? 'started' : 'cleared'}`; },
38
+ cpuConstrained: ([e]) => String((e as { constrained?: boolean }).constrained),
39
+ videoLimited: ([e]) => (e as { reason?: string }).reason,
40
+ localTrackEnded: ([e]) => { const t = e as { kind?: string; recovered?: boolean }; return `${t.kind} ${t.recovered ? 'recovered' : 'off'}`; },
41
+ devicesChanged: ([e]) => { const d = e as { added?: unknown[]; removed?: unknown[] }; return `+${d.added?.length ?? 0} -${d.removed?.length ?? 0}`; },
42
+ kicked: ([e]) => (e as { reason?: string }).reason,
43
+ roomClosed: ([e]) => (e as { reason?: string }).reason,
44
+ usageLimit: ([e]) => (e as { reason?: string }).reason,
45
+ autoplayFailed: () => undefined,
46
+ trackEnded: ([e]) => (e as { producerId?: string }).producerId,
47
+ track: ([e]) => { const t = e as { identity?: string; source?: string }; return `${t.identity} ${t.source}`; },
48
+ };
49
+
50
+ /** A ring buffer of the SDK's recent events. */
51
+ export class DiagnosticsLog {
52
+ private readonly entries: DiagnosticsEntry[] = [];
53
+
54
+ constructor(private readonly now: () => number = Date.now) {}
55
+
56
+ /** Keep an emitted event, when it is one worth keeping. */
57
+ record(event: string, args: unknown[]): void {
58
+ const summarize = SUMMARY[event];
59
+ if (!summarize) return;
60
+ let detail: string | undefined;
61
+ try {
62
+ detail = summarize(args);
63
+ } catch {
64
+ detail = undefined;
65
+ }
66
+ this.entries.push(detail === undefined ? { at: this.now(), event } : { at: this.now(), event, detail });
67
+ if (this.entries.length > DIAGNOSTICS_LOG_SIZE) this.entries.shift();
68
+ }
69
+
70
+ list(): DiagnosticsEntry[] {
71
+ return this.entries.map((e) => ({ ...e }));
72
+ }
73
+ }
74
+
75
+ export interface DiagnosticsTransport {
76
+ state: string;
77
+ path: { protocol: 'udp' | 'tcp' | null; relayed: boolean } | null;
78
+ }
79
+
80
+ export interface Diagnostics {
81
+ sdk: string;
82
+ at: number;
83
+ userAgent: string;
84
+ connectionState: string;
85
+ room: string | null;
86
+ identity: string | null;
87
+ role: string | null;
88
+ transports: { send: DiagnosticsTransport | null; recv: DiagnosticsTransport | null };
89
+ network: { rttMs: number | null; availableKbps: number | null } | null;
90
+ published: { source: string; kind: string; paused: boolean }[];
91
+ received: { identity: string; source: string; kind: string; paused: boolean }[];
92
+ /** Recent events, oldest first. */
93
+ log: DiagnosticsEntry[];
94
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Video effects, the parts that need no browser (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-5): settings,
3
+ * fitting a background image, the size segmentation runs at, and pacing it on a slow device. Kept apart from the
4
+ * WebGL and MediaPipe code so they are tested in Node.
5
+ */
6
+
7
+ /** A background picture: a URL, or anything the browser can draw. */
8
+ export type BackgroundImageSource = string | Blob | ImageBitmap | HTMLImageElement | HTMLCanvasElement | OffscreenCanvas;
9
+
10
+ /** What happens behind the person: nothing, a blur, or a picture. */
11
+ export type BackgroundSetting = 'none' | 'blur' | { image: BackgroundImageSource };
12
+
13
+ /** Beauty, each 0-1 (Agora's beauty effect has the same four). */
14
+ export interface BeautyOptions {
15
+ /** Skin smoothing. Default 0.5. */
16
+ smooth?: number;
17
+ /** Brightening. Default 0.3. */
18
+ brighten?: number;
19
+ /** A rosier skin. Default 0.1. */
20
+ redness?: number;
21
+ /** Sharper eyes, hair and edges (not skin). Default 0.3. */
22
+ sharpen?: number;
23
+ }
24
+
25
+ export interface BeautySettings {
26
+ smooth: number;
27
+ brighten: number;
28
+ redness: number;
29
+ sharpen: number;
30
+ }
31
+
32
+ export const DEFAULT_BEAUTY: Readonly<BeautySettings> = Object.freeze({ smooth: 0.5, brighten: 0.3, redness: 0.1, sharpen: 0.3 });
33
+
34
+ /** The blur behind the person, 1 (light) to 10 (strong). */
35
+ export const DEFAULT_BLUR = 5;
36
+
37
+ const unit = (value: unknown, fallback: number): number =>
38
+ typeof value === 'number' && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
39
+
40
+ /** Beauty settings from what the app passed: `true` is the defaults, `false`/`null` is off. */
41
+ export function beautySettings(options: BeautyOptions | boolean | null | undefined): BeautySettings | null {
42
+ if (!options) return null;
43
+ const o: BeautyOptions = options === true ? {} : options;
44
+ return {
45
+ smooth: unit(o.smooth, DEFAULT_BEAUTY.smooth),
46
+ brighten: unit(o.brighten, DEFAULT_BEAUTY.brighten),
47
+ redness: unit(o.redness, DEFAULT_BEAUTY.redness),
48
+ sharpen: unit(o.sharpen, DEFAULT_BEAUTY.sharpen),
49
+ };
50
+ }
51
+
52
+ /** A blur level from what the app passed: 1-10, rounded; anything else is the default. */
53
+ export function blurLevel(value: unknown): number {
54
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(10, Math.max(1, Math.round(value))) : DEFAULT_BLUR;
55
+ }
56
+
57
+ /**
58
+ * The blur's Gaussian sigma, in texels of the quarter-size picture it is computed on: a share of the picture's height,
59
+ * so a level looks the same at 480p and 1080p.
60
+ */
61
+ export function blurSigma(level: number, blurHeight: number): number {
62
+ return Math.max(1, (blurLevel(level) / 10) * (blurHeight / 14));
63
+ }
64
+
65
+ /** What the background setting asks of the pipeline. */
66
+ export function backgroundMode(setting: BackgroundSetting | undefined | null): 'none' | 'blur' | 'image' {
67
+ if (setting === 'blur') return 'blur';
68
+ if (setting && typeof setting === 'object' && 'image' in setting && setting.image) return 'image';
69
+ return 'none';
70
+ }
71
+
72
+ /**
73
+ * Fit a background picture to the frame the way CSS `object-fit: cover` does: it fills the frame, cropped evenly on
74
+ * the long side, never stretched. Returns the texture transform `imageUv = frameUv * scale + offset`.
75
+ */
76
+ export function coverFit(imageWidth: number, imageHeight: number, frameWidth: number, frameHeight: number): { sx: number; sy: number; ox: number; oy: number } {
77
+ if (!(imageWidth > 0 && imageHeight > 0 && frameWidth > 0 && frameHeight > 0)) return { sx: 1, sy: 1, ox: 0, oy: 0 };
78
+ const image = imageWidth / imageHeight;
79
+ const frame = frameWidth / frameHeight;
80
+ if (image > frame) {
81
+ // Wider than the frame: its sides are cropped.
82
+ const sx = frame / image;
83
+ return { sx, sy: 1, ox: (1 - sx) / 2, oy: 0 };
84
+ }
85
+ const sy = image / frame;
86
+ return { sx: 1, sy, ox: 0, oy: (1 - sy) / 2 };
87
+ }
88
+
89
+ /** The size the person is found at: the frame scaled so its longer side is `longSide` (the model's input). */
90
+ export function segmentationSize(width: number, height: number, longSide = 256): { width: number; height: number } {
91
+ if (!(width > 0 && height > 0)) return { width: longSide, height: longSide };
92
+ const scale = longSide / Math.max(width, height);
93
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
94
+ }
95
+
96
+ /** How fast the mask follows a new one: lower is steadier between frames, higher follows movement sooner. */
97
+ export const MASK_FOLLOW = 0.65;
98
+
99
+ /**
100
+ * Smooth the person mask between frames (Stream's temporal smoothing), against flicker at the edges, and write it as
101
+ * bytes for the GPU. `steady` keeps the running mask; a new size starts it over.
102
+ */
103
+ export function smoothMask(confidence: Float32Array, steady: Float32Array | null, out: Uint8Array, follow = MASK_FOLLOW): Float32Array {
104
+ const fresh = steady === null || steady.length !== confidence.length;
105
+ const running = fresh ? new Float32Array(confidence) : steady;
106
+ for (let i = 0; i < confidence.length; i += 1) {
107
+ const value = Math.min(1, Math.max(0, confidence[i]));
108
+ const next = fresh ? value : running[i] + (value - running[i]) * follow;
109
+ running[i] = next;
110
+ out[i] = Math.round(next * 255);
111
+ }
112
+ return running;
113
+ }
114
+
115
+ /** The most frames a mask is reused for before the device is called overloaded. */
116
+ export const MAX_SEGMENT_EVERY = 6;
117
+
118
+ /**
119
+ * Amazon Chime's adaptive background filter: finding the person is the expensive part, so when it takes too much of
120
+ * each frame's time it runs only every Nth frame and the last mask is reused -- the picture still runs at the camera's
121
+ * rate. N comes back down once it is cheap again. `overloaded` is the point where even that is not enough.
122
+ */
123
+ export class SegmentationPacer {
124
+ private average: number | null = null;
125
+ private changedAt = Number.NEGATIVE_INFINITY;
126
+ private every = 1;
127
+
128
+ constructor(private readonly maxEvery = MAX_SEGMENT_EVERY) {}
129
+
130
+ /** Run segmentation on every this-many frames. */
131
+ get interval(): number {
132
+ return this.every;
133
+ }
134
+
135
+ /** Average milliseconds one segmentation takes. */
136
+ get segmentMs(): number {
137
+ return this.average ?? 0;
138
+ }
139
+
140
+ shouldSegment(frameIndex: number): boolean {
141
+ return frameIndex % this.every === 0;
142
+ }
143
+
144
+ /** The share of the frame time segmentation takes now, given how often it runs. */
145
+ load(frameIntervalMs: number): number {
146
+ if (this.average === null || !(frameIntervalMs > 0)) return 0;
147
+ return this.average / (this.every * frameIntervalMs);
148
+ }
149
+
150
+ /** Whether segmenting even this rarely still takes too long. */
151
+ overloaded(frameIntervalMs: number): boolean {
152
+ return this.every >= this.maxEvery && this.load(frameIntervalMs) > 0.3;
153
+ }
154
+
155
+ /** One segmentation took `ms`, at a frame interval of `frameIntervalMs`. */
156
+ report(ms: number, frameIntervalMs: number, now: number): void {
157
+ if (!Number.isFinite(ms) || ms < 0) return;
158
+ this.average = this.average === null ? ms : this.average * 0.8 + ms * 0.2;
159
+ const load = this.load(frameIntervalMs);
160
+ if (load > 0.3 && this.every < this.maxEvery && now - this.changedAt >= 1000) {
161
+ this.every += 1;
162
+ this.changedAt = now;
163
+ } else if (load < 0.12 && this.every > 1 && now - this.changedAt >= 3000) {
164
+ this.every -= 1;
165
+ this.changedAt = now;
166
+ }
167
+ }
168
+ }
169
+
170
+ /** Timestamps MediaPipe's video mode accepts: in milliseconds and always increasing. */
171
+ export class MonotonicClock {
172
+ private last = 0;
173
+ next(now: number): number {
174
+ this.last = Math.max(this.last + 1, Math.round(now));
175
+ return this.last;
176
+ }
177
+ }