@appsocom/client 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/adaptive.d.ts +246 -0
- package/dist/adaptive.js +389 -0
- package/dist/adaptive.js.map +1 -0
- package/dist/captions.d.ts +18 -0
- package/dist/captions.js +190 -0
- package/dist/captions.js.map +1 -0
- package/dist/e2ee.d.ts +71 -0
- package/dist/e2ee.js +268 -0
- package/dist/e2ee.js.map +1 -0
- package/dist/effects.d.ts +84 -0
- package/dist/effects.js +417 -0
- package/dist/effects.js.map +1 -0
- package/dist/emitter.d.ts +9 -0
- package/dist/emitter.js +35 -0
- package/dist/emitter.js.map +1 -0
- package/dist/index.d.ts +632 -0
- package/dist/index.js +2289 -0
- package/dist/index.js.map +1 -0
- package/dist/preflight.d.ts +67 -0
- package/dist/preflight.js +365 -0
- package/dist/preflight.js.map +1 -0
- package/dist/speaker.d.ts +62 -0
- package/dist/speaker.js +89 -0
- package/dist/speaker.js.map +1 -0
- package/package.json +64 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Appsotech Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @appsocom/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browser client for [Appsocom Live](https://live.appsocom.com) video calls. It joins a room from any web app (React, Vue, Svelte or none), handles the media connection, reconnects and video quality, and gives you the room's events. You decide how it looks.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @appsocom/client
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { joinRoom, attachTrack } from "@appsocom/client";
|
|
11
|
+
|
|
12
|
+
const { token } = await fetch("/api/join-token").then((r) => r.json()); // minted by your backend
|
|
13
|
+
const room = await joinRoom({ url: "https://api.appsocom.dev", token, audio: true, video: true });
|
|
14
|
+
|
|
15
|
+
room.on("local-track", (kind, track) => {
|
|
16
|
+
if (kind === "video") attachTrack(document.querySelector("#me"), track);
|
|
17
|
+
});
|
|
18
|
+
room.on("track", (participant, kind, track) => {
|
|
19
|
+
if (kind === "video") attachTrack(videoFor(participant.id), track); // remote audio plays by itself
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
document.querySelector("#mic").onclick = () => room.toggleMic();
|
|
23
|
+
document.querySelector("#leave").onclick = () => room.leave();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Join tokens come from your backend with a server SDK such as [`@appsocom/node`](https://www.npmjs.com/package/@appsocom/node). Want a ready-made call screen? Use [`@appsocom/react`](https://www.npmjs.com/package/@appsocom/react).
|
|
27
|
+
|
|
28
|
+
Guides: [Quickstart](https://docs.appsocom.dev/quickstart) · [Web client](https://docs.appsocom.dev/web-client)
|
|
29
|
+
|
|
30
|
+
Docs: [docs.appsocom.dev](https://docs.appsocom.dev) · License: MIT
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive quality for weak connections. Pure logic (no DOM, no WebRTC objects),
|
|
3
|
+
* so it can be unit-tested: feed it LinkSample values, read back decisions.
|
|
4
|
+
*/
|
|
5
|
+
import type { ConnectionQuality, DataUsage, VideoLayer, VideoQuality } from "@appsocom/protocol";
|
|
6
|
+
export type { DataUsage } from "@appsocom/protocol";
|
|
7
|
+
/** Simulcast encodings for camera video. RIDs sort in quality order (see VideoLayer). */
|
|
8
|
+
export declare const CAMERA_SIMULCAST: RTCRtpEncodingParameters[];
|
|
9
|
+
export declare const CAMERA_LAYERS: VideoLayer[];
|
|
10
|
+
/** Camera capture size when simulcast is on, so the layers are 720p / 360p / 180p. */
|
|
11
|
+
export declare const CAMERA_CONSTRAINTS: MediaTrackConstraints;
|
|
12
|
+
export declare const rank: (q: VideoQuality) => number;
|
|
13
|
+
export declare const minQuality: (a: VideoQuality, b: VideoQuality) => VideoQuality;
|
|
14
|
+
/** Which quality a <video> of this rendered height (CSS px × devicePixelRatio) needs. */
|
|
15
|
+
export declare function qualityForHeight(pixels: number): VideoQuality;
|
|
16
|
+
/** One measurement of the link, taken from RTCPeerConnection.getStats() every couple of seconds. */
|
|
17
|
+
export interface LinkSample {
|
|
18
|
+
/** Fraction of incoming packets lost since the last sample (0–1). */
|
|
19
|
+
inboundLoss: number;
|
|
20
|
+
/** Fraction of our outgoing packets the SFU reports lost (0–1), if known. */
|
|
21
|
+
outboundLoss?: number;
|
|
22
|
+
/** Round-trip time to the SFU in seconds, if known. */
|
|
23
|
+
rtt?: number;
|
|
24
|
+
/** Browser's estimate of upload bandwidth in bits/s, if known. */
|
|
25
|
+
availableOutgoingBitrate?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface AdaptiveState {
|
|
28
|
+
/** Highest video quality we currently allow ourselves to receive. */
|
|
29
|
+
receiveCap: VideoQuality;
|
|
30
|
+
/** Whether we currently send camera video. */
|
|
31
|
+
sendVideo: boolean;
|
|
32
|
+
badRecv: number;
|
|
33
|
+
goodRecv: number;
|
|
34
|
+
badSend: number;
|
|
35
|
+
goodSend: number;
|
|
36
|
+
}
|
|
37
|
+
export declare const initialAdaptiveState: () => AdaptiveState;
|
|
38
|
+
/** Tuning. Samples are ~2s apart, so 3 samples ≈ 6s, 5 ≈ 10s. */
|
|
39
|
+
export declare const ADAPTIVE: {
|
|
40
|
+
recvBadLoss: number;
|
|
41
|
+
recvGoodLoss: number;
|
|
42
|
+
badRtt: number;
|
|
43
|
+
goodRtt: number;
|
|
44
|
+
sendBadBitrate: number;
|
|
45
|
+
sendGoodBitrate: number;
|
|
46
|
+
sendBadLoss: number;
|
|
47
|
+
samplesToDegrade: number;
|
|
48
|
+
samplesToRecover: number;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Next adaptive state. Degrades one step at a time after sustained trouble and
|
|
52
|
+
* recovers one step at a time after a longer stretch of good samples (hysteresis,
|
|
53
|
+
* so it doesn't flap).
|
|
54
|
+
*/
|
|
55
|
+
export declare function nextAdaptiveState(prev: AdaptiveState, s: LinkSample, t?: {
|
|
56
|
+
recvBadLoss: number;
|
|
57
|
+
recvGoodLoss: number;
|
|
58
|
+
badRtt: number;
|
|
59
|
+
goodRtt: number;
|
|
60
|
+
sendBadBitrate: number;
|
|
61
|
+
sendGoodBitrate: number;
|
|
62
|
+
sendBadLoss: number;
|
|
63
|
+
samplesToDegrade: number;
|
|
64
|
+
samplesToRecover: number;
|
|
65
|
+
}): AdaptiveState;
|
|
66
|
+
/**
|
|
67
|
+
* Rate a link for the UI. Loss is the stronger signal (it's what people hear and
|
|
68
|
+
* see); RTT adds delay/echo. Thresholds roughly follow common WebRTC MOS buckets.
|
|
69
|
+
*/
|
|
70
|
+
export declare function connectionQualityFromSample(s: LinkSample): ConnectionQuality;
|
|
71
|
+
/** Cumulative counters kept between samples to compute per-interval loss. */
|
|
72
|
+
export interface StatsCursor {
|
|
73
|
+
inLost: number;
|
|
74
|
+
inReceived: number;
|
|
75
|
+
/** Per remote-inbound report: a marker that changes with each new RTCP report. */
|
|
76
|
+
outReports?: Record<string, number>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Turns an RTCStatsReport into a LinkSample. `cursor` holds the previous totals;
|
|
80
|
+
* the function returns the new totals alongside the sample.
|
|
81
|
+
*/
|
|
82
|
+
export declare function sampleFromStats(report: RTCStatsReport, cursor: StatsCursor | null): {
|
|
83
|
+
sample: LinkSample;
|
|
84
|
+
cursor: StatsCursor;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Audio-first fallback: when the link stays very bad even after the normal
|
|
88
|
+
* adaptive steps, stop all camera video (both ways) so audio gets the whole
|
|
89
|
+
* link. Comes back after a long good stretch; every fallback within a few
|
|
90
|
+
* minutes of the last one doubles the wait (up to 4×), so it doesn't flap.
|
|
91
|
+
*/
|
|
92
|
+
export interface FallbackState {
|
|
93
|
+
active: boolean;
|
|
94
|
+
bad: number;
|
|
95
|
+
good: number;
|
|
96
|
+
/** How many times we fell back recently (sets the recovery wait). */
|
|
97
|
+
strikes: number;
|
|
98
|
+
/** When the last fallback ended (ms), for the strike decay. */
|
|
99
|
+
lastRecoveredAt: number;
|
|
100
|
+
}
|
|
101
|
+
export declare const initialFallbackState: () => FallbackState;
|
|
102
|
+
export declare const FALLBACK: {
|
|
103
|
+
/** Loss (either way) that counts as severe. */
|
|
104
|
+
severeLoss: number;
|
|
105
|
+
/** RTT (s) that counts as severe. */
|
|
106
|
+
severeRtt: number;
|
|
107
|
+
/** Upload estimate (bits/s) that counts as severe. */
|
|
108
|
+
severeBitrate: number;
|
|
109
|
+
/** Severe samples in a row before falling back (~6 s at 2 s per sample). */
|
|
110
|
+
samplesToFallback: number;
|
|
111
|
+
/** Good samples in a row before trying video again (~30 s), × 2^strikes. */
|
|
112
|
+
samplesToRecover: number;
|
|
113
|
+
maxStrikes: number;
|
|
114
|
+
/** A fallback this long after the last recovery starts the count again. */
|
|
115
|
+
strikeResetMs: number;
|
|
116
|
+
/** Mic cap while fallen back (Opus stays clear at 24 kbit/s and leaves room on a weak link). */
|
|
117
|
+
audioBitrate: number;
|
|
118
|
+
};
|
|
119
|
+
/** Mic bitrate cap (0 = none): the data saver's, tightened while fallen back to audio. */
|
|
120
|
+
export declare function micBitrateCap(saverCap: number, fallbackActive: boolean, t?: {
|
|
121
|
+
/** Loss (either way) that counts as severe. */
|
|
122
|
+
severeLoss: number;
|
|
123
|
+
/** RTT (s) that counts as severe. */
|
|
124
|
+
severeRtt: number;
|
|
125
|
+
/** Upload estimate (bits/s) that counts as severe. */
|
|
126
|
+
severeBitrate: number;
|
|
127
|
+
/** Severe samples in a row before falling back (~6 s at 2 s per sample). */
|
|
128
|
+
samplesToFallback: number;
|
|
129
|
+
/** Good samples in a row before trying video again (~30 s), × 2^strikes. */
|
|
130
|
+
samplesToRecover: number;
|
|
131
|
+
maxStrikes: number;
|
|
132
|
+
/** A fallback this long after the last recovery starts the count again. */
|
|
133
|
+
strikeResetMs: number;
|
|
134
|
+
/** Mic cap while fallen back (Opus stays clear at 24 kbit/s and leaves room on a weak link). */
|
|
135
|
+
audioBitrate: number;
|
|
136
|
+
}): number;
|
|
137
|
+
/** Severe: lossy or slow enough that even audio suffers. */
|
|
138
|
+
export declare function isSevere(s: LinkSample, t?: {
|
|
139
|
+
/** Loss (either way) that counts as severe. */
|
|
140
|
+
severeLoss: number;
|
|
141
|
+
/** RTT (s) that counts as severe. */
|
|
142
|
+
severeRtt: number;
|
|
143
|
+
/** Upload estimate (bits/s) that counts as severe. */
|
|
144
|
+
severeBitrate: number;
|
|
145
|
+
/** Severe samples in a row before falling back (~6 s at 2 s per sample). */
|
|
146
|
+
samplesToFallback: number;
|
|
147
|
+
/** Good samples in a row before trying video again (~30 s), × 2^strikes. */
|
|
148
|
+
samplesToRecover: number;
|
|
149
|
+
maxStrikes: number;
|
|
150
|
+
/** A fallback this long after the last recovery starts the count again. */
|
|
151
|
+
strikeResetMs: number;
|
|
152
|
+
/** Mic cap while fallen back (Opus stays clear at 24 kbit/s and leaves room on a weak link). */
|
|
153
|
+
audioBitrate: number;
|
|
154
|
+
}): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Good enough to try video again: little loss either way and short round
|
|
157
|
+
* trips. The browser's upload estimate is ignored here: with audio alone it
|
|
158
|
+
* doesn't probe for more, so it stays as low as it fell (seen in tests: it
|
|
159
|
+
* never recovers on its own). If the upload really is still too small, video
|
|
160
|
+
* coming back pushes the link into trouble again and the next fallback waits
|
|
161
|
+
* twice as long.
|
|
162
|
+
*/
|
|
163
|
+
export declare function isClear(s: LinkSample, t?: {
|
|
164
|
+
recvBadLoss: number;
|
|
165
|
+
recvGoodLoss: number;
|
|
166
|
+
badRtt: number;
|
|
167
|
+
goodRtt: number;
|
|
168
|
+
sendBadBitrate: number;
|
|
169
|
+
sendGoodBitrate: number;
|
|
170
|
+
sendBadLoss: number;
|
|
171
|
+
samplesToDegrade: number;
|
|
172
|
+
samplesToRecover: number;
|
|
173
|
+
}): boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Next fallback state. `adaptive` is the normal adaptive state after this
|
|
176
|
+
* sample: once it has already paused video both ways and the link is still
|
|
177
|
+
* not clear, that also counts toward falling back.
|
|
178
|
+
*/
|
|
179
|
+
export declare function nextFallbackState(prev: FallbackState, s: LinkSample, adaptive: AdaptiveState, now: number, t?: {
|
|
180
|
+
/** Loss (either way) that counts as severe. */
|
|
181
|
+
severeLoss: number;
|
|
182
|
+
/** RTT (s) that counts as severe. */
|
|
183
|
+
severeRtt: number;
|
|
184
|
+
/** Upload estimate (bits/s) that counts as severe. */
|
|
185
|
+
severeBitrate: number;
|
|
186
|
+
/** Severe samples in a row before falling back (~6 s at 2 s per sample). */
|
|
187
|
+
samplesToFallback: number;
|
|
188
|
+
/** Good samples in a row before trying video again (~30 s), × 2^strikes. */
|
|
189
|
+
samplesToRecover: number;
|
|
190
|
+
maxStrikes: number;
|
|
191
|
+
/** A fallback this long after the last recovery starts the count again. */
|
|
192
|
+
strikeResetMs: number;
|
|
193
|
+
/** Mic cap while fallen back (Opus stays clear at 24 kbit/s and leaves room on a weak link). */
|
|
194
|
+
audioBitrate: number;
|
|
195
|
+
}): FallbackState;
|
|
196
|
+
export interface SaverSendSettings {
|
|
197
|
+
maxSendHeight: number;
|
|
198
|
+
maxSendBitrate: number;
|
|
199
|
+
maxSendFramerate: number;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Camera send encodings and the layers to announce, for a data-saver setting.
|
|
203
|
+
* With simulcast, the transceiver keeps its three RIDs (they can't change
|
|
204
|
+
* after negotiation): only the best layer that fits `maxSendHeight` stays
|
|
205
|
+
* active, capped. Without simulcast, the one encoding is scaled and capped.
|
|
206
|
+
* `captureHeight` is the camera's actual height (for the non-simulcast scale).
|
|
207
|
+
*/
|
|
208
|
+
export declare function cameraEncodings(simulcast: boolean, saver: SaverSendSettings | null, sending: boolean, captureHeight?: number,
|
|
209
|
+
/** RIDs the sender actually has (browsers drop layers for small cameras). Default all three. */
|
|
210
|
+
available?: readonly VideoLayer[]): {
|
|
211
|
+
encodings: RTCRtpEncodingParameters[];
|
|
212
|
+
layers: VideoLayer[];
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Apply planned encodings onto a sender's current ones (matched by RID when
|
|
216
|
+
* simulcast, else the first). Returns null when nothing would change. The
|
|
217
|
+
* number and RIDs of encodings can't change after negotiation, so this keeps
|
|
218
|
+
* whatever the browser negotiated and only sets active / caps / scale.
|
|
219
|
+
*/
|
|
220
|
+
export declare function mergeEncodings(current: RTCRtpEncodingParameters[], planned: RTCRtpEncodingParameters[]): RTCRtpEncodingParameters[] | null;
|
|
221
|
+
/**
|
|
222
|
+
* Who you receive camera video from when it's limited to recent speakers:
|
|
223
|
+
* `recent` is remote ids, most recent speaker first; `present` is everyone
|
|
224
|
+
* there in join order (fills the slots before anyone has spoken); spotlighted
|
|
225
|
+
* ids always count. Returns null when there is no limit.
|
|
226
|
+
*/
|
|
227
|
+
export declare function videoAllowList(count: number, recent: readonly string[], present: readonly string[], always?: readonly string[]): Set<string> | null;
|
|
228
|
+
/** Bytes on the wire so far in this media connection (RTP + RTCP + ICE, from getStats). */
|
|
229
|
+
export interface ByteTotals {
|
|
230
|
+
sent: number;
|
|
231
|
+
received: number;
|
|
232
|
+
}
|
|
233
|
+
export declare function bytesFromStats(report: RTCStatsReport): ByteTotals | null;
|
|
234
|
+
export interface UsageMeter {
|
|
235
|
+
usage: DataUsage;
|
|
236
|
+
last: ByteTotals | null;
|
|
237
|
+
lastAt: number;
|
|
238
|
+
/** Recent (time, totalBytes) points for the per-hour rate. */
|
|
239
|
+
window: Array<[number, number]>;
|
|
240
|
+
}
|
|
241
|
+
export declare const initialUsageMeter: (now: number) => UsageMeter;
|
|
242
|
+
/**
|
|
243
|
+
* Add a reading. Counters restart when the media connection is rebuilt
|
|
244
|
+
* (reconnect): a total lower than the last one is taken as all new.
|
|
245
|
+
*/
|
|
246
|
+
export declare function nextUsage(m: UsageMeter, totals: ByteTotals, now: number, windowMs?: number): UsageMeter;
|
package/dist/adaptive.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/** Simulcast encodings for camera video. RIDs sort in quality order (see VideoLayer). */
|
|
2
|
+
export const CAMERA_SIMULCAST = [
|
|
3
|
+
{ rid: "f", maxBitrate: 1_200_000, maxFramerate: 30 },
|
|
4
|
+
{ rid: "h", maxBitrate: 450_000, maxFramerate: 30, scaleResolutionDownBy: 2 },
|
|
5
|
+
{ rid: "q", maxBitrate: 150_000, maxFramerate: 15, scaleResolutionDownBy: 4 },
|
|
6
|
+
];
|
|
7
|
+
export const CAMERA_LAYERS = ["f", "h", "q"];
|
|
8
|
+
/** Camera capture size when simulcast is on, so the layers are 720p / 360p / 180p. */
|
|
9
|
+
export const CAMERA_CONSTRAINTS = {
|
|
10
|
+
width: { ideal: 1280 },
|
|
11
|
+
height: { ideal: 720 },
|
|
12
|
+
frameRate: { ideal: 30 },
|
|
13
|
+
};
|
|
14
|
+
const ORDER = ["off", "low", "medium", "high"];
|
|
15
|
+
export const rank = (q) => ORDER.indexOf(q);
|
|
16
|
+
export const minQuality = (a, b) => (rank(a) <= rank(b) ? a : b);
|
|
17
|
+
const stepDown = (q) => ORDER[Math.max(0, rank(q) - 1)];
|
|
18
|
+
const stepUp = (q) => ORDER[Math.min(ORDER.length - 1, rank(q) + 1)];
|
|
19
|
+
/** Which quality a <video> of this rendered height (CSS px × devicePixelRatio) needs. */
|
|
20
|
+
export function qualityForHeight(pixels) {
|
|
21
|
+
if (pixels <= 0)
|
|
22
|
+
return "low";
|
|
23
|
+
if (pixels > 400)
|
|
24
|
+
return "high";
|
|
25
|
+
if (pixels > 200)
|
|
26
|
+
return "medium";
|
|
27
|
+
return "low";
|
|
28
|
+
}
|
|
29
|
+
export const initialAdaptiveState = () => ({
|
|
30
|
+
receiveCap: "high",
|
|
31
|
+
sendVideo: true,
|
|
32
|
+
badRecv: 0,
|
|
33
|
+
goodRecv: 0,
|
|
34
|
+
badSend: 0,
|
|
35
|
+
goodSend: 0,
|
|
36
|
+
});
|
|
37
|
+
/** Tuning. Samples are ~2s apart, so 3 samples ≈ 6s, 5 ≈ 10s. */
|
|
38
|
+
export const ADAPTIVE = {
|
|
39
|
+
recvBadLoss: 0.08,
|
|
40
|
+
recvGoodLoss: 0.02,
|
|
41
|
+
badRtt: 0.8,
|
|
42
|
+
goodRtt: 0.35,
|
|
43
|
+
sendBadBitrate: 120_000,
|
|
44
|
+
sendGoodBitrate: 350_000,
|
|
45
|
+
sendBadLoss: 0.15,
|
|
46
|
+
samplesToDegrade: 3,
|
|
47
|
+
samplesToRecover: 5,
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Next adaptive state. Degrades one step at a time after sustained trouble and
|
|
51
|
+
* recovers one step at a time after a longer stretch of good samples (hysteresis,
|
|
52
|
+
* so it doesn't flap).
|
|
53
|
+
*/
|
|
54
|
+
export function nextAdaptiveState(prev, s, t = ADAPTIVE) {
|
|
55
|
+
const st = { ...prev };
|
|
56
|
+
const rttBad = s.rtt !== undefined && s.rtt > t.badRtt;
|
|
57
|
+
const rttGood = s.rtt === undefined || s.rtt < t.goodRtt;
|
|
58
|
+
// Receiving
|
|
59
|
+
if (s.inboundLoss > t.recvBadLoss || rttBad) {
|
|
60
|
+
st.badRecv++;
|
|
61
|
+
st.goodRecv = 0;
|
|
62
|
+
}
|
|
63
|
+
else if (s.inboundLoss < t.recvGoodLoss && rttGood) {
|
|
64
|
+
st.goodRecv++;
|
|
65
|
+
st.badRecv = 0;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
st.badRecv = 0;
|
|
69
|
+
st.goodRecv = 0;
|
|
70
|
+
}
|
|
71
|
+
if (st.badRecv >= t.samplesToDegrade && st.receiveCap !== "off") {
|
|
72
|
+
st.receiveCap = stepDown(st.receiveCap);
|
|
73
|
+
st.badRecv = 0;
|
|
74
|
+
}
|
|
75
|
+
else if (st.goodRecv >= t.samplesToRecover && st.receiveCap !== "high") {
|
|
76
|
+
st.receiveCap = stepUp(st.receiveCap);
|
|
77
|
+
st.goodRecv = 0;
|
|
78
|
+
}
|
|
79
|
+
// Sending
|
|
80
|
+
const bitrate = s.availableOutgoingBitrate;
|
|
81
|
+
const sendBad = (bitrate !== undefined && bitrate < t.sendBadBitrate) || (s.outboundLoss !== undefined && s.outboundLoss > t.sendBadLoss);
|
|
82
|
+
const sendGood = (bitrate === undefined || bitrate > t.sendGoodBitrate) && (s.outboundLoss === undefined || s.outboundLoss < t.recvGoodLoss * 2);
|
|
83
|
+
if (sendBad) {
|
|
84
|
+
st.badSend++;
|
|
85
|
+
st.goodSend = 0;
|
|
86
|
+
}
|
|
87
|
+
else if (sendGood) {
|
|
88
|
+
st.goodSend++;
|
|
89
|
+
st.badSend = 0;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
st.badSend = 0;
|
|
93
|
+
st.goodSend = 0;
|
|
94
|
+
}
|
|
95
|
+
if (st.sendVideo && st.badSend >= t.samplesToDegrade) {
|
|
96
|
+
st.sendVideo = false;
|
|
97
|
+
st.badSend = 0;
|
|
98
|
+
}
|
|
99
|
+
else if (!st.sendVideo && st.goodSend >= t.samplesToRecover) {
|
|
100
|
+
st.sendVideo = true;
|
|
101
|
+
st.goodSend = 0;
|
|
102
|
+
}
|
|
103
|
+
return st;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Rate a link for the UI. Loss is the stronger signal (it's what people hear and
|
|
107
|
+
* see); RTT adds delay/echo. Thresholds roughly follow common WebRTC MOS buckets.
|
|
108
|
+
*/
|
|
109
|
+
export function connectionQualityFromSample(s) {
|
|
110
|
+
const loss = Math.max(s.inboundLoss, s.outboundLoss ?? 0);
|
|
111
|
+
const rtt = s.rtt ?? 0;
|
|
112
|
+
if (loss > 0.1 || rtt > 0.8)
|
|
113
|
+
return "poor";
|
|
114
|
+
if (loss > 0.03 || rtt > 0.3)
|
|
115
|
+
return "good";
|
|
116
|
+
return "excellent";
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Turns an RTCStatsReport into a LinkSample. `cursor` holds the previous totals;
|
|
120
|
+
* the function returns the new totals alongside the sample.
|
|
121
|
+
*/
|
|
122
|
+
export function sampleFromStats(report, cursor) {
|
|
123
|
+
let inLost = 0;
|
|
124
|
+
let inReceived = 0;
|
|
125
|
+
let rtt;
|
|
126
|
+
let bitrate;
|
|
127
|
+
let outLossSum = 0;
|
|
128
|
+
let outLossN = 0;
|
|
129
|
+
const outReports = {};
|
|
130
|
+
report.forEach((r) => {
|
|
131
|
+
if (r.type === "inbound-rtp") {
|
|
132
|
+
inLost += Math.max(0, r.packetsLost ?? 0);
|
|
133
|
+
inReceived += r.packetsReceived ?? 0;
|
|
134
|
+
}
|
|
135
|
+
else if (r.type === "remote-inbound-rtp" && typeof r.fractionLost === "number") {
|
|
136
|
+
// A paused sender (e.g. camera layers off in a fallback) gets no new
|
|
137
|
+
// receiver reports, so its last fractionLost stays frozen: skip reports
|
|
138
|
+
// that haven't changed since the previous sample.
|
|
139
|
+
const key = String(r.id ?? r.ssrc ?? "");
|
|
140
|
+
const marker = r.roundTripTimeMeasurements ?? r.timestamp;
|
|
141
|
+
if (typeof marker === "number")
|
|
142
|
+
outReports[key] = marker;
|
|
143
|
+
const stale = typeof marker === "number" && cursor?.outReports?.[key] === marker;
|
|
144
|
+
if (!stale) {
|
|
145
|
+
outLossSum += r.fractionLost;
|
|
146
|
+
outLossN++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (r.type === "candidate-pair" && r.nominated && r.state === "succeeded") {
|
|
150
|
+
if (typeof r.currentRoundTripTime === "number")
|
|
151
|
+
rtt = r.currentRoundTripTime;
|
|
152
|
+
if (typeof r.availableOutgoingBitrate === "number")
|
|
153
|
+
bitrate = r.availableOutgoingBitrate;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
const dLost = cursor ? inLost - cursor.inLost : 0;
|
|
157
|
+
const dRecv = cursor ? inReceived - cursor.inReceived : 0;
|
|
158
|
+
const total = dLost + dRecv;
|
|
159
|
+
return {
|
|
160
|
+
sample: {
|
|
161
|
+
inboundLoss: total > 0 ? Math.max(0, dLost) / total : 0,
|
|
162
|
+
outboundLoss: outLossN ? outLossSum / outLossN : undefined,
|
|
163
|
+
rtt,
|
|
164
|
+
availableOutgoingBitrate: bitrate,
|
|
165
|
+
},
|
|
166
|
+
cursor: { inLost, inReceived, outReports },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export const initialFallbackState = () => ({ active: false, bad: 0, good: 0, strikes: 0, lastRecoveredAt: 0 });
|
|
170
|
+
export const FALLBACK = {
|
|
171
|
+
/** Loss (either way) that counts as severe. */
|
|
172
|
+
severeLoss: 0.15,
|
|
173
|
+
/** RTT (s) that counts as severe. */
|
|
174
|
+
severeRtt: 1.0,
|
|
175
|
+
/** Upload estimate (bits/s) that counts as severe. */
|
|
176
|
+
severeBitrate: 80_000,
|
|
177
|
+
/** Severe samples in a row before falling back (~6 s at 2 s per sample). */
|
|
178
|
+
samplesToFallback: 3,
|
|
179
|
+
/** Good samples in a row before trying video again (~30 s), × 2^strikes. */
|
|
180
|
+
samplesToRecover: 15,
|
|
181
|
+
maxStrikes: 2,
|
|
182
|
+
/** A fallback this long after the last recovery starts the count again. */
|
|
183
|
+
strikeResetMs: 5 * 60_000,
|
|
184
|
+
/** Mic cap while fallen back (Opus stays clear at 24 kbit/s and leaves room on a weak link). */
|
|
185
|
+
audioBitrate: 24_000,
|
|
186
|
+
};
|
|
187
|
+
/** Mic bitrate cap (0 = none): the data saver's, tightened while fallen back to audio. */
|
|
188
|
+
export function micBitrateCap(saverCap, fallbackActive, t = FALLBACK) {
|
|
189
|
+
if (!fallbackActive)
|
|
190
|
+
return saverCap;
|
|
191
|
+
return saverCap > 0 ? Math.min(saverCap, t.audioBitrate) : t.audioBitrate;
|
|
192
|
+
}
|
|
193
|
+
/** Severe: lossy or slow enough that even audio suffers. */
|
|
194
|
+
export function isSevere(s, t = FALLBACK) {
|
|
195
|
+
return (s.inboundLoss > t.severeLoss ||
|
|
196
|
+
(s.outboundLoss ?? 0) > t.severeLoss ||
|
|
197
|
+
(s.rtt ?? 0) > t.severeRtt ||
|
|
198
|
+
(s.availableOutgoingBitrate !== undefined && s.availableOutgoingBitrate < t.severeBitrate));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Good enough to try video again: little loss either way and short round
|
|
202
|
+
* trips. The browser's upload estimate is ignored here: with audio alone it
|
|
203
|
+
* doesn't probe for more, so it stays as low as it fell (seen in tests: it
|
|
204
|
+
* never recovers on its own). If the upload really is still too small, video
|
|
205
|
+
* coming back pushes the link into trouble again and the next fallback waits
|
|
206
|
+
* twice as long.
|
|
207
|
+
*/
|
|
208
|
+
export function isClear(s, t = ADAPTIVE) {
|
|
209
|
+
return s.inboundLoss < t.recvGoodLoss && (s.outboundLoss ?? 0) < t.recvGoodLoss * 2 && (s.rtt === undefined || s.rtt < t.goodRtt);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Next fallback state. `adaptive` is the normal adaptive state after this
|
|
213
|
+
* sample: once it has already paused video both ways and the link is still
|
|
214
|
+
* not clear, that also counts toward falling back.
|
|
215
|
+
*/
|
|
216
|
+
export function nextFallbackState(prev, s, adaptive, now, t = FALLBACK) {
|
|
217
|
+
const st = { ...prev };
|
|
218
|
+
if (!st.active) {
|
|
219
|
+
const exhausted = adaptive.receiveCap === "off" && !adaptive.sendVideo && !isClear(s);
|
|
220
|
+
if (isSevere(s, t) || exhausted)
|
|
221
|
+
st.bad++;
|
|
222
|
+
else
|
|
223
|
+
st.bad = 0;
|
|
224
|
+
if (st.bad >= t.samplesToFallback) {
|
|
225
|
+
const recent = st.lastRecoveredAt > 0 && now - st.lastRecoveredAt <= t.strikeResetMs;
|
|
226
|
+
st.strikes = recent ? Math.min(t.maxStrikes, st.strikes + 1) : 0;
|
|
227
|
+
st.active = true;
|
|
228
|
+
st.bad = 0;
|
|
229
|
+
st.good = 0;
|
|
230
|
+
}
|
|
231
|
+
return st;
|
|
232
|
+
}
|
|
233
|
+
if (isClear(s))
|
|
234
|
+
st.good++;
|
|
235
|
+
else
|
|
236
|
+
st.good = 0;
|
|
237
|
+
if (st.good >= t.samplesToRecover * 2 ** st.strikes) {
|
|
238
|
+
st.active = false;
|
|
239
|
+
st.good = 0;
|
|
240
|
+
st.lastRecoveredAt = now;
|
|
241
|
+
}
|
|
242
|
+
return st;
|
|
243
|
+
}
|
|
244
|
+
// ---------------- data saver: sending ----------------
|
|
245
|
+
/** Camera layers with their output heights when capturing 720p. */
|
|
246
|
+
const LAYER_HEIGHTS = { f: 720, h: 360, q: 180 };
|
|
247
|
+
/**
|
|
248
|
+
* Camera send encodings and the layers to announce, for a data-saver setting.
|
|
249
|
+
* With simulcast, the transceiver keeps its three RIDs (they can't change
|
|
250
|
+
* after negotiation): only the best layer that fits `maxSendHeight` stays
|
|
251
|
+
* active, capped. Without simulcast, the one encoding is scaled and capped.
|
|
252
|
+
* `captureHeight` is the camera's actual height (for the non-simulcast scale).
|
|
253
|
+
*/
|
|
254
|
+
export function cameraEncodings(simulcast, saver, sending, captureHeight = 720,
|
|
255
|
+
/** RIDs the sender actually has (browsers drop layers for small cameras). Default all three. */
|
|
256
|
+
available = CAMERA_LAYERS) {
|
|
257
|
+
const limited = saver && (saver.maxSendHeight < 720 || saver.maxSendBitrate > 0 || saver.maxSendFramerate > 0);
|
|
258
|
+
if (!simulcast) {
|
|
259
|
+
const e = { active: sending };
|
|
260
|
+
if (limited) {
|
|
261
|
+
if (saver.maxSendHeight > 0 && captureHeight > saver.maxSendHeight)
|
|
262
|
+
e.scaleResolutionDownBy = captureHeight / saver.maxSendHeight;
|
|
263
|
+
if (saver.maxSendBitrate > 0)
|
|
264
|
+
e.maxBitrate = saver.maxSendBitrate;
|
|
265
|
+
if (saver.maxSendFramerate > 0)
|
|
266
|
+
e.maxFramerate = saver.maxSendFramerate;
|
|
267
|
+
}
|
|
268
|
+
return { encodings: [e], layers: [] };
|
|
269
|
+
}
|
|
270
|
+
const have = CAMERA_LAYERS.filter((l) => available.includes(l));
|
|
271
|
+
if (!limited)
|
|
272
|
+
return { encodings: CAMERA_SIMULCAST.map((e) => ({ ...e, active: sending })), layers: have };
|
|
273
|
+
const keep = have.find((l) => LAYER_HEIGHTS[l] <= Math.max(saver.maxSendHeight, 180)) ?? have[have.length - 1] ?? "q";
|
|
274
|
+
const encodings = CAMERA_SIMULCAST.map((e) => {
|
|
275
|
+
if (e.rid !== keep)
|
|
276
|
+
return { ...e, active: false };
|
|
277
|
+
const out = { ...e, active: sending };
|
|
278
|
+
if (saver.maxSendBitrate > 0)
|
|
279
|
+
out.maxBitrate = Math.min(e.maxBitrate ?? Infinity, saver.maxSendBitrate);
|
|
280
|
+
if (saver.maxSendFramerate > 0)
|
|
281
|
+
out.maxFramerate = Math.min(e.maxFramerate ?? Infinity, saver.maxSendFramerate);
|
|
282
|
+
return out;
|
|
283
|
+
});
|
|
284
|
+
return { encodings, layers: [keep] };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Apply planned encodings onto a sender's current ones (matched by RID when
|
|
288
|
+
* simulcast, else the first). Returns null when nothing would change. The
|
|
289
|
+
* number and RIDs of encodings can't change after negotiation, so this keeps
|
|
290
|
+
* whatever the browser negotiated and only sets active / caps / scale.
|
|
291
|
+
*/
|
|
292
|
+
export function mergeEncodings(current, planned) {
|
|
293
|
+
let changed = false;
|
|
294
|
+
const keys = ["active", "maxBitrate", "maxFramerate", "scaleResolutionDownBy"];
|
|
295
|
+
const out = current.map((e, i) => {
|
|
296
|
+
const want = (e.rid ? planned.find((p) => p.rid === e.rid) : planned[0]) ?? planned[i] ?? planned[0] ?? {};
|
|
297
|
+
const next = { ...e };
|
|
298
|
+
for (const k of keys) {
|
|
299
|
+
const v = k === "active" ? (want.active ?? true) : want[k];
|
|
300
|
+
if (v === undefined) {
|
|
301
|
+
if (k === "scaleResolutionDownBy" && e.rid)
|
|
302
|
+
continue; // keep the layer's size
|
|
303
|
+
if (next[k] !== undefined) {
|
|
304
|
+
delete next[k];
|
|
305
|
+
changed = true;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else if (next[k] !== v) {
|
|
309
|
+
next[k] = v;
|
|
310
|
+
changed = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return next;
|
|
314
|
+
});
|
|
315
|
+
return changed ? out : null;
|
|
316
|
+
}
|
|
317
|
+
// ---------------- data saver: receiving ----------------
|
|
318
|
+
/**
|
|
319
|
+
* Who you receive camera video from when it's limited to recent speakers:
|
|
320
|
+
* `recent` is remote ids, most recent speaker first; `present` is everyone
|
|
321
|
+
* there in join order (fills the slots before anyone has spoken); spotlighted
|
|
322
|
+
* ids always count. Returns null when there is no limit.
|
|
323
|
+
*/
|
|
324
|
+
export function videoAllowList(count, recent, present, always = []) {
|
|
325
|
+
if (count <= 0 || present.length <= count)
|
|
326
|
+
return null;
|
|
327
|
+
const out = new Set(always.filter((id) => present.includes(id)));
|
|
328
|
+
const pick = [];
|
|
329
|
+
for (const id of [...recent, ...present]) {
|
|
330
|
+
if (pick.length >= count)
|
|
331
|
+
break;
|
|
332
|
+
if (present.includes(id) && !pick.includes(id))
|
|
333
|
+
pick.push(id);
|
|
334
|
+
}
|
|
335
|
+
for (const id of pick)
|
|
336
|
+
out.add(id);
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
export function bytesFromStats(report) {
|
|
340
|
+
let transport = null;
|
|
341
|
+
let pair = null;
|
|
342
|
+
report.forEach((r) => {
|
|
343
|
+
if (r.type === "transport" && typeof r.bytesSent === "number") {
|
|
344
|
+
transport = { sent: (transport?.sent ?? 0) + r.bytesSent, received: (transport?.received ?? 0) + (r.bytesReceived ?? 0) };
|
|
345
|
+
}
|
|
346
|
+
else if (r.type === "candidate-pair" && r.nominated && typeof r.bytesSent === "number") {
|
|
347
|
+
pair = { sent: (pair?.sent ?? 0) + r.bytesSent, received: (pair?.received ?? 0) + (r.bytesReceived ?? 0) };
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
return transport ?? pair;
|
|
351
|
+
}
|
|
352
|
+
export const initialUsageMeter = (now) => ({
|
|
353
|
+
usage: { sentBytes: 0, receivedBytes: 0, totalBytes: 0, sendBitrate: 0, receiveBitrate: 0, mbPerHour: 0, since: now },
|
|
354
|
+
last: null,
|
|
355
|
+
lastAt: now,
|
|
356
|
+
window: [[now, 0]],
|
|
357
|
+
});
|
|
358
|
+
/**
|
|
359
|
+
* Add a reading. Counters restart when the media connection is rebuilt
|
|
360
|
+
* (reconnect): a total lower than the last one is taken as all new.
|
|
361
|
+
*/
|
|
362
|
+
export function nextUsage(m, totals, now, windowMs = 30_000) {
|
|
363
|
+
const dSent = m.last && totals.sent >= m.last.sent ? totals.sent - m.last.sent : totals.sent;
|
|
364
|
+
const dRecv = m.last && totals.received >= m.last.received ? totals.received - m.last.received : totals.received;
|
|
365
|
+
const secs = Math.max(0.001, (now - m.lastAt) / 1000);
|
|
366
|
+
const sentBytes = m.usage.sentBytes + dSent;
|
|
367
|
+
const receivedBytes = m.usage.receivedBytes + dRecv;
|
|
368
|
+
const totalBytes = sentBytes + receivedBytes;
|
|
369
|
+
const window = [...m.window, [now, totalBytes]].filter(([t], i, a) => i === a.length - 1 || now - t <= windowMs);
|
|
370
|
+
if (window.length === 1)
|
|
371
|
+
window.unshift(m.window[m.window.length - 1]);
|
|
372
|
+
const [t0, b0] = window[0];
|
|
373
|
+
const rate = now > t0 ? ((totalBytes - b0) / ((now - t0) / 1000)) * 3600 / 1e6 : 0;
|
|
374
|
+
return {
|
|
375
|
+
usage: {
|
|
376
|
+
sentBytes,
|
|
377
|
+
receivedBytes,
|
|
378
|
+
totalBytes,
|
|
379
|
+
sendBitrate: m.last ? Math.round((dSent * 8) / secs) : 0,
|
|
380
|
+
receiveBitrate: m.last ? Math.round((dRecv * 8) / secs) : 0,
|
|
381
|
+
mbPerHour: Math.round(rate * 10) / 10,
|
|
382
|
+
since: m.usage.since,
|
|
383
|
+
},
|
|
384
|
+
last: totals,
|
|
385
|
+
lastAt: now,
|
|
386
|
+
window,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
//# sourceMappingURL=adaptive.js.map
|