@voltkit-rtc/client 0.3.0 → 0.5.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 +110 -0
- package/README.md +196 -24
- package/dist/cjs/chat.d.ts +2 -0
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +23 -4
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/device.d.ts +85 -0
- package/dist/cjs/device.d.ts.map +1 -0
- package/dist/cjs/device.js +134 -0
- package/dist/cjs/device.js.map +1 -0
- package/dist/cjs/index.d.ts +212 -6
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +822 -52
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/music.d.ts +1 -1
- package/dist/cjs/music.d.ts.map +1 -1
- package/dist/cjs/quality.d.ts +43 -16
- package/dist/cjs/quality.d.ts.map +1 -1
- package/dist/cjs/quality.js +81 -31
- package/dist/cjs/quality.js.map +1 -1
- package/dist/cjs/stats.d.ts +151 -0
- package/dist/cjs/stats.d.ts.map +1 -0
- package/dist/cjs/stats.js +243 -0
- package/dist/cjs/stats.js.map +1 -0
- package/dist/esm/chat.d.ts +2 -0
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +23 -4
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/device.d.ts +85 -0
- package/dist/esm/device.d.ts.map +1 -0
- package/dist/esm/device.js +122 -0
- package/dist/esm/device.js.map +1 -0
- package/dist/esm/index.d.ts +212 -6
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +823 -53
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/music.d.ts +1 -1
- package/dist/esm/music.d.ts.map +1 -1
- package/dist/esm/quality.d.ts +43 -16
- package/dist/esm/quality.d.ts.map +1 -1
- package/dist/esm/quality.js +76 -31
- package/dist/esm/quality.js.map +1 -1
- package/dist/esm/stats.d.ts +151 -0
- package/dist/esm/stats.d.ts.map +1 -0
- package/dist/esm/stats.js +232 -0
- package/dist/esm/stats.js.map +1 -0
- package/package.json +1 -1
- package/src/chat.ts +21 -4
- package/src/device.ts +168 -0
- package/src/index.ts +879 -58
- package/src/music.ts +1 -1
- package/src/quality.ts +96 -32
- package/src/stats.ts +328 -0
package/src/music.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface MusicOptions {
|
|
|
22
22
|
monitor?: boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export type TrackSource = 'mic' | 'music' | 'camera' | 'screen';
|
|
25
|
+
export type TrackSource = 'mic' | 'music' | 'camera' | 'screen' | 'screen-audio';
|
|
26
26
|
|
|
27
27
|
let shared: AudioContext | null = null;
|
|
28
28
|
/** A media element can be wired to one audio graph, once, for its whole life. */
|
package/src/quality.ts
CHANGED
|
@@ -1,25 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Video quality tiers and the simulcast layers sent for each.
|
|
3
|
-
*
|
|
4
|
-
* A project can publish at 720p or 480p on any plan. 1080p uses more
|
|
5
|
-
* bandwidth, so the server allows it only for projects that have it enabled,
|
|
6
|
-
* and during the free trial; the join result says which (`video.maxQuality`),
|
|
7
|
-
* and the server caps each publisher's incoming bitrate to match.
|
|
8
|
-
*/
|
|
1
|
+
/** All video qualities are available on every plan; per-project safety restrictions still apply. */
|
|
9
2
|
|
|
10
3
|
export type VideoQuality = '1080p' | '720p' | '480p';
|
|
11
4
|
|
|
12
5
|
export const VIDEO_QUALITIES: readonly VideoQuality[] = ['1080p', '720p', '480p'];
|
|
13
6
|
|
|
7
|
+
/** How many simulcast layers to send at most. */
|
|
8
|
+
export type LayerCount = 1 | 2 | 3;
|
|
9
|
+
|
|
14
10
|
/** What the server allows this project to publish, from the join result. */
|
|
15
11
|
export interface VideoPolicy {
|
|
16
|
-
maxQuality:
|
|
12
|
+
maxQuality: VideoQuality;
|
|
13
|
+
maxFrameRate?: 30 | 60;
|
|
17
14
|
/** The server's cap on one publisher's incoming bitrate, in bits per second. */
|
|
18
15
|
maxBitrate?: number;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
/** Servers from before quality tiers send no policy; they get the default tier. */
|
|
22
|
-
export const DEFAULT_VIDEO_POLICY: VideoPolicy = { maxQuality: '
|
|
19
|
+
export const DEFAULT_VIDEO_POLICY: VideoPolicy = { maxQuality: '1080p', maxFrameRate: 60 };
|
|
23
20
|
|
|
24
21
|
/** Each tier's 16:9 frame. */
|
|
25
22
|
const TIER_SIZE: Record<VideoQuality, { width: number; height: number }> = {
|
|
@@ -32,23 +29,19 @@ const RANK: Record<VideoQuality, number> = { '480p': 0, '720p': 1, '1080p': 2 };
|
|
|
32
29
|
|
|
33
30
|
/** The lower of what was asked for and what the project may use. */
|
|
34
31
|
export function allowedQuality(requested: VideoQuality | undefined, policy: VideoPolicy = DEFAULT_VIDEO_POLICY): VideoQuality {
|
|
35
|
-
const max = policy.maxQuality
|
|
32
|
+
const max = policy.maxQuality in RANK ? policy.maxQuality : '1080p';
|
|
36
33
|
const wanted = requested && requested in RANK ? requested : max;
|
|
37
34
|
return RANK[wanted] <= RANK[max] ? wanted : max;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
/**
|
|
41
|
-
* The bitrate a layer of this many pixels gets, at up to 30 fps.
|
|
42
|
-
* About 1080p 2.5 Mbps, 720p 1.8 Mbps, 540p 1.2 Mbps, 480p 750 kbps,
|
|
43
|
-
* 360p 600 kbps, 240p 250 kbps, smaller 200 kbps.
|
|
44
|
-
*/
|
|
37
|
+
/** The authoritative 30 fps bitrate for a layer of this many pixels. */
|
|
45
38
|
export function layerBitrate(pixels: number): number {
|
|
46
|
-
if (pixels >= 1_800_000) return
|
|
47
|
-
if (pixels >= 800_000) return
|
|
48
|
-
if (pixels >= 450_000) return
|
|
49
|
-
if (pixels >= 360_000) return
|
|
50
|
-
if (pixels >= 200_000) return
|
|
51
|
-
if (pixels >= 100_000) return
|
|
39
|
+
if (pixels >= 1_800_000) return 6_000_000;
|
|
40
|
+
if (pixels >= 800_000) return 3_000_000;
|
|
41
|
+
if (pixels >= 450_000) return 2_000_000;
|
|
42
|
+
if (pixels >= 360_000) return 1_200_000;
|
|
43
|
+
if (pixels >= 200_000) return 800_000;
|
|
44
|
+
if (pixels >= 100_000) return 400_000;
|
|
52
45
|
return 200_000;
|
|
53
46
|
}
|
|
54
47
|
|
|
@@ -57,11 +50,26 @@ export interface VideoEncoding {
|
|
|
57
50
|
scaleResolutionDownBy: number;
|
|
58
51
|
maxBitrate: number;
|
|
59
52
|
scalabilityMode: 'L1T3';
|
|
53
|
+
maxFramerate: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The size the top layer is sent at: the camera picture scaled down to the
|
|
58
|
+
* tier (never up), keeping its shape. An unknown size (some tracks don't
|
|
59
|
+
* report one) is taken to be the tier's own frame.
|
|
60
|
+
*/
|
|
61
|
+
export function topLayerSize(width: number, height: number, quality: VideoQuality): { width: number; height: number; scale: number } {
|
|
62
|
+
const tier = TIER_SIZE[quality] ?? TIER_SIZE['720p'];
|
|
63
|
+
const known = width > 0 && height > 0;
|
|
64
|
+
const w = known ? width : tier.width;
|
|
65
|
+
const h = known ? height : tier.height;
|
|
66
|
+
const scale = Math.max(1, Math.max(w, h) / tier.width);
|
|
67
|
+
return { width: w / scale, height: h / scale, scale };
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/**
|
|
63
71
|
* The simulcast layers for a camera picture of `width` × `height` at
|
|
64
|
-
* `quality
|
|
72
|
+
* `quality`, sending at most `maxLayers` of them.
|
|
65
73
|
*
|
|
66
74
|
* The top layer is the picture scaled down to the tier (never up). The number
|
|
67
75
|
* of layers follows what browsers really send for a picture that size: Chrome
|
|
@@ -69,23 +77,79 @@ export interface VideoEncoding {
|
|
|
69
77
|
* when a picture is too small for the layers asked for it drops the TOP ones,
|
|
70
78
|
* which would leave viewers with half or a quarter of the resolution. Asking
|
|
71
79
|
* for exactly as many layers as will be sent keeps the full picture.
|
|
80
|
+
*
|
|
81
|
+
* Fewer layers are dropped from the bottom: two are half and full size, so the
|
|
82
|
+
* full picture is always one of them. That matters on a busy phone, where the
|
|
83
|
+
* browser shrinks the picture and then sends fewer layers: from two it keeps
|
|
84
|
+
* the full one, from three it lost it.
|
|
72
85
|
*/
|
|
73
|
-
export function videoEncodings(width: number, height: number, quality: VideoQuality): VideoEncoding[] {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const top = (w / scale) * (h / scale);
|
|
80
|
-
const layers = top >= 960 * 540 ? 3 : top >= 480 * 270 ? 2 : 1;
|
|
86
|
+
export function videoEncodings(width: number, height: number, quality: VideoQuality, maxLayers: LayerCount = 3, frameRate: 30 | 60 = 30): VideoEncoding[] {
|
|
87
|
+
const { width: w, height: h, scale } = topLayerSize(width, height, quality);
|
|
88
|
+
const top = w * h;
|
|
89
|
+
const possible = top >= 960 * 540 ? 3 : top >= 480 * 270 ? 2 : 1;
|
|
90
|
+
const wanted = maxLayers === 1 || maxLayers === 2 ? maxLayers : 3;
|
|
91
|
+
const layers = Math.min(wanted, possible);
|
|
81
92
|
const round = (value: number) => Math.round(value * 1000) / 1000;
|
|
82
93
|
const layer = (divisor: number, rid?: string): VideoEncoding => ({
|
|
83
94
|
...(rid ? { rid } : {}),
|
|
84
95
|
scaleResolutionDownBy: round(scale * divisor),
|
|
85
|
-
maxBitrate: layerBitrate(top / (divisor * divisor)),
|
|
96
|
+
maxBitrate: layerBitrate(top / (divisor * divisor)) * (divisor === 1 && frameRate === 60 ? 1.5 : 1),
|
|
97
|
+
maxFramerate: divisor === 1 ? frameRate : 30,
|
|
86
98
|
scalabilityMode: 'L1T3',
|
|
87
99
|
});
|
|
88
100
|
if (layers === 3) return [layer(4, 'q'), layer(2, 'h'), layer(1, 'f')];
|
|
89
101
|
if (layers === 2) return [layer(2, 'h'), layer(1, 'f')];
|
|
90
102
|
return [layer(1)];
|
|
91
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether two sets of layers are laid out alike: as many layers, each scaled
|
|
107
|
+
* the same. A track can then be swapped under the same producer without
|
|
108
|
+
* renegotiating; anything else needs a new producer.
|
|
109
|
+
*/
|
|
110
|
+
export function sameLayout(a: readonly VideoEncoding[], b: readonly VideoEncoding[]): boolean {
|
|
111
|
+
return a.length === b.length && a.every((encoding, i) => encoding.scaleResolutionDownBy === b[i].scaleResolutionDownBy);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Actual producer layer dimensions, smallest first; includes portrait and screen video. */
|
|
115
|
+
export interface VideoLayer {
|
|
116
|
+
spatialLayer: number;
|
|
117
|
+
width: number;
|
|
118
|
+
height: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type ReceiveQuality = 'auto' | VideoQuality;
|
|
122
|
+
export type ScreenMode = 'detail' | 'motion';
|
|
123
|
+
|
|
124
|
+
/** Never invent layers when an older producer does not describe them. */
|
|
125
|
+
export function receiveSpatialLayer(layers: readonly VideoLayer[], quality: VideoQuality): number {
|
|
126
|
+
const valid = layers.filter((l) => Number.isInteger(l.spatialLayer) && l.spatialLayer >= 0 &&
|
|
127
|
+
Number.isFinite(l.width) && l.width > 0 && Number.isFinite(l.height) && l.height > 0)
|
|
128
|
+
.sort((a, b) => a.width * a.height - b.width * b.height);
|
|
129
|
+
if (!valid.length) throw new Error('producer has no video layer metadata');
|
|
130
|
+
const tier = TIER_SIZE[quality];
|
|
131
|
+
const fitting = valid.filter((l) => Math.max(l.width, l.height) <= tier.width + 1 && Math.min(l.width, l.height) <= tier.height + 1);
|
|
132
|
+
return (fitting.at(-1) ?? valid[0]).spatialLayer;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function describeVideoLayers(width: number, height: number, encodings: readonly VideoEncoding[], quality: VideoQuality = '1080p'): VideoLayer[] {
|
|
136
|
+
const fallback = TIER_SIZE[quality];
|
|
137
|
+
const w = width > 0 ? width : fallback.width;
|
|
138
|
+
const h = height > 0 ? height : fallback.height;
|
|
139
|
+
return encodings.map((encoding, spatialLayer) => ({ spatialLayer, width: Math.round(w / encoding.scaleResolutionDownBy), height: Math.round(h / encoding.scaleResolutionDownBy) }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Screen frames are capped to native 1080p, never enlarged, with two adaptive copies. */
|
|
143
|
+
export function screenEncodings(width: number, height: number, mode: ScreenMode = 'detail'): VideoEncoding[] {
|
|
144
|
+
const nativeWidth = width > 0 ? width : 1920;
|
|
145
|
+
const nativeHeight = height > 0 ? height : 1080;
|
|
146
|
+
const scale = Math.max(1, Math.max(nativeWidth, nativeHeight) / 1920, Math.min(nativeWidth, nativeHeight) / 1080);
|
|
147
|
+
const w = nativeWidth / scale;
|
|
148
|
+
const h = nativeHeight / scale;
|
|
149
|
+
const layer = (divisor: number, rid?: string): VideoEncoding => ({
|
|
150
|
+
...(rid ? { rid } : {}), scaleResolutionDownBy: scale * divisor,
|
|
151
|
+
maxBitrate: divisor === 1 ? (mode === 'motion' ? 6_000_000 : 2_500_000) : 600_000,
|
|
152
|
+
maxFramerate: mode === 'motion' ? 30 : 15, scalabilityMode: 'L1T3',
|
|
153
|
+
});
|
|
154
|
+
return w * h >= 480 * 270 ? [layer(2, 'h'), layer(1, 'f')] : [layer(1)];
|
|
155
|
+
}
|
package/src/stats.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the camera is really sending, and what a viewer is really receiving,
|
|
3
|
+
* read from the browser's WebRTC statistics.
|
|
4
|
+
*
|
|
5
|
+
* The parsers are pure: they take the stats objects a `getStats()` report
|
|
6
|
+
* holds and a baseline of byte counts from the previous call, so they can be
|
|
7
|
+
* tested with plain objects. Bitrates are the bytes sent or received since
|
|
8
|
+
* the previous call for the same key, over the time between the two reports'
|
|
9
|
+
* timestamps: 0 on the first call.
|
|
10
|
+
*/
|
|
11
|
+
import type { VideoQuality } from './quality.js';
|
|
12
|
+
|
|
13
|
+
/** One simulcast layer of the camera video. */
|
|
14
|
+
export interface VideoLayerStats {
|
|
15
|
+
/** The layer's RTP stream id, as the browser reports it (`r0` is the smallest). */
|
|
16
|
+
rid?: string;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
fps: number;
|
|
20
|
+
/** Bits per second since the previous call; 0 on the first. */
|
|
21
|
+
bitrate: number;
|
|
22
|
+
/** Switched on, and frames or bytes are going out. */
|
|
23
|
+
active: boolean;
|
|
24
|
+
/** Why the browser is sending less than asked: `cpu`, `bandwidth`, `other` or `none`. */
|
|
25
|
+
qualityLimitation?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the camera is sending. The top-level fields describe the biggest active layer. */
|
|
29
|
+
export interface VideoSendStats {
|
|
30
|
+
/** `VP8` or `H264`, when the browser says. */
|
|
31
|
+
codec?: string;
|
|
32
|
+
/** The quality being published, after the project's limit. */
|
|
33
|
+
quality: VideoQuality;
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
fps: number;
|
|
37
|
+
bitrate: number;
|
|
38
|
+
qualityLimitation?: string;
|
|
39
|
+
/** The browser's estimate of the upload it can use, in bits per second. */
|
|
40
|
+
availableOutgoingBitrate?: number;
|
|
41
|
+
/** Every layer, smallest first. */
|
|
42
|
+
layers: VideoLayerStats[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What this client is receiving of someone's video. */
|
|
46
|
+
export interface VideoReceiveStats {
|
|
47
|
+
producerId: string;
|
|
48
|
+
codec?: string;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
fps: number;
|
|
52
|
+
/** Bits per second since the previous call for this video; 0 on the first. */
|
|
53
|
+
bitrate: number;
|
|
54
|
+
/** Packets lost since this video started. */
|
|
55
|
+
packetsLost: number;
|
|
56
|
+
/** Seconds, as the browser reports it. */
|
|
57
|
+
jitter?: number;
|
|
58
|
+
/** How many times the picture froze, where the browser counts it. */
|
|
59
|
+
freezeCount?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A stats object as a report holds it. Only `type` is relied on. */
|
|
63
|
+
export type StatsEntry = Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
/** Byte counts and their report time from the previous call, per key. The caller keeps it. */
|
|
66
|
+
export type StatsBaseline = Map<string, { bytes: number; timestamp: number }>;
|
|
67
|
+
|
|
68
|
+
const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
69
|
+
const optNum = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
|
70
|
+
const optStr = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined);
|
|
71
|
+
const oneDecimal = (value: number): number => Math.round(value * 10) / 10;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bits per second from `bytes` at `timestamp` (ms), against what `key` had
|
|
75
|
+
* last time, and remembers this reading for the next call. 0 on the first
|
|
76
|
+
* reading, and whenever the counter went backwards (a new stream).
|
|
77
|
+
*/
|
|
78
|
+
export function bitrateSince(baseline: StatsBaseline, key: string, bytes: number, timestamp: number): number {
|
|
79
|
+
const previous = baseline.get(key);
|
|
80
|
+
baseline.set(key, { bytes, timestamp });
|
|
81
|
+
if (!previous) return 0;
|
|
82
|
+
const seconds = (timestamp - previous.timestamp) / 1000;
|
|
83
|
+
const delta = bytes - previous.bytes;
|
|
84
|
+
if (seconds <= 0 || delta < 0) return 0;
|
|
85
|
+
return Math.round((delta * 8) / seconds);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Forget every key `keep` turns down: the streams they counted are gone. */
|
|
89
|
+
export function pruneBaseline(baseline: StatsBaseline, keep: (key: string) => boolean): void {
|
|
90
|
+
for (const key of [...baseline.keys()]) {
|
|
91
|
+
if (!keep(key)) baseline.delete(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const isVideo = (entry: StatsEntry): boolean => entry.kind === 'video' || entry.mediaType === 'video';
|
|
96
|
+
|
|
97
|
+
/** `VP8` from the codec entry an RTP stream points at. */
|
|
98
|
+
function codecOf(byId: Map<string, StatsEntry>, entry: StatsEntry | undefined): string | undefined {
|
|
99
|
+
const codec = entry && typeof entry.codecId === 'string' ? byId.get(entry.codecId) : undefined;
|
|
100
|
+
const mime = optStr(codec?.mimeType);
|
|
101
|
+
return mime ? mime.replace(/^video\//i, '') : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The upload estimate on the candidate pair in use. */
|
|
105
|
+
function availableOutgoing(entries: StatsEntry[], byId: Map<string, StatsEntry>): number | undefined {
|
|
106
|
+
for (const transport of entries.filter((e) => e.type === 'transport')) {
|
|
107
|
+
const pair = typeof transport.selectedCandidatePairId === 'string' ? byId.get(transport.selectedCandidatePairId) : undefined;
|
|
108
|
+
const bitrate = optNum(pair?.availableOutgoingBitrate);
|
|
109
|
+
if (bitrate !== undefined) return bitrate;
|
|
110
|
+
}
|
|
111
|
+
const pairs = entries.filter((e) => e.type === 'candidate-pair' && optNum(e.availableOutgoingBitrate) !== undefined);
|
|
112
|
+
const chosen = pairs.find((p) => p.nominated === true && p.state === 'succeeded') ?? pairs.find((p) => p.nominated === true) ?? pairs[0];
|
|
113
|
+
return optNum(chosen?.availableOutgoingBitrate);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const area = (layer: { width: number; height: number }): number => layer.width * layer.height;
|
|
117
|
+
|
|
118
|
+
/** r0, r1, r2 in order; layers without a rid by size. */
|
|
119
|
+
function layerOrder(a: VideoLayerStats, b: VideoLayerStats): number {
|
|
120
|
+
if (a.rid !== undefined && b.rid !== undefined && a.rid !== b.rid) {
|
|
121
|
+
return a.rid.localeCompare(b.rid, 'en', { numeric: true });
|
|
122
|
+
}
|
|
123
|
+
return area(a) - area(b);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The camera's send statistics from a sender's report. `keyPrefix` names the
|
|
128
|
+
* producer, so a new producer's counters never meet an old one's.
|
|
129
|
+
*/
|
|
130
|
+
export function parseSendStats(
|
|
131
|
+
report: Iterable<StatsEntry>,
|
|
132
|
+
options: { quality: VideoQuality; baseline: StatsBaseline; keyPrefix: string },
|
|
133
|
+
): VideoSendStats {
|
|
134
|
+
const entries = [...report].filter((e): e is StatsEntry => typeof e === 'object' && e !== null);
|
|
135
|
+
const byId = new Map<string, StatsEntry>();
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
if (typeof entry.id === 'string') byId.set(entry.id, entry);
|
|
138
|
+
}
|
|
139
|
+
const outbound = entries.filter((e) => e.type === 'outbound-rtp' && isVideo(e));
|
|
140
|
+
|
|
141
|
+
const layers = outbound.map((entry): VideoLayerStats & { entry: StatsEntry } => {
|
|
142
|
+
const rid = optStr(entry.rid);
|
|
143
|
+
const key = `${options.keyPrefix}${rid ?? String(entry.ssrc ?? entry.id ?? '')}`;
|
|
144
|
+
const bitrate = bitrateSince(options.baseline, key, num(entry.bytesSent), num(entry.timestamp));
|
|
145
|
+
const fps = oneDecimal(num(entry.framesPerSecond));
|
|
146
|
+
const reason = optStr(entry.qualityLimitationReason);
|
|
147
|
+
return {
|
|
148
|
+
...(rid !== undefined ? { rid } : {}),
|
|
149
|
+
width: num(entry.frameWidth),
|
|
150
|
+
height: num(entry.frameHeight),
|
|
151
|
+
fps,
|
|
152
|
+
bitrate,
|
|
153
|
+
active: entry.active !== false && (fps > 0 || bitrate > 0),
|
|
154
|
+
...(reason !== undefined ? { qualityLimitation: reason } : {}),
|
|
155
|
+
entry,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
layers.sort(layerOrder);
|
|
159
|
+
|
|
160
|
+
// The biggest layer going out is what the best-connected viewer gets. With
|
|
161
|
+
// nothing going out (paused, or the very first frames), the biggest there is.
|
|
162
|
+
const pool = layers.some((l) => l.active) ? layers.filter((l) => l.active) : layers;
|
|
163
|
+
const top = pool.reduce<(typeof layers)[number] | undefined>((best, l) => (!best || area(l) > area(best) ? l : best), undefined);
|
|
164
|
+
const codec = codecOf(byId, top?.entry) ?? outbound.map((e) => codecOf(byId, e)).find(Boolean);
|
|
165
|
+
const available = availableOutgoing(entries, byId);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
...(codec !== undefined ? { codec } : {}),
|
|
169
|
+
quality: options.quality,
|
|
170
|
+
width: top?.width ?? 0,
|
|
171
|
+
height: top?.height ?? 0,
|
|
172
|
+
fps: top?.fps ?? 0,
|
|
173
|
+
bitrate: top?.bitrate ?? 0,
|
|
174
|
+
...(top?.qualityLimitation !== undefined ? { qualityLimitation: top.qualityLimitation } : {}),
|
|
175
|
+
...(available !== undefined ? { availableOutgoingBitrate: available } : {}),
|
|
176
|
+
layers: layers.map(({ entry: _entry, ...layer }) => layer),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One consumed video's receive statistics from a receiver's report; null when it has none. */
|
|
181
|
+
export function parseReceiveStats(
|
|
182
|
+
report: Iterable<StatsEntry>,
|
|
183
|
+
options: { producerId: string; baseline: StatsBaseline; key: string },
|
|
184
|
+
): VideoReceiveStats | null {
|
|
185
|
+
const entries = [...report].filter((e): e is StatsEntry => typeof e === 'object' && e !== null);
|
|
186
|
+
const byId = new Map<string, StatsEntry>();
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
if (typeof entry.id === 'string') byId.set(entry.id, entry);
|
|
189
|
+
}
|
|
190
|
+
const inbound = entries
|
|
191
|
+
.filter((e) => e.type === 'inbound-rtp' && isVideo(e))
|
|
192
|
+
.sort((a, b) => num(b.bytesReceived) - num(a.bytesReceived))[0];
|
|
193
|
+
if (!inbound) return null;
|
|
194
|
+
const codec = codecOf(byId, inbound);
|
|
195
|
+
const jitter = optNum(inbound.jitter);
|
|
196
|
+
const freezeCount = optNum(inbound.freezeCount);
|
|
197
|
+
return {
|
|
198
|
+
producerId: options.producerId,
|
|
199
|
+
...(codec !== undefined ? { codec } : {}),
|
|
200
|
+
width: num(inbound.frameWidth),
|
|
201
|
+
height: num(inbound.frameHeight),
|
|
202
|
+
fps: oneDecimal(num(inbound.framesPerSecond)),
|
|
203
|
+
bitrate: bitrateSince(options.baseline, options.key, num(inbound.bytesReceived), num(inbound.timestamp)),
|
|
204
|
+
packetsLost: num(inbound.packetsLost),
|
|
205
|
+
...(jitter !== undefined ? { jitter } : {}),
|
|
206
|
+
...(freezeCount !== undefined ? { freezeCount } : {}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ------------------------------------------------------------ videoLimited
|
|
211
|
+
|
|
212
|
+
/** What is holding the camera video back: the device, the upload, something else, or nothing. */
|
|
213
|
+
export type VideoLimitReason = 'cpu' | 'bandwidth' | 'other' | 'none';
|
|
214
|
+
|
|
215
|
+
export interface VideoLimitedEvent {
|
|
216
|
+
reason: VideoLimitReason;
|
|
217
|
+
/** The biggest layer going out when this was decided. */
|
|
218
|
+
width: number;
|
|
219
|
+
height: number;
|
|
220
|
+
fps: number;
|
|
221
|
+
bitrate: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The reason across the layers going out (all of them when none is): the
|
|
226
|
+
* device first, since it costs the most picture, then the upload, then
|
|
227
|
+
* anything else.
|
|
228
|
+
*/
|
|
229
|
+
export function limitReasonOf(layers: readonly VideoLayerStats[]): VideoLimitReason {
|
|
230
|
+
const pool = layers.some((l) => l.active) ? layers.filter((l) => l.active) : layers;
|
|
231
|
+
const reasons = new Set(pool.map((l) => l.qualityLimitation));
|
|
232
|
+
if (reasons.has('cpu')) return 'cpu';
|
|
233
|
+
if (reasons.has('bandwidth')) return 'bandwidth';
|
|
234
|
+
if (reasons.has('other')) return 'other';
|
|
235
|
+
return 'none';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Samples a new reason must hold for before it is reported: about 4 s at one every 2 s. */
|
|
239
|
+
export const LIMIT_SAMPLES_TO_REPORT = 2;
|
|
240
|
+
|
|
241
|
+
export interface LimitTracker {
|
|
242
|
+
/** The last reason reported; null before the first. */
|
|
243
|
+
reported: VideoLimitReason | null;
|
|
244
|
+
/** A different reason seen lately, and for how many samples in a row. */
|
|
245
|
+
pending: VideoLimitReason | null;
|
|
246
|
+
streak: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const initialLimitTracker = (): LimitTracker => ({ reported: null, pending: null, streak: 0 });
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Feed one sample's reason. Returns the reason to report, if any: one that
|
|
253
|
+
* differs from the last reported and held for LIMIT_SAMPLES_TO_REPORT samples
|
|
254
|
+
* in a row, so a brief spike stays quiet. Before anything is reported the
|
|
255
|
+
* state counts as 'none', so 'none' is never the first event.
|
|
256
|
+
*/
|
|
257
|
+
export function trackLimit(tracker: LimitTracker, reason: VideoLimitReason): { tracker: LimitTracker; report: VideoLimitReason | null } {
|
|
258
|
+
const current = tracker.reported ?? 'none';
|
|
259
|
+
if (reason === current) return { tracker: { reported: tracker.reported, pending: null, streak: 0 }, report: null };
|
|
260
|
+
const streak = reason === tracker.pending ? tracker.streak + 1 : 1;
|
|
261
|
+
if (streak >= LIMIT_SAMPLES_TO_REPORT) return { tracker: { reported: reason, pending: null, streak: 0 }, report: reason };
|
|
262
|
+
return { tracker: { reported: tracker.reported, pending: reason, streak }, report: null };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** How often the camera is sampled while someone listens for `videoLimited`. */
|
|
266
|
+
export const LIMIT_SAMPLE_MS = 2000;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Samples the camera's send statistics on a timer while it is wanted and
|
|
270
|
+
* reports changes in what holds the video back. The stats it reads keep their
|
|
271
|
+
* own baseline, so its sampling never disturbs the bitrates an app reads with
|
|
272
|
+
* `getVideoStats()`.
|
|
273
|
+
*/
|
|
274
|
+
export class VideoLimitWatch {
|
|
275
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
276
|
+
private tracker = initialLimitTracker();
|
|
277
|
+
private sampling = false;
|
|
278
|
+
private readonly baseline: StatsBaseline = new Map();
|
|
279
|
+
|
|
280
|
+
constructor(
|
|
281
|
+
private readonly sample: (baseline: StatsBaseline) => Promise<VideoSendStats | null>,
|
|
282
|
+
private readonly report: (event: VideoLimitedEvent) => void,
|
|
283
|
+
private readonly intervalMs = LIMIT_SAMPLE_MS,
|
|
284
|
+
) {}
|
|
285
|
+
|
|
286
|
+
get running(): boolean {
|
|
287
|
+
return this.timer !== null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Start or stop sampling. Safe to call as often as anything changes. */
|
|
291
|
+
set(wanted: boolean): void {
|
|
292
|
+
if (wanted && !this.timer) {
|
|
293
|
+
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
294
|
+
} else if (!wanted && this.timer) {
|
|
295
|
+
clearInterval(this.timer);
|
|
296
|
+
this.timer = null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Stop, and start over as if nothing had been reported. Returns the event
|
|
302
|
+
* that clears a limitation still being reported, for when publishing stops.
|
|
303
|
+
*/
|
|
304
|
+
reset(): VideoLimitedEvent | null {
|
|
305
|
+
this.set(false);
|
|
306
|
+
const was = this.tracker.reported;
|
|
307
|
+
this.tracker = initialLimitTracker();
|
|
308
|
+
this.baseline.clear();
|
|
309
|
+
return was && was !== 'none' ? { reason: 'none', width: 0, height: 0, fps: 0, bitrate: 0 } : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async tick(): Promise<void> {
|
|
313
|
+
// A slow getStats must not pile samples up behind it.
|
|
314
|
+
if (this.sampling) return;
|
|
315
|
+
this.sampling = true;
|
|
316
|
+
try {
|
|
317
|
+
const stats = await this.sample(this.baseline);
|
|
318
|
+
if (!stats || !this.timer) return;
|
|
319
|
+
const { tracker, report } = trackLimit(this.tracker, limitReasonOf(stats.layers));
|
|
320
|
+
this.tracker = tracker;
|
|
321
|
+
if (report) this.report({ reason: report, width: stats.width, height: stats.height, fps: stats.fps, bitrate: stats.bitrate });
|
|
322
|
+
} catch {
|
|
323
|
+
// Statistics are best effort; the next sample tries again.
|
|
324
|
+
} finally {
|
|
325
|
+
this.sampling = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|