@voltkit-rtc/client 0.7.2 → 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 +89 -0
- package/README.md +255 -4
- 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 +350 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1201 -36
- 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/recording.d.ts +14 -0
- package/dist/cjs/recording.d.ts.map +1 -1
- package/dist/cjs/recording.js +51 -5
- package/dist/cjs/recording.js.map +1 -1
- 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 +350 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1199 -36
- 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/recording.d.ts +14 -0
- package/dist/esm/recording.d.ts.map +1 -1
- package/dist/esm/recording.js +51 -5
- package/dist/esm/recording.js.map +1 -1
- 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 +1192 -36
- package/src/link.ts +255 -0
- package/src/preflight.ts +191 -0
- package/src/recording.ts +59 -21
- package/src/speakers.ts +66 -0
package/src/link.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping the connection alive between drops (docs/implementation-progress/RTC_PERF_PROGRESS.md G1, G2).
|
|
3
|
+
*
|
|
4
|
+
* Two failures the socket's own close never reports:
|
|
5
|
+
*
|
|
6
|
+
* - **The media path dies while the socket lives** (Wi-Fi to mobile data, a NAT rebinding, UDP blocked mid-call).
|
|
7
|
+
* The video freezes for good. Twilio and LiveKit restart ICE on the same transports first -- new ICE credentials
|
|
8
|
+
* from the server, the same producers and consumers -- and rejoin only when that does not bring the path back.
|
|
9
|
+
* {@link MediaPathWatch} does the same: a transport `disconnected` for {@link DISCONNECTED_GRACE_MS} or `failed`
|
|
10
|
+
* restarts ICE; not `connected` within {@link RESTART_TIMEOUT_MS} of starting recovery (including the request), or a server without `restartIce`, is a
|
|
11
|
+
* full reconnect.
|
|
12
|
+
* - **The socket dies silently**: the network went away and TCP has not noticed yet, which can take minutes. The
|
|
13
|
+
* server pings, but a browser cannot see those. LiveKit pings from the client and reconnects when no pong comes.
|
|
14
|
+
* {@link SocketLiveness} sends `ping` after {@link IDLE_MS} with nothing received, and calls the socket dead when
|
|
15
|
+
* nothing at all arrives within {@link PING_TIMEOUT_MS} of it. Any frame counts, so an older server's
|
|
16
|
+
* `unknown-method` reply proves the socket as well as a pong.
|
|
17
|
+
*
|
|
18
|
+
* Pure: timers and the clock are injectable, so the decisions are unit-tested on their own.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A transport `disconnected` this long is restarted (it often recovers by itself within a second or two). */
|
|
22
|
+
export const DISCONNECTED_GRACE_MS = 3000;
|
|
23
|
+
/** The whole ICE recovery, including the request, has this long before the session rejoins. */
|
|
24
|
+
export const RESTART_TIMEOUT_MS = 10_000;
|
|
25
|
+
/** With nothing received for this long, the client pings. */
|
|
26
|
+
export const IDLE_MS = 15_000;
|
|
27
|
+
/** A ping with nothing at all received for this long after it: the socket is dead. */
|
|
28
|
+
export const PING_TIMEOUT_MS = 8000;
|
|
29
|
+
/** How often liveness is checked. Background tabs may run it less often; the decisions use the clock, not counts. */
|
|
30
|
+
export const LIVENESS_TICK_MS = 5000;
|
|
31
|
+
|
|
32
|
+
export type TransportState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
|
33
|
+
|
|
34
|
+
/** What an ICE restart came to. */
|
|
35
|
+
export type RestartOutcome =
|
|
36
|
+
/** New credentials applied: wait for `connected`. */
|
|
37
|
+
| 'restarted'
|
|
38
|
+
/** The server has no restartIce (older than 2026-10-19): rejoin instead. */
|
|
39
|
+
| 'unsupported'
|
|
40
|
+
/** It could not be done now (the socket is down, the session is going): the socket's own recovery handles it. */
|
|
41
|
+
| 'skipped';
|
|
42
|
+
|
|
43
|
+
export interface MediaPathHost {
|
|
44
|
+
/** Whether the session is connected; nothing is done otherwise (a reconnect is already under way, or it ended). */
|
|
45
|
+
active(): boolean;
|
|
46
|
+
/** Ask the server for new ICE credentials for this transport and apply them. */
|
|
47
|
+
restartIce(transportId: string): Promise<RestartOutcome>;
|
|
48
|
+
/** Give up on this connection and rejoin. */
|
|
49
|
+
reconnect(reason: string): void;
|
|
50
|
+
/** Told when a transport's media path is lost and when it is back (for `mediaReconnecting`-style UI). */
|
|
51
|
+
onPathChange?(transportId: string, lost: boolean): void;
|
|
52
|
+
setTimer?(fn: () => void, ms: number): unknown;
|
|
53
|
+
clearTimer?(handle: unknown): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PathEntry {
|
|
57
|
+
revision: number;
|
|
58
|
+
state: TransportState;
|
|
59
|
+
timer: unknown;
|
|
60
|
+
/** One restart is in flight; repeated loss notifications share its deadline. */
|
|
61
|
+
restarting: boolean;
|
|
62
|
+
lost: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Watches each media transport's connection state and recovers a dead path. */
|
|
66
|
+
export class MediaPathWatch {
|
|
67
|
+
private readonly entries = new Map<string, PathEntry>();
|
|
68
|
+
private readonly setTimer: (fn: () => void, ms: number) => unknown;
|
|
69
|
+
private readonly clearTimer: (handle: unknown) => void;
|
|
70
|
+
|
|
71
|
+
constructor(private readonly host: MediaPathHost) {
|
|
72
|
+
this.setTimer = host.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
73
|
+
this.clearTimer = host.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A transport's connection state changed (mediasoup-client `connectionstatechange`). */
|
|
77
|
+
update(transportId: string, state: TransportState): void {
|
|
78
|
+
let entry = this.entries.get(transportId);
|
|
79
|
+
if (!entry) {
|
|
80
|
+
entry = { state, timer: null, restarting: false, lost: false, revision: 0 };
|
|
81
|
+
this.entries.set(transportId, entry);
|
|
82
|
+
}
|
|
83
|
+
entry.state = state;
|
|
84
|
+
switch (state) {
|
|
85
|
+
case 'connected':
|
|
86
|
+
entry.revision++;
|
|
87
|
+
this.cancel(entry);
|
|
88
|
+
entry.restarting = false;
|
|
89
|
+
this.setLost(transportId, entry, false);
|
|
90
|
+
return;
|
|
91
|
+
case 'closed':
|
|
92
|
+
this.forget(transportId);
|
|
93
|
+
return;
|
|
94
|
+
case 'disconnected':
|
|
95
|
+
// A restart is already waiting for `connected`; its own deadline decides.
|
|
96
|
+
if (entry.restarting || entry.timer !== null) return;
|
|
97
|
+
entry.timer = this.setTimer(() => {
|
|
98
|
+
entry.timer = null;
|
|
99
|
+
if (entry.state === 'disconnected' || entry.state === 'failed') void this.restart(transportId, entry);
|
|
100
|
+
}, DISCONNECTED_GRACE_MS);
|
|
101
|
+
return;
|
|
102
|
+
case 'failed':
|
|
103
|
+
if (entry.restarting) return; // duplicate notification; the deadline decides
|
|
104
|
+
this.cancel(entry);
|
|
105
|
+
void this.restart(transportId, entry);
|
|
106
|
+
return;
|
|
107
|
+
default:
|
|
108
|
+
// new / connecting: a fresh transport, or one mid-restart.
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The transport went away with its session (teardown): no more decisions for it. */
|
|
114
|
+
forget(transportId: string): void {
|
|
115
|
+
const entry = this.entries.get(transportId);
|
|
116
|
+
if (!entry) return;
|
|
117
|
+
this.cancel(entry);
|
|
118
|
+
this.entries.delete(transportId);
|
|
119
|
+
if (entry.lost) this.host.onPathChange?.(transportId, false);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every transport went away (teardown, close). */
|
|
123
|
+
reset(): void {
|
|
124
|
+
for (const id of [...this.entries.keys()]) this.forget(id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** For tests and the network-change nudge: is this transport being recovered now? */
|
|
128
|
+
restartingNow(transportId: string): boolean {
|
|
129
|
+
return this.entries.get(transportId)?.restarting === true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async restart(transportId: string, entry: PathEntry): Promise<void> {
|
|
133
|
+
if (!this.host.active() || this.entries.get(transportId) !== entry) return;
|
|
134
|
+
entry.restarting = true;
|
|
135
|
+
const revision = ++entry.revision;
|
|
136
|
+
// Include both the RPC and the platform call in the deadline: neither is
|
|
137
|
+
// guaranteed to settle when a network/interface disappears.
|
|
138
|
+
this.cancel(entry);
|
|
139
|
+
entry.timer = this.setTimer(() => {
|
|
140
|
+
entry.timer = null;
|
|
141
|
+
if (!entry.restarting || this.entries.get(transportId) !== entry) return;
|
|
142
|
+
entry.revision++;
|
|
143
|
+
entry.restarting = false;
|
|
144
|
+
if (this.host.active()) this.host.reconnect('media-timeout');
|
|
145
|
+
}, RESTART_TIMEOUT_MS);
|
|
146
|
+
this.setLost(transportId, entry, true);
|
|
147
|
+
let outcome: RestartOutcome;
|
|
148
|
+
try {
|
|
149
|
+
outcome = await this.host.restartIce(transportId);
|
|
150
|
+
} catch {
|
|
151
|
+
outcome = 'unsupported';
|
|
152
|
+
}
|
|
153
|
+
if (this.entries.get(transportId) !== entry || entry.revision !== revision) return;
|
|
154
|
+
if (outcome === 'skipped') {
|
|
155
|
+
this.cancel(entry);
|
|
156
|
+
entry.restarting = false;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (outcome === 'unsupported') {
|
|
160
|
+
this.cancel(entry);
|
|
161
|
+
entry.restarting = false;
|
|
162
|
+
if (this.host.active()) this.host.reconnect('media-failed');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// `connected` clears the deadline, including when it arrives during the await.
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private setLost(transportId: string, entry: PathEntry, lost: boolean): void {
|
|
169
|
+
if (entry.lost === lost) return;
|
|
170
|
+
entry.lost = lost;
|
|
171
|
+
this.host.onPathChange?.(transportId, lost);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private cancel(entry: PathEntry): void {
|
|
175
|
+
if (entry.timer !== null) this.clearTimer(entry.timer);
|
|
176
|
+
entry.timer = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface LivenessHost {
|
|
181
|
+
/** Whether the session's socket should be checked now (connected, not closing). */
|
|
182
|
+
active(): boolean;
|
|
183
|
+
/** Send a ping; its reply (or error reply) reaches {@link SocketLiveness.received} like any frame. */
|
|
184
|
+
ping(): void;
|
|
185
|
+
/** Nothing came back: close the socket so the normal reconnect runs. */
|
|
186
|
+
dead(): void;
|
|
187
|
+
now?(): number;
|
|
188
|
+
setInterval?(fn: () => void, ms: number): unknown;
|
|
189
|
+
clearInterval?(handle: unknown): void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Notices a socket that died without closing. */
|
|
193
|
+
export class SocketLiveness {
|
|
194
|
+
private lastReceived = 0;
|
|
195
|
+
private pingSentAt: number | null = null;
|
|
196
|
+
private timer: unknown = null;
|
|
197
|
+
private readonly now: () => number;
|
|
198
|
+
|
|
199
|
+
constructor(private readonly host: LivenessHost) {
|
|
200
|
+
this.now = host.now ?? Date.now;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Start watching a newly opened socket. */
|
|
204
|
+
start(): void {
|
|
205
|
+
this.stop();
|
|
206
|
+
this.lastReceived = this.now();
|
|
207
|
+
this.pingSentAt = null;
|
|
208
|
+
const every = this.host.setInterval ?? ((fn, ms) => setInterval(fn, ms));
|
|
209
|
+
this.timer = every(() => this.check(), LIVENESS_TICK_MS);
|
|
210
|
+
// A Node/SSR process with no other work need not stay alive for a browser
|
|
211
|
+
// connection watch. Browser timers are numbers and have no unref.
|
|
212
|
+
(this.timer as { unref?: () => void })?.unref?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
stop(): void {
|
|
216
|
+
if (this.timer !== null) (this.host.clearInterval ?? ((h) => clearInterval(h as ReturnType<typeof setInterval>)))(this.timer);
|
|
217
|
+
this.timer = null;
|
|
218
|
+
this.pingSentAt = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get running(): boolean {
|
|
222
|
+
return this.timer !== null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Any frame arrived on the socket. */
|
|
226
|
+
received(): void {
|
|
227
|
+
this.lastReceived = this.now();
|
|
228
|
+
this.pingSentAt = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Check now instead of at the next tick (the browser said the network changed). */
|
|
232
|
+
checkNow(): void {
|
|
233
|
+
if (this.timer === null || !this.host.active()) return;
|
|
234
|
+
if (this.pingSentAt === null) {
|
|
235
|
+
this.pingSentAt = this.now();
|
|
236
|
+
this.host.ping();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
check(): void {
|
|
241
|
+
if (this.timer === null || !this.host.active()) return;
|
|
242
|
+
const now = this.now();
|
|
243
|
+
if (this.pingSentAt !== null) {
|
|
244
|
+
if (now - this.pingSentAt >= PING_TIMEOUT_MS) {
|
|
245
|
+
this.stop();
|
|
246
|
+
this.host.dead();
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (now - this.lastReceived >= IDLE_MS) {
|
|
251
|
+
this.pingSentAt = now;
|
|
252
|
+
this.host.ping();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/preflight.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-call check (docs/implementation-progress/RTC_PERF_PROGRESS.md L1).
|
|
3
|
+
*
|
|
4
|
+
* - Twilio's `runPreflight` (read from its code) joins a test room, publishes synthetic audio and video and reports
|
|
5
|
+
* timings, the connection path, round trip, jitter and loss.
|
|
6
|
+
* - LiveKit's `ConnectionCheck` runs separate checks -- WebSocket, WebRTC, TURN, publishing audio and video, UDP
|
|
7
|
+
* against TCP -- each with messages and warnings.
|
|
8
|
+
* - Agora's `startLastmileProbeTest` measures the last mile before a call.
|
|
9
|
+
*
|
|
10
|
+
* This joins the room the token names (mint one for a test room nobody else is in: the session is real, and billed
|
|
11
|
+
* like any other for its few seconds), publishes what it is given -- or a tone and a picture it makes -- and reports
|
|
12
|
+
* what it saw. The orchestration takes a small target interface, so it is tested without a browser.
|
|
13
|
+
*/
|
|
14
|
+
import { networkQualityOf, readTransportStats, type NetworkQualityLevel, type TransportSample } from './network.js';
|
|
15
|
+
import type { StatsEntry } from './stats.js';
|
|
16
|
+
import type { MediaWarningCode } from './health.js';
|
|
17
|
+
|
|
18
|
+
export interface PreflightOptions {
|
|
19
|
+
/** What to publish (cloned; your tracks are left running). Without it, a tone and a moving picture are made. */
|
|
20
|
+
media?: MediaStream;
|
|
21
|
+
/** How long media is measured once it flows. Default 5 s. */
|
|
22
|
+
durationMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PreflightStepName = 'join' | 'media';
|
|
26
|
+
|
|
27
|
+
export interface PreflightStep {
|
|
28
|
+
name: PreflightStepName;
|
|
29
|
+
ok: boolean;
|
|
30
|
+
/** How long it took, in ms. */
|
|
31
|
+
ms: number | null;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PreflightReport {
|
|
36
|
+
/** Joined, and media reached the server. */
|
|
37
|
+
ok: boolean;
|
|
38
|
+
steps: PreflightStep[];
|
|
39
|
+
/** How media travelled: UDP is best; TCP or a relay means a firewall in the way (it works, with more delay). */
|
|
40
|
+
path: { protocol: 'udp' | 'tcp' | null; relayed: boolean } | null;
|
|
41
|
+
rttMs: number | null;
|
|
42
|
+
lossPct: number | null;
|
|
43
|
+
/** The upload the browser estimated it could use, in kbps. */
|
|
44
|
+
uploadKbps: number | null;
|
|
45
|
+
level: NetworkQualityLevel | null;
|
|
46
|
+
/** Camera and microphone problems seen during the test (see the mediaWarning event). */
|
|
47
|
+
warnings: MediaWarningCode[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What a pre-call check needs from a client. */
|
|
51
|
+
export interface PreflightTarget {
|
|
52
|
+
connect(): Promise<unknown>;
|
|
53
|
+
publish(stream: MediaStream): Promise<void>;
|
|
54
|
+
/** The send transport's connection state, or null before there is one. */
|
|
55
|
+
sendState(): string | null;
|
|
56
|
+
/** The send transport's statistics, or null without one. */
|
|
57
|
+
sendStats(): Promise<Iterable<StatsEntry> | null>;
|
|
58
|
+
onWarning(listener: (code: MediaWarningCode, active: boolean) => void): () => void;
|
|
59
|
+
close(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PreflightEnv {
|
|
63
|
+
now(): number;
|
|
64
|
+
sleep(ms: number): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const realEnv: PreflightEnv = { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
|
|
68
|
+
|
|
69
|
+
/** Media has this long to connect once published. */
|
|
70
|
+
export const MEDIA_CONNECT_TIMEOUT_MS = 10_000;
|
|
71
|
+
/** Statistics are read this often while measuring. */
|
|
72
|
+
export const PREFLIGHT_SAMPLE_MS = 1000;
|
|
73
|
+
|
|
74
|
+
/** The selected candidate pair's protocol, and whether either end is a relay. */
|
|
75
|
+
export function connectionPath(report: Iterable<StatsEntry>): PreflightReport['path'] {
|
|
76
|
+
const entries = [...report];
|
|
77
|
+
const byId = new Map<string, StatsEntry>();
|
|
78
|
+
for (const e of entries) if (typeof e.id === 'string') byId.set(e.id, e);
|
|
79
|
+
let pair: StatsEntry | undefined;
|
|
80
|
+
for (const t of entries.filter((e) => e.type === 'transport')) {
|
|
81
|
+
if (typeof t.selectedCandidatePairId === 'string') pair = byId.get(t.selectedCandidatePairId);
|
|
82
|
+
if (pair) break;
|
|
83
|
+
}
|
|
84
|
+
pair ??= entries.find((e) => e.type === 'candidate-pair' && e.nominated === true && e.state === 'succeeded');
|
|
85
|
+
if (!pair) return null;
|
|
86
|
+
const local = typeof pair.localCandidateId === 'string' ? byId.get(pair.localCandidateId) : undefined;
|
|
87
|
+
const remote = typeof pair.remoteCandidateId === 'string' ? byId.get(pair.remoteCandidateId) : undefined;
|
|
88
|
+
const protocol = local?.protocol === 'udp' || local?.protocol === 'tcp' ? local.protocol : null;
|
|
89
|
+
const relayed = local?.candidateType === 'relay' || remote?.candidateType === 'relay';
|
|
90
|
+
return { protocol, relayed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Run the check on `target`, closing it at the end whatever happens. */
|
|
94
|
+
export async function runPreflight(target: PreflightTarget, stream: MediaStream, options: PreflightOptions = {}, env: PreflightEnv = realEnv): Promise<PreflightReport> {
|
|
95
|
+
const report: PreflightReport = { ok: false, steps: [], path: null, rttMs: null, lossPct: null, uploadKbps: null, level: null, warnings: [] };
|
|
96
|
+
const seen = new Set<MediaWarningCode>();
|
|
97
|
+
const stopWarnings = target.onWarning((code, active) => { if (active) seen.add(code); });
|
|
98
|
+
try {
|
|
99
|
+
let started = env.now();
|
|
100
|
+
try {
|
|
101
|
+
await target.connect();
|
|
102
|
+
report.steps.push({ name: 'join', ok: true, ms: env.now() - started });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
report.steps.push({ name: 'join', ok: false, ms: null, error: String((err as Error)?.message ?? err) });
|
|
105
|
+
return report;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
started = env.now();
|
|
109
|
+
try {
|
|
110
|
+
await target.publish(stream);
|
|
111
|
+
while (target.sendState() !== 'connected') {
|
|
112
|
+
const state = target.sendState();
|
|
113
|
+
if (state === 'failed' || state === 'closed') throw new Error(`media connection ${state}`);
|
|
114
|
+
if (env.now() - started > MEDIA_CONNECT_TIMEOUT_MS) throw new Error('media did not connect in time (a firewall blocking UDP and TCP?)');
|
|
115
|
+
await env.sleep(100);
|
|
116
|
+
}
|
|
117
|
+
report.steps.push({ name: 'media', ok: true, ms: env.now() - started });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
report.steps.push({ name: 'media', ok: false, ms: null, error: String((err as Error)?.message ?? err) });
|
|
120
|
+
return report;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Measure: the worst round trip and loss over the run, the last upload estimate.
|
|
124
|
+
const duration = options.durationMs ?? 5000;
|
|
125
|
+
let previous = new Map<string, TransportSample>();
|
|
126
|
+
const until = env.now() + duration;
|
|
127
|
+
while (env.now() < until) {
|
|
128
|
+
await env.sleep(PREFLIGHT_SAMPLE_MS);
|
|
129
|
+
const stats = await target.sendStats().catch(() => null);
|
|
130
|
+
if (!stats) continue;
|
|
131
|
+
const entries = [...stats];
|
|
132
|
+
report.path = connectionPath(entries) ?? report.path;
|
|
133
|
+
const now = new Map([['send', readTransportStats(entries)]]);
|
|
134
|
+
const q = networkQualityOf(now, previous, true);
|
|
135
|
+
previous = now;
|
|
136
|
+
if (q.rttMs !== null) report.rttMs = Math.max(report.rttMs ?? 0, q.rttMs);
|
|
137
|
+
if (q.lossPct !== null) report.lossPct = Math.max(report.lossPct ?? 0, q.lossPct);
|
|
138
|
+
if (q.availableKbps !== null) report.uploadKbps = q.availableKbps;
|
|
139
|
+
report.level = q.level;
|
|
140
|
+
}
|
|
141
|
+
report.ok = true;
|
|
142
|
+
return report;
|
|
143
|
+
} finally {
|
|
144
|
+
report.warnings = [...seen];
|
|
145
|
+
stopWarnings();
|
|
146
|
+
target.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A tone and a moving picture to publish when the app gives none (Twilio's preflight makes synthetic media too).
|
|
152
|
+
* Returns the stream and a function that stops everything it made. Browser only.
|
|
153
|
+
*/
|
|
154
|
+
export function syntheticMedia(): { stream: MediaStream; stop: () => void } {
|
|
155
|
+
const tracks: MediaStreamTrack[] = [];
|
|
156
|
+
const stops: (() => void)[] = [];
|
|
157
|
+
const AudioCtx = (globalThis as { AudioContext?: typeof AudioContext }).AudioContext;
|
|
158
|
+
if (AudioCtx) {
|
|
159
|
+
const ctx = new AudioCtx();
|
|
160
|
+
const osc = ctx.createOscillator();
|
|
161
|
+
const gain = ctx.createGain();
|
|
162
|
+
gain.gain.value = 0.05;
|
|
163
|
+
const dest = ctx.createMediaStreamDestination();
|
|
164
|
+
osc.connect(gain).connect(dest);
|
|
165
|
+
osc.start();
|
|
166
|
+
tracks.push(...dest.stream.getAudioTracks());
|
|
167
|
+
stops.push(() => { osc.stop(); void ctx.close(); });
|
|
168
|
+
}
|
|
169
|
+
if (typeof document !== 'undefined') {
|
|
170
|
+
const canvas = document.createElement('canvas');
|
|
171
|
+
canvas.width = 640;
|
|
172
|
+
canvas.height = 360;
|
|
173
|
+
const g = canvas.getContext('2d');
|
|
174
|
+
let frame = 0;
|
|
175
|
+
const draw = () => {
|
|
176
|
+
if (!g) return;
|
|
177
|
+
frame += 1;
|
|
178
|
+
g.fillStyle = `hsl(${frame % 360}, 60%, 40%)`;
|
|
179
|
+
g.fillRect(0, 0, canvas.width, canvas.height);
|
|
180
|
+
g.fillStyle = '#fff';
|
|
181
|
+
g.fillRect((frame * 8) % canvas.width, 150, 60, 60);
|
|
182
|
+
};
|
|
183
|
+
draw();
|
|
184
|
+
const timer = setInterval(draw, 1000 / 30);
|
|
185
|
+
const captured = (canvas as HTMLCanvasElement & { captureStream?: (fps: number) => MediaStream }).captureStream?.(30);
|
|
186
|
+
if (captured) tracks.push(...captured.getVideoTracks());
|
|
187
|
+
stops.push(() => clearInterval(timer));
|
|
188
|
+
}
|
|
189
|
+
const stream = new MediaStream(tracks);
|
|
190
|
+
return { stream, stop: () => { for (const t of tracks) t.stop(); for (const s of stops) s(); } };
|
|
191
|
+
}
|
package/src/recording.ts
CHANGED
|
@@ -53,25 +53,26 @@ export class RecordingError extends Error {
|
|
|
53
53
|
}
|
|
54
54
|
const delay = (ms: number) =>
|
|
55
55
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
/** One POST to the recording API. */
|
|
57
|
+
function recordingFetch(base: string, path: string, body: unknown, token?: string): Promise<Response> {
|
|
58
|
+
return fetch(`${base.replace(/\/$/, "")}/recording/upload${path}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
credentials: "omit",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: AbortSignal.timeout(path.endsWith("/complete") ? 180000 : 15000),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
56
69
|
export async function recordingRequest<T>(
|
|
57
70
|
base: string,
|
|
58
71
|
path: string,
|
|
59
72
|
body: unknown,
|
|
60
73
|
token?: string,
|
|
61
74
|
): Promise<T> {
|
|
62
|
-
const response = await
|
|
63
|
-
`${base.replace(/\/$/, "")}/recording/upload${path}`,
|
|
64
|
-
{
|
|
65
|
-
method: "POST",
|
|
66
|
-
credentials: "omit",
|
|
67
|
-
headers: {
|
|
68
|
-
"Content-Type": "application/json",
|
|
69
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
70
|
-
},
|
|
71
|
-
body: JSON.stringify(body),
|
|
72
|
-
signal: AbortSignal.timeout(path.endsWith("/complete") ? 180000 : 15000),
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
+
const response = await recordingFetch(base, path, body, token);
|
|
75
76
|
const result = (await response.json()) as {
|
|
76
77
|
success: boolean;
|
|
77
78
|
data?: T;
|
|
@@ -304,16 +305,53 @@ export class RecordingUploader {
|
|
|
304
305
|
}
|
|
305
306
|
this.sealed = true;
|
|
306
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* How long complete() waits before asking `/complete` again when the
|
|
310
|
+
* answer was lost or unclear: one wait per extra attempt. Exposed for tests.
|
|
311
|
+
* @internal
|
|
312
|
+
*/
|
|
313
|
+
static completeRetryDelaysMs = [1000, 3000];
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Finalise. An accepted `/complete` whose reply cannot be read rejects
|
|
317
|
+
* saying the recording completed. A lost or unclear answer (no reply, an
|
|
318
|
+
* unreadable one, a 5xx, 408/409/425/429) is asked again -- the service's
|
|
319
|
+
* completion is idempotent -- and if it is still unclear, rejects saying the
|
|
320
|
+
* recording may already be saved. Neither is aborted; only a readable
|
|
321
|
+
* refusal is final (V072-04).
|
|
322
|
+
*/
|
|
307
323
|
async complete(): Promise<RecordingFile> {
|
|
308
324
|
await this.seal();
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
325
|
+
const path = `/sessions/${this.session.recording.id}/complete`;
|
|
326
|
+
for (let attempt = 0; ; attempt++) {
|
|
327
|
+
type Reply = { success?: unknown; data?: unknown; error?: { message?: string } };
|
|
328
|
+
let status = 0;
|
|
329
|
+
let body: Reply | null = null;
|
|
330
|
+
try {
|
|
331
|
+
const response = await recordingFetch(this.options.apiUrl, path, {}, this.session.token);
|
|
332
|
+
status = response.status;
|
|
333
|
+
const parsed: unknown = await response.json();
|
|
334
|
+
if (parsed && typeof parsed === "object") body = parsed as Reply;
|
|
335
|
+
} catch {
|
|
336
|
+
// Lost, cut short or unreadable: the service may have saved it.
|
|
337
|
+
}
|
|
338
|
+
if (body && status > 0 && status < 400 && body.success === true) {
|
|
339
|
+
const file = body.data as RecordingFile | null | undefined;
|
|
340
|
+
if (!file || typeof file !== "object" || typeof file.id !== "string") {
|
|
341
|
+
throw new RecordingError("The recording was completed but its reply could not be read", status);
|
|
342
|
+
}
|
|
343
|
+
this.emit("ready");
|
|
344
|
+
return file;
|
|
345
|
+
}
|
|
346
|
+
if (body && status > 0 && status < 500 && ![408, 409, 425, 429].includes(status)) {
|
|
347
|
+
throw new RecordingError(body.error?.message ?? "Recording request failed", status);
|
|
348
|
+
}
|
|
349
|
+
const delays = RecordingUploader.completeRetryDelaysMs;
|
|
350
|
+
if (attempt >= delays.length) {
|
|
351
|
+
throw new RecordingError("The service did not confirm the recording, which may already be saved; it was not aborted", status);
|
|
352
|
+
}
|
|
353
|
+
await delay(delays[attempt]);
|
|
354
|
+
}
|
|
317
355
|
}
|
|
318
356
|
async abort(): Promise<void> {
|
|
319
357
|
this.canceled = true;
|
package/src/speakers.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio levels and active speakers (docs/implementation-progress/RTC_PERF_PROGRESS.md J1).
|
|
3
|
+
*
|
|
4
|
+
* - Agora `volume-indicator` and Tencent TRTC `AUDIO_VOLUME` report every participant's volume on an interval.
|
|
5
|
+
* - Amazon Chime's `DefaultActiveSpeakerPolicy` (read from its code) ranks speakers: a volume above a silence
|
|
6
|
+
* threshold counts as speaking (1, else 0); each sample the speaker's score becomes `score × 0.9 + speaking × 0.1`,
|
|
7
|
+
* and everyone else's drops by `0.2 × speaking` (the takeover); scores under 0.01 are 0. Its detector updates every
|
|
8
|
+
* 200 ms. Sampled together here, so two people speaking at once both stay; the takeover applies to the silent.
|
|
9
|
+
* - LiveKit's server counts a participant as speaking above -35 dBov.
|
|
10
|
+
*
|
|
11
|
+
* Levels are WebRTC's `audioLevel`: linear, 0 = silence, 1 = 0 dBov. -35 dBov is 10^(-35/20) ≈ 0.0178.
|
|
12
|
+
* Pure: the ranking takes levels and gives the order.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Above this level a participant is speaking (LiveKit's -35 dBov). */
|
|
16
|
+
export const SPEAKING_LEVEL = 0.0178;
|
|
17
|
+
/** How often levels are read while someone listens (Chime's detector: 200 ms). */
|
|
18
|
+
export const AUDIO_LEVEL_INTERVAL_MS = 250;
|
|
19
|
+
|
|
20
|
+
export const SPEAKER_WEIGHT = 0.9;
|
|
21
|
+
export const TAKEOVER_RATE = 0.2;
|
|
22
|
+
export const SCORE_CUTOFF = 0.01;
|
|
23
|
+
|
|
24
|
+
/** Chime's active speaker ranking: who has been speaking recently, the most recent and steady first. */
|
|
25
|
+
export class ActiveSpeakers {
|
|
26
|
+
private readonly scores = new Map<string, number>();
|
|
27
|
+
private last: string[] = [];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One sample: each identity's loudest level now (0..1). Identities missing from it count as silent. Returns the new
|
|
31
|
+
* order when it changed, else null.
|
|
32
|
+
*/
|
|
33
|
+
update(levels: ReadonlyMap<string, number>): string[] | null {
|
|
34
|
+
for (const id of levels.keys()) if (!this.scores.has(id)) this.scores.set(id, 0);
|
|
35
|
+
// Chime updates one attendee at a time as volumes arrive; here everyone is sampled together, so people speaking
|
|
36
|
+
// at the same moment do not take over from each other -- only the silent ones lose ground.
|
|
37
|
+
const speakingNow = new Set([...levels].filter(([, level]) => level > SPEAKING_LEVEL).map(([id]) => id));
|
|
38
|
+
for (const id of this.scores.keys()) {
|
|
39
|
+
const speaking = speakingNow.has(id) ? 1 : 0;
|
|
40
|
+
let score = (this.scores.get(id) ?? 0) * SPEAKER_WEIGHT + speaking * (1 - SPEAKER_WEIGHT);
|
|
41
|
+
if (!speaking && speakingNow.size > 0) score = Math.max(score - TAKEOVER_RATE, 0);
|
|
42
|
+
this.scores.set(id, score);
|
|
43
|
+
}
|
|
44
|
+
const order = [...this.scores.entries()]
|
|
45
|
+
.filter(([, score]) => score >= SCORE_CUTOFF)
|
|
46
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
47
|
+
.map(([id]) => id);
|
|
48
|
+
if (order.length === this.last.length && order.every((id, i) => id === this.last[i])) return null;
|
|
49
|
+
this.last = order;
|
|
50
|
+
return order;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Someone left: forget them. Returns the new order when it changed. */
|
|
54
|
+
remove(identity: string): string[] | null {
|
|
55
|
+
if (!this.scores.delete(identity)) return null;
|
|
56
|
+
const order = this.last.filter((id) => id !== identity);
|
|
57
|
+
if (order.length === this.last.length) return null;
|
|
58
|
+
this.last = order;
|
|
59
|
+
return order;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
reset(): void {
|
|
63
|
+
this.scores.clear();
|
|
64
|
+
this.last = [];
|
|
65
|
+
}
|
|
66
|
+
}
|