@voltkit-rtc/client 0.7.3 → 0.8.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.
- package/CHANGELOG.md +57 -0
- package/README.md +244 -2
- package/dist/cjs/adaptive.d.ts +104 -0
- package/dist/cjs/adaptive.d.ts.map +1 -0
- package/dist/cjs/adaptive.js +263 -0
- package/dist/cjs/adaptive.js.map +1 -0
- package/dist/cjs/devices.d.ts +42 -0
- package/dist/cjs/devices.d.ts.map +1 -0
- package/dist/cjs/devices.js +56 -0
- package/dist/cjs/devices.js.map +1 -0
- package/dist/cjs/diagnostics.d.ts +65 -0
- package/dist/cjs/diagnostics.d.ts.map +1 -0
- package/dist/cjs/diagnostics.js +69 -0
- package/dist/cjs/diagnostics.js.map +1 -0
- package/dist/cjs/fallback.d.ts +81 -0
- package/dist/cjs/fallback.d.ts.map +1 -0
- package/dist/cjs/fallback.js +148 -0
- package/dist/cjs/fallback.js.map +1 -0
- package/dist/cjs/health.d.ts +70 -0
- package/dist/cjs/health.d.ts.map +1 -0
- package/dist/cjs/health.js +116 -0
- package/dist/cjs/health.js.map +1 -0
- package/dist/cjs/index.d.ts +339 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1098 -26
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/link.d.ts +99 -0
- package/dist/cjs/link.d.ts.map +1 -0
- package/dist/cjs/link.js +219 -0
- package/dist/cjs/link.js.map +1 -0
- package/dist/cjs/preflight.d.ts +79 -0
- package/dist/cjs/preflight.d.ts.map +1 -0
- package/dist/cjs/preflight.js +159 -0
- package/dist/cjs/preflight.js.map +1 -0
- package/dist/cjs/speakers.d.ts +34 -0
- package/dist/cjs/speakers.d.ts.map +1 -0
- package/dist/cjs/speakers.js +71 -0
- package/dist/cjs/speakers.js.map +1 -0
- package/dist/esm/adaptive.d.ts +104 -0
- package/dist/esm/adaptive.d.ts.map +1 -0
- package/dist/esm/adaptive.js +256 -0
- package/dist/esm/adaptive.js.map +1 -0
- package/dist/esm/devices.d.ts +42 -0
- package/dist/esm/devices.d.ts.map +1 -0
- package/dist/esm/devices.js +50 -0
- package/dist/esm/devices.js.map +1 -0
- package/dist/esm/diagnostics.d.ts +65 -0
- package/dist/esm/diagnostics.d.ts.map +1 -0
- package/dist/esm/diagnostics.js +65 -0
- package/dist/esm/diagnostics.js.map +1 -0
- package/dist/esm/fallback.d.ts +81 -0
- package/dist/esm/fallback.d.ts.map +1 -0
- package/dist/esm/fallback.js +142 -0
- package/dist/esm/fallback.js.map +1 -0
- package/dist/esm/health.d.ts +70 -0
- package/dist/esm/health.d.ts.map +1 -0
- package/dist/esm/health.js +111 -0
- package/dist/esm/health.js.map +1 -0
- package/dist/esm/index.d.ts +339 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1096 -26
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/link.d.ts +99 -0
- package/dist/esm/link.d.ts.map +1 -0
- package/dist/esm/link.js +214 -0
- package/dist/esm/link.js.map +1 -0
- package/dist/esm/preflight.d.ts +79 -0
- package/dist/esm/preflight.d.ts.map +1 -0
- package/dist/esm/preflight.js +153 -0
- package/dist/esm/preflight.js.map +1 -0
- package/dist/esm/speakers.d.ts +34 -0
- package/dist/esm/speakers.d.ts.map +1 -0
- package/dist/esm/speakers.js +67 -0
- package/dist/esm/speakers.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive.ts +295 -0
- package/src/devices.ts +70 -0
- package/src/diagnostics.ts +94 -0
- package/src/fallback.ts +169 -0
- package/src/health.ts +127 -0
- package/src/index.ts +1097 -27
- package/src/link.ts +255 -0
- package/src/preflight.ts +191 -0
- package/src/speakers.ts +66 -0
package/src/adaptive.ts
ADDED
|
@@ -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.8.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
|
+
}
|
package/src/fallback.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio-only fallback (docs/implementation-progress/RTC_PERF_PROGRESS.md H1).
|
|
3
|
+
*
|
|
4
|
+
* When this viewer's connection is so poor that even the sound breaks up, remote camera videos are paused at the
|
|
5
|
+
* server so what bandwidth there is goes to the audio, and they come back once the connection has recovered.
|
|
6
|
+
*
|
|
7
|
+
* - Agora's `setStreamFallbackOption(uid, AUDIO_ONLY)` drops to the low stream and then to audio only, and reports
|
|
8
|
+
* `stream-fallback` (fallback / recover).
|
|
9
|
+
* - Amazon Chime watches the audio it receives (`UnusableAudioWarningConnectionHealthPolicy`): about half of the
|
|
10
|
+
* expected packets missing over 15 one-second samples suggests stopping video, with a 60 s cool-down.
|
|
11
|
+
*
|
|
12
|
+
* This follows Chime's signal -- the audio actually received -- measured as RTP loss (sequence gaps) rather than
|
|
13
|
+
* packet counts, because Opus DTX sends almost nothing during silence and a count would read silence as loss.
|
|
14
|
+
* Extreme only: {@link FALLBACK_LOSS} of the audio lost over {@link FALLBACK_SAMPLES} samples, with at least
|
|
15
|
+
* {@link MIN_PACKETS} packets to judge by. Back after a hold ({@link FIRST_HOLD_MS}, doubled for each relapse within
|
|
16
|
+
* {@link RELAPSE_MS}, up to {@link MAX_HOLD_MS}) once the loss is under {@link RECOVER_LOSS} -- or nobody spoke
|
|
17
|
+
* enough to tell, in which case video comes back and a still-bad connection falls back again, holding longer.
|
|
18
|
+
*
|
|
19
|
+
* The SFU cutting a video for bandwidth by itself is reported separately, by the server (`reason: 'bandwidth'`).
|
|
20
|
+
* Pure: the decisions take counters and a clock.
|
|
21
|
+
*/
|
|
22
|
+
import type { StatsEntry } from './stats.js';
|
|
23
|
+
|
|
24
|
+
/** How often the received audio is sampled while the fallback could be needed. */
|
|
25
|
+
export const PROTECT_SAMPLE_MS = 3000;
|
|
26
|
+
/** This share of the audio lost (RTP sequence gaps) over the window: fall back. */
|
|
27
|
+
export const FALLBACK_LOSS = 0.25;
|
|
28
|
+
/** Below this share lost over the recovery window: video may come back. */
|
|
29
|
+
export const RECOVER_LOSS = 0.05;
|
|
30
|
+
/** Samples judged for falling back (15 s at 3 s) -- at least 3 of them. */
|
|
31
|
+
export const FALLBACK_SAMPLES = 5;
|
|
32
|
+
/** Samples judged for coming back (12 s). */
|
|
33
|
+
export const RECOVER_SAMPLES = 4;
|
|
34
|
+
/** Fewer audio packets than this over the fallback window are too few to judge. */
|
|
35
|
+
export const MIN_PACKETS = 150;
|
|
36
|
+
/** Fewer than this over the recovery window: nobody spoke enough to tell. */
|
|
37
|
+
export const MIN_RECOVER_PACKETS = 30;
|
|
38
|
+
/** The first fallback lasts at least this long. */
|
|
39
|
+
export const FIRST_HOLD_MS = 20_000;
|
|
40
|
+
/** A relapse doubles the hold, up to this. */
|
|
41
|
+
export const MAX_HOLD_MS = 160_000;
|
|
42
|
+
/** Falling back again within this long of coming back is a relapse. */
|
|
43
|
+
export const RELAPSE_MS = 120_000;
|
|
44
|
+
|
|
45
|
+
export interface AudioCounters {
|
|
46
|
+
received: number;
|
|
47
|
+
lost: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
51
|
+
|
|
52
|
+
/** The received-audio counters of a receive transport's report, summed over its audio streams. */
|
|
53
|
+
export function readInboundAudio(report: Iterable<StatsEntry>): AudioCounters {
|
|
54
|
+
let received = 0;
|
|
55
|
+
let lost = 0;
|
|
56
|
+
for (const e of report) {
|
|
57
|
+
if (typeof e !== 'object' || e === null || e.type !== 'inbound-rtp') continue;
|
|
58
|
+
if (e.kind !== 'audio' && e.mediaType !== 'audio') continue;
|
|
59
|
+
received += num(e.packetsReceived);
|
|
60
|
+
lost += Math.max(0, num(e.packetsLost));
|
|
61
|
+
}
|
|
62
|
+
return { received, lost };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The sent-audio counters of a send transport's report: packets sent, and how many of them the server reported
|
|
67
|
+
* lost (its RTCP receiver reports). For the publisher side (Agora's setLocalPublishFallbackOption): `received` is what
|
|
68
|
+
* got through.
|
|
69
|
+
*/
|
|
70
|
+
export function readOutboundAudio(report: Iterable<StatsEntry>): AudioCounters {
|
|
71
|
+
let sent = 0;
|
|
72
|
+
let lost = 0;
|
|
73
|
+
for (const e of report) {
|
|
74
|
+
if (typeof e !== 'object' || e === null) continue;
|
|
75
|
+
const audio = e.kind === 'audio' || e.mediaType === 'audio';
|
|
76
|
+
if (!audio) continue;
|
|
77
|
+
if (e.type === 'outbound-rtp') sent += num(e.packetsSent);
|
|
78
|
+
else if (e.type === 'remote-inbound-rtp') lost += Math.max(0, num(e.packetsLost));
|
|
79
|
+
}
|
|
80
|
+
return { received: Math.max(0, sent - lost), lost };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Decides when to fall back to audio only and when video may come back. */
|
|
84
|
+
export class AudioProtection {
|
|
85
|
+
private samples: AudioCounters[] = [];
|
|
86
|
+
private previous: AudioCounters | null = null;
|
|
87
|
+
private activeSince: number | null = null;
|
|
88
|
+
private hold = FIRST_HOLD_MS;
|
|
89
|
+
private recoveredAt: number | null = null;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
private readonly onChange: (active: boolean) => void,
|
|
93
|
+
private readonly now: () => number = Date.now,
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
/** Whether remote camera videos are held back now. */
|
|
97
|
+
get active(): boolean {
|
|
98
|
+
return this.activeSince !== null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How long the current (or next first) fallback holds, in ms. */
|
|
102
|
+
get holdMs(): number {
|
|
103
|
+
return this.hold;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One reading of the cumulative counters. They only go up on one transport; lower ones are a new transport, and
|
|
108
|
+
* judging starts over from them.
|
|
109
|
+
*/
|
|
110
|
+
feed(counters: AudioCounters): void {
|
|
111
|
+
const before = this.previous;
|
|
112
|
+
this.previous = counters;
|
|
113
|
+
if (before === null || counters.received < before.received || counters.lost < before.lost) {
|
|
114
|
+
this.samples = [];
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.samples.push({ received: counters.received - before.received, lost: counters.lost - before.lost });
|
|
118
|
+
if (this.samples.length > Math.max(FALLBACK_SAMPLES, RECOVER_SAMPLES)) this.samples.shift();
|
|
119
|
+
|
|
120
|
+
if (this.activeSince === null) {
|
|
121
|
+
const window = this.samples.slice(-FALLBACK_SAMPLES);
|
|
122
|
+
if (window.length < 3) return;
|
|
123
|
+
const { total, lost } = sum(window);
|
|
124
|
+
if (total >= MIN_PACKETS && lost / total >= FALLBACK_LOSS) this.activate();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (this.now() - this.activeSince < this.hold || this.samples.length < RECOVER_SAMPLES) return;
|
|
128
|
+
const { total, lost } = sum(this.samples.slice(-RECOVER_SAMPLES));
|
|
129
|
+
if (total < MIN_RECOVER_PACKETS || lost / total < RECOVER_LOSS) this.recover();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Come back now (the app turned the fallback off). */
|
|
133
|
+
release(): void {
|
|
134
|
+
if (this.activeSince !== null) this.recover();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The transports went away (reconnect, close): forget everything, without telling. */
|
|
138
|
+
reset(): void {
|
|
139
|
+
this.activeSince = null;
|
|
140
|
+
this.samples = [];
|
|
141
|
+
this.previous = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private activate(): void {
|
|
145
|
+
const now = this.now();
|
|
146
|
+
const relapse = this.recoveredAt !== null && now - this.recoveredAt < RELAPSE_MS;
|
|
147
|
+
this.hold = relapse ? Math.min(this.hold * 2, MAX_HOLD_MS) : FIRST_HOLD_MS;
|
|
148
|
+
this.activeSince = now;
|
|
149
|
+
this.samples = [];
|
|
150
|
+
this.onChange(true);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private recover(): void {
|
|
154
|
+
this.activeSince = null;
|
|
155
|
+
this.recoveredAt = this.now();
|
|
156
|
+
this.samples = [];
|
|
157
|
+
this.onChange(false);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sum(samples: readonly AudioCounters[]): { total: number; lost: number } {
|
|
162
|
+
let total = 0;
|
|
163
|
+
let lost = 0;
|
|
164
|
+
for (const s of samples) {
|
|
165
|
+
total += s.received + s.lost;
|
|
166
|
+
lost += s.lost;
|
|
167
|
+
}
|
|
168
|
+
return { total, lost };
|
|
169
|
+
}
|