@relaymessenger/livekit 0.1.0-staging.4 → 0.1.0-staging.6
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/README.md +21 -5
- package/dist/engine-werift.d.ts.map +1 -1
- package/dist/engine-werift.js +10 -0
- package/dist/engine-werift.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/livekit.d.ts +21 -5
- package/dist/livekit.d.ts.map +1 -1
- package/dist/livekit.js +12 -1
- package/dist/livekit.js.map +1 -1
- package/dist/transport.d.ts +91 -8
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +367 -78
- package/dist/transport.js.map +1 -1
- package/package.json +2 -2
package/dist/transport.js
CHANGED
|
@@ -5,10 +5,32 @@ export const DEFAULT_INBOUND_AUDIO = Object.freeze({
|
|
|
5
5
|
});
|
|
6
6
|
const INBOUND_SAMPLE_RATES = new Set([8_000, 12_000, 16_000, 24_000, 48_000]);
|
|
7
7
|
const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 10_000;
|
|
8
|
-
const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
|
|
9
8
|
const AUDIO_SLICE_MS = 10;
|
|
10
9
|
const STALL_CHECK_MS = 500;
|
|
11
10
|
const STALL_AFTER_MS = 2_000;
|
|
11
|
+
/**
|
|
12
|
+
* Restart rule, copied from PartyTracks and Cloudflare (PROTOCOL.md section 4):
|
|
13
|
+
* not `connected` 5 s after the SFU answer (Cloudflare's echo example waits
|
|
14
|
+
* 5000 ms; SFU operations block up to 5 s awaiting `connected`), `failed`, or
|
|
15
|
+
* `disconnected` for 7 s (PartyTracks.ts `timeoutSeconds = 7`). Backoff 250 ms
|
|
16
|
+
* x1.1 per attempt, capped at 10 s (PartyTracks `retryWithBackoff`
|
|
17
|
+
* `backoffFactor: 1.1`, rxjs-helpers.ts defaults). werift never reports
|
|
18
|
+
* `failed` on a session whose checks go unanswered (ice.js:983-986), so the
|
|
19
|
+
* 5 s timer is the trigger that fires in practice.
|
|
20
|
+
*/
|
|
21
|
+
export const RESTART_CONNECT_TIMEOUT_MS = 5_000;
|
|
22
|
+
export const RESTART_DISCONNECTED_MS = 7_000;
|
|
23
|
+
export const RESTART_INITIAL_DELAY_MS = 250;
|
|
24
|
+
export const RESTART_BACKOFF_FACTOR = 1.1;
|
|
25
|
+
export const RESTART_MAX_DELAY_MS = 10_000;
|
|
26
|
+
/**
|
|
27
|
+
* Backoff before restart number `attempt` (1-based) counted since the last
|
|
28
|
+
* session that connected. PartyTracks passes `resetOnSuccess: true` to rxjs
|
|
29
|
+
* `retry` (rxjs-helpers.ts:18, :39); this transport reads "success" as a
|
|
30
|
+
* session reaching `connected`.
|
|
31
|
+
*/
|
|
32
|
+
export const restartDelayMs = (attempt) => Math.min(RESTART_INITIAL_DELAY_MS * RESTART_BACKOFF_FACTOR ** (attempt - 1), RESTART_MAX_DELAY_MS);
|
|
33
|
+
const ACTIVE_CALL_STATUSES = new Set(["ringing", "in-progress"]);
|
|
12
34
|
export class RelayCallTransportError extends Error {
|
|
13
35
|
code;
|
|
14
36
|
constructor(message, code) {
|
|
@@ -83,7 +105,13 @@ const summarizeRoom = (room) => {
|
|
|
83
105
|
return `room: ${parts.join(", ")}`;
|
|
84
106
|
};
|
|
85
107
|
const summarize = (diagnostics) => `${summarizeIce(diagnostics)}; ${summarizeInbound(diagnostics.inbound)}; `
|
|
86
|
-
+ `${summarizeOutbound(diagnostics.outbound)}; ${summarizeRoom(diagnostics.room)}
|
|
108
|
+
+ `${summarizeOutbound(diagnostics.outbound)}; ${summarizeRoom(diagnostics.room)}`
|
|
109
|
+
+ (diagnostics.restarts ? `; restarts ${diagnostics.restarts}` : "");
|
|
110
|
+
const copyIceServers = (servers) => servers.map((server) => ({
|
|
111
|
+
urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
|
|
112
|
+
...(server.username === undefined ? {} : { username: server.username }),
|
|
113
|
+
...(server.credential === undefined ? {} : { credential: server.credential }),
|
|
114
|
+
}));
|
|
87
115
|
const cloneSamples = (samples) => {
|
|
88
116
|
const copy = new Int16Array(samples.length);
|
|
89
117
|
copy.set(samples);
|
|
@@ -129,6 +157,13 @@ class WrtcAudioSource {
|
|
|
129
157
|
this.#runAcceptedMs = 0;
|
|
130
158
|
}
|
|
131
159
|
}
|
|
160
|
+
/** werift's `close()` is async; a rejection there must not become an unhandled one. */
|
|
161
|
+
const closePeer = (peer) => {
|
|
162
|
+
try {
|
|
163
|
+
void Promise.resolve(peer.close()).catch(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
catch { /* already closed */ }
|
|
166
|
+
};
|
|
132
167
|
const delay = (milliseconds) => new Promise((resolve) => {
|
|
133
168
|
const timer = setTimeout(resolve, milliseconds);
|
|
134
169
|
timer.unref?.();
|
|
@@ -137,15 +172,19 @@ const delay = (milliseconds) => new Promise((resolve) => {
|
|
|
137
172
|
* Provider-neutral Node WebRTC bridge for Relay Call rooms.
|
|
138
173
|
*
|
|
139
174
|
* The class owns Relay media negotiation internally. Higher-level adapters only
|
|
140
|
-
* exchange PCM16 frames and call lifecycle events.
|
|
175
|
+
* exchange PCM16 frames and call lifecycle events. When an SFU session never
|
|
176
|
+
* connects or dies, the transport builds a new peer connection on a new
|
|
177
|
+
* session (PROTOCOL.md section 4); the audio source and the `audio` events
|
|
178
|
+
* carry on across the swap, so adapters only hear silence.
|
|
141
179
|
*/
|
|
142
180
|
export class RelayCallTransport {
|
|
143
181
|
#room;
|
|
144
182
|
#providedFactory;
|
|
145
183
|
#engine;
|
|
146
184
|
#iceGatheringTimeoutMs;
|
|
147
|
-
#
|
|
148
|
-
#
|
|
185
|
+
#sessionConnectTimeoutMs;
|
|
186
|
+
#iceServers;
|
|
187
|
+
#iceTransportPolicy;
|
|
149
188
|
#inboundAudio;
|
|
150
189
|
#onWarning;
|
|
151
190
|
#connectStartedAt = 0;
|
|
@@ -158,6 +197,8 @@ export class RelayCallTransport {
|
|
|
158
197
|
/** Engine stats frozen when media shuts down, so diagnostics survive the call's end. */
|
|
159
198
|
#finalSinkStats;
|
|
160
199
|
#finalSourceStats;
|
|
200
|
+
/** Inbound counts from sinks of peers already replaced by a restart. */
|
|
201
|
+
#retiredSinkStats;
|
|
161
202
|
#stallTimer;
|
|
162
203
|
#stallSince;
|
|
163
204
|
#stallWarned = false;
|
|
@@ -167,6 +208,10 @@ export class RelayCallTransport {
|
|
|
167
208
|
#listeners = new Map();
|
|
168
209
|
#factory;
|
|
169
210
|
#peer;
|
|
211
|
+
/** Bumped for every peer built; work started for an older peer stops when it sees a newer one. */
|
|
212
|
+
#peerGeneration = 0;
|
|
213
|
+
/** The current peer has reached `connected` since it was built. */
|
|
214
|
+
#peerConnected = false;
|
|
170
215
|
#audioSource;
|
|
171
216
|
#localTrack;
|
|
172
217
|
#remoteSink;
|
|
@@ -174,6 +219,20 @@ export class RelayCallTransport {
|
|
|
174
219
|
#publishFrame;
|
|
175
220
|
#initialAnswerSdp;
|
|
176
221
|
#negotiationTail = Promise.resolve();
|
|
222
|
+
#restarts = 0;
|
|
223
|
+
/** Restarts since the last `connected`; sets the backoff. */
|
|
224
|
+
#failedAttempts = 0;
|
|
225
|
+
#restartPending = false;
|
|
226
|
+
#wakeRestart;
|
|
227
|
+
#connectTimer;
|
|
228
|
+
#disconnectTimer;
|
|
229
|
+
#callStatus;
|
|
230
|
+
#remoteVideo = false;
|
|
231
|
+
#personConnected = false;
|
|
232
|
+
#peerAudioArrived = false;
|
|
233
|
+
#peerAudioReady = false;
|
|
234
|
+
#peerAudioWaiters = new Set();
|
|
235
|
+
#ended = false;
|
|
177
236
|
#audioGeneration = 0;
|
|
178
237
|
/** `waitForPlayout()` callers released early by `clearAudio()` or `close()`. */
|
|
179
238
|
#playoutWaiters = new Set();
|
|
@@ -210,26 +269,22 @@ export class RelayCallTransport {
|
|
|
210
269
|
}
|
|
211
270
|
this.#inboundAudio = { sampleRate: inbound.sampleRate, channelCount: inbound.channelCount };
|
|
212
271
|
this.#iceGatheringTimeoutMs = options.iceGatheringTimeoutMs ?? DEFAULT_ICE_GATHERING_TIMEOUT_MS;
|
|
213
|
-
this.#
|
|
272
|
+
this.#sessionConnectTimeoutMs = options.sessionConnectTimeoutMs
|
|
273
|
+
?? options.mediaConnectTimeoutMs
|
|
214
274
|
?? options.connectionTimeoutMs
|
|
215
|
-
??
|
|
275
|
+
?? RESTART_CONNECT_TIMEOUT_MS;
|
|
216
276
|
const policy = options.iceTransportPolicy ?? "all";
|
|
217
277
|
if (policy !== "all" && policy !== "relay") {
|
|
218
278
|
throw new Error('iceTransportPolicy must be "all" or "relay".');
|
|
219
279
|
}
|
|
220
|
-
this.#
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
...(server.username === undefined ? {} : { username: server.username }),
|
|
224
|
-
...(server.credential === undefined ? {} : { credential: server.credential }),
|
|
225
|
-
})),
|
|
226
|
-
iceTransportPolicy: policy,
|
|
227
|
-
};
|
|
280
|
+
this.#iceTransportPolicy = policy;
|
|
281
|
+
const iceServers = options.iceServers ?? [];
|
|
282
|
+
this.#iceServers = typeof iceServers === "function" ? iceServers : copyIceServers(iceServers);
|
|
228
283
|
if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
|
|
229
284
|
throw new Error("iceGatheringTimeoutMs must be greater than zero.");
|
|
230
285
|
}
|
|
231
|
-
if (!Number.isFinite(this.#
|
|
232
|
-
throw new Error("
|
|
286
|
+
if (!Number.isFinite(this.#sessionConnectTimeoutMs) || this.#sessionConnectTimeoutMs <= 0) {
|
|
287
|
+
throw new Error("sessionConnectTimeoutMs must be greater than zero.");
|
|
233
288
|
}
|
|
234
289
|
this.#ready = new Promise((resolve, reject) => {
|
|
235
290
|
this.#readyResolve = resolve;
|
|
@@ -250,13 +305,37 @@ export class RelayCallTransport {
|
|
|
250
305
|
this.#listeners.get(event)?.delete(listener);
|
|
251
306
|
return this;
|
|
252
307
|
}
|
|
253
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Join the room, publish, and resolve on the first `connected`. Dead SFU
|
|
310
|
+
* sessions are replaced as they are found (PROTOCOL.md section 4), with no
|
|
311
|
+
* overall deadline: this rejects only when the Call ends, the room or
|
|
312
|
+
* transport closes, the room reports an error, or `signal` aborts (which
|
|
313
|
+
* also closes the transport).
|
|
314
|
+
*/
|
|
315
|
+
async connect(options = {}) {
|
|
254
316
|
if (this.#closed)
|
|
255
317
|
throw new Error("Relay Call transport is closed.");
|
|
318
|
+
const { signal } = options;
|
|
319
|
+
const aborted = () => new RelayCallTransportError("Relay Call connect was aborted.", "aborted");
|
|
320
|
+
if (signal?.aborted)
|
|
321
|
+
throw aborted();
|
|
322
|
+
const abort = () => {
|
|
323
|
+
this.#rejectReady(aborted());
|
|
324
|
+
this.close();
|
|
325
|
+
};
|
|
326
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
327
|
+
try {
|
|
328
|
+
await this.#connect();
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
signal?.removeEventListener("abort", abort);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async #connect() {
|
|
256
335
|
this.#attachRoomHandlers();
|
|
257
336
|
await this.#room.connect();
|
|
258
|
-
if (this.#
|
|
259
|
-
await this.#
|
|
337
|
+
if (this.#factory) {
|
|
338
|
+
await this.#ready;
|
|
260
339
|
return;
|
|
261
340
|
}
|
|
262
341
|
this.#factory = this.#providedFactory ?? await loadWebRTCFactory(this.#engine);
|
|
@@ -266,18 +345,9 @@ export class RelayCallTransport {
|
|
|
266
345
|
throw new RelayCallTransportError("The WebRTC binding created a non-audio Relay track.");
|
|
267
346
|
}
|
|
268
347
|
this.#connectStartedAt = Date.now();
|
|
269
|
-
const peer = this.#factory.createPeerConnection(this.#peerConfig);
|
|
270
|
-
this.#peer = peer;
|
|
271
|
-
this.#publishTransceiver = peer.addTransceiver(this.#localTrack, { direction: "sendonly" });
|
|
272
|
-
this.#observeIce(peer);
|
|
273
|
-
peer.onconnectionstatechange = () => {
|
|
274
|
-
this.#recordTransition("connection", peer.connectionState);
|
|
275
|
-
this.#connectionStateChanged();
|
|
276
|
-
};
|
|
277
|
-
peer.ontrack = (event) => this.#remoteTrack(event.track);
|
|
278
348
|
try {
|
|
279
|
-
await this.#
|
|
280
|
-
await this.#
|
|
349
|
+
await this.#startPeer();
|
|
350
|
+
await this.#ready;
|
|
281
351
|
}
|
|
282
352
|
catch (error) {
|
|
283
353
|
this.close();
|
|
@@ -294,15 +364,15 @@ export class RelayCallTransport {
|
|
|
294
364
|
this.#room.send(this.#publishFrame);
|
|
295
365
|
}
|
|
296
366
|
/**
|
|
297
|
-
* ICE candidates, state transitions, packet counts in both directions
|
|
298
|
-
* room frame counts recorded for this call, with a one-line summary.
|
|
367
|
+
* ICE candidates, state transitions, packet counts in both directions,
|
|
368
|
+
* room frame counts and restarts recorded for this call, with a one-line summary.
|
|
299
369
|
*/
|
|
300
370
|
diagnostics() {
|
|
301
371
|
const snapshot = {
|
|
302
372
|
local: { ...this.#iceLocal },
|
|
303
373
|
remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
|
|
304
374
|
transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
|
|
305
|
-
connected: this.#
|
|
375
|
+
connected: this.#peerConnected,
|
|
306
376
|
inbound: this.#inboundDiagnostics(),
|
|
307
377
|
outbound: this.#outboundDiagnostics(),
|
|
308
378
|
room: {
|
|
@@ -311,14 +381,31 @@ export class RelayCallTransport {
|
|
|
311
381
|
endedReason: this.#endedReason,
|
|
312
382
|
errors: [...this.#roomErrors],
|
|
313
383
|
},
|
|
384
|
+
restarts: this.#restarts,
|
|
314
385
|
};
|
|
315
386
|
return { ...snapshot, summary: summarize(snapshot) };
|
|
316
387
|
}
|
|
317
388
|
#sinceConnect(epochMs) {
|
|
318
389
|
return epochMs === undefined ? undefined : epochMs - this.#connectStartedAt;
|
|
319
390
|
}
|
|
391
|
+
/** The live sink's counts added to those of sinks retired by restarts. */
|
|
392
|
+
#sinkStats() {
|
|
393
|
+
const current = this.#remoteSink?.stats?.();
|
|
394
|
+
const retired = this.#retiredSinkStats;
|
|
395
|
+
if (!retired)
|
|
396
|
+
return current;
|
|
397
|
+
if (!current)
|
|
398
|
+
return { ...retired, recentRtpPackets: 0 };
|
|
399
|
+
return {
|
|
400
|
+
rtpPackets: retired.rtpPackets + current.rtpPackets,
|
|
401
|
+
decodeFailures: retired.decodeFailures + current.decodeFailures,
|
|
402
|
+
firstRtpAt: retired.firstRtpAt ?? current.firstRtpAt,
|
|
403
|
+
lastRtpAt: current.lastRtpAt ?? retired.lastRtpAt,
|
|
404
|
+
recentRtpPackets: current.recentRtpPackets,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
320
407
|
#inboundDiagnostics() {
|
|
321
|
-
const stats = this.#
|
|
408
|
+
const stats = this.#finalSinkStats ?? this.#sinkStats();
|
|
322
409
|
return {
|
|
323
410
|
rtpPackets: stats?.rtpPackets ?? 0,
|
|
324
411
|
decodeFailures: stats?.decodeFailures ?? 0,
|
|
@@ -431,6 +518,41 @@ export class RelayCallTransport {
|
|
|
431
518
|
this.#audioSource?.clear?.();
|
|
432
519
|
this.#releasePlayoutWaiters();
|
|
433
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Resolves once `peerAudio` has fired (at once if it already has). Rejects
|
|
523
|
+
* after `timeoutMs`, or when the Call ends or the transport closes first.
|
|
524
|
+
*/
|
|
525
|
+
waitForPeerAudio(timeoutMs) {
|
|
526
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
527
|
+
return Promise.reject(new Error("waitForPeerAudio timeoutMs must be greater than zero."));
|
|
528
|
+
}
|
|
529
|
+
if (this.#peerAudioReady)
|
|
530
|
+
return Promise.resolve();
|
|
531
|
+
if (this.#closed || this.#ended) {
|
|
532
|
+
return Promise.reject(new RelayCallTransportError("Relay Call ended before the person's audio arrived."));
|
|
533
|
+
}
|
|
534
|
+
return new Promise((resolve, reject) => {
|
|
535
|
+
const waiter = {
|
|
536
|
+
resolve: () => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); resolve(); },
|
|
537
|
+
reject: (error) => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); reject(error); },
|
|
538
|
+
};
|
|
539
|
+
const timer = setTimeout(() => waiter.reject(new RelayCallTransportError(`Timed out waiting for the person's audio (${this.diagnostics().summary})`)), timeoutMs);
|
|
540
|
+
timer.unref?.();
|
|
541
|
+
this.#peerAudioWaiters.add(waiter);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
#checkPeerAudio() {
|
|
545
|
+
if (this.#peerAudioReady || !this.#peerAudioArrived || !this.#personConnected)
|
|
546
|
+
return;
|
|
547
|
+
this.#peerAudioReady = true;
|
|
548
|
+
for (const waiter of [...this.#peerAudioWaiters])
|
|
549
|
+
waiter.resolve();
|
|
550
|
+
this.#emit("peerAudio");
|
|
551
|
+
}
|
|
552
|
+
#rejectPeerAudio(error) {
|
|
553
|
+
for (const waiter of [...this.#peerAudioWaiters])
|
|
554
|
+
waiter.reject(error);
|
|
555
|
+
}
|
|
434
556
|
setMuted(muted) {
|
|
435
557
|
this.#room.userUpdate({ muted });
|
|
436
558
|
}
|
|
@@ -442,17 +564,54 @@ export class RelayCallTransport {
|
|
|
442
564
|
return;
|
|
443
565
|
this.#closed = true;
|
|
444
566
|
this.#rejectReady(new RelayCallTransportError("Relay Call transport closed before media connected."));
|
|
567
|
+
this.#rejectPeerAudio(new RelayCallTransportError("Relay Call transport closed before the person's audio arrived."));
|
|
445
568
|
this.#audioGeneration += 1;
|
|
446
569
|
this.#shutdownMedia();
|
|
447
570
|
this.#releasePlayoutWaiters();
|
|
448
571
|
this.#room.close();
|
|
449
572
|
}
|
|
450
|
-
|
|
451
|
-
|
|
573
|
+
/**
|
|
574
|
+
* Build a peer connection around the one local track and publish it. The
|
|
575
|
+
* first peer sends a plain `offer`; every later one is a restart onto a new
|
|
576
|
+
* SFU session (`restart: true`) with ICE servers fetched again.
|
|
577
|
+
*/
|
|
578
|
+
async #startPeer() {
|
|
579
|
+
const factory = this.#factory;
|
|
580
|
+
const track = this.#localTrack;
|
|
581
|
+
if (!factory || !track)
|
|
582
|
+
throw new Error("Relay Call transport is not connected.");
|
|
583
|
+
const restarts = this.#restarts;
|
|
584
|
+
const generation = ++this.#peerGeneration;
|
|
585
|
+
const iceServers = typeof this.#iceServers === "function"
|
|
586
|
+
? copyIceServers(await this.#iceServers({ restarts }))
|
|
587
|
+
: copyIceServers(this.#iceServers);
|
|
588
|
+
if (this.#closed || this.#ended || generation !== this.#peerGeneration)
|
|
589
|
+
return;
|
|
590
|
+
const peer = factory.createPeerConnection({ iceServers, iceTransportPolicy: this.#iceTransportPolicy });
|
|
591
|
+
this.#peer = peer;
|
|
592
|
+
this.#peerConnected = false;
|
|
593
|
+
this.#initialAnswerSdp = undefined;
|
|
594
|
+
this.#publishTransceiver = peer.addTransceiver(track, { direction: "sendonly" });
|
|
595
|
+
this.#observeIce(peer);
|
|
596
|
+
peer.onconnectionstatechange = () => {
|
|
597
|
+
if (this.#peer !== peer)
|
|
598
|
+
return;
|
|
599
|
+
this.#recordTransition("connection", peer.connectionState);
|
|
600
|
+
this.#connectionStateChanged(peer);
|
|
601
|
+
};
|
|
602
|
+
peer.ontrack = (event) => {
|
|
603
|
+
if (this.#peer === peer)
|
|
604
|
+
this.#remoteTrack(event.track);
|
|
605
|
+
};
|
|
606
|
+
await this.#publishLocalAudio(peer, restarts > 0);
|
|
607
|
+
}
|
|
608
|
+
async #publishLocalAudio(peer, restart) {
|
|
452
609
|
const offer = await peer.createOffer();
|
|
453
610
|
await peer.setLocalDescription(offer);
|
|
454
611
|
await this.#waitForIceGathering(peer);
|
|
455
|
-
|
|
612
|
+
if (this.#peer !== peer)
|
|
613
|
+
return;
|
|
614
|
+
const description = this.#localDescription(peer, "offer");
|
|
456
615
|
const mid = this.#publishTransceiver?.mid;
|
|
457
616
|
if (!mid)
|
|
458
617
|
throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
|
|
@@ -460,6 +619,7 @@ export class RelayCallTransport {
|
|
|
460
619
|
type: "offer",
|
|
461
620
|
session_description: description,
|
|
462
621
|
tracks: [{ mid, name: "audio" }],
|
|
622
|
+
...(restart ? { restart: true } : {}),
|
|
463
623
|
};
|
|
464
624
|
this.#room.send(this.#publishFrame);
|
|
465
625
|
}
|
|
@@ -476,7 +636,18 @@ export class RelayCallTransport {
|
|
|
476
636
|
});
|
|
477
637
|
this.#room.on("roomState", (frame) => {
|
|
478
638
|
this.#roomStates += 1;
|
|
639
|
+
this.#callStatus = frame.call?.status;
|
|
640
|
+
// A Call has exactly one agent; the transport is that agent, so the
|
|
641
|
+
// person is the other participant.
|
|
642
|
+
const person = frame.participants?.find((participant) => participant.kind === "user");
|
|
643
|
+
const video = person?.video === true;
|
|
644
|
+
const videoChanged = video !== this.#remoteVideo;
|
|
645
|
+
this.#remoteVideo = video;
|
|
646
|
+
this.#personConnected = person?.connected === true;
|
|
479
647
|
this.#emit("roomState", frame);
|
|
648
|
+
if (videoChanged)
|
|
649
|
+
this.#emit("remoteVideo", video);
|
|
650
|
+
this.#checkPeerAudio();
|
|
480
651
|
});
|
|
481
652
|
this.#room.on("error", (error) => {
|
|
482
653
|
if (error instanceof Error) {
|
|
@@ -487,8 +658,10 @@ export class RelayCallTransport {
|
|
|
487
658
|
this.#serverError(error);
|
|
488
659
|
});
|
|
489
660
|
this.#room.on("ended", (frame) => {
|
|
661
|
+
this.#ended = true;
|
|
490
662
|
this.#endedReason = frame.reason;
|
|
491
663
|
this.#rejectReady(new RelayCallTransportError(`Relay Call ended before media connected (${frame.reason}).`));
|
|
664
|
+
this.#rejectPeerAudio(new RelayCallTransportError(`Relay Call ended before the person's audio arrived (${frame.reason}).`));
|
|
492
665
|
this.clearAudio();
|
|
493
666
|
this.#shutdownMedia();
|
|
494
667
|
this.#emit("ended", frame);
|
|
@@ -509,25 +682,42 @@ export class RelayCallTransport {
|
|
|
509
682
|
});
|
|
510
683
|
}
|
|
511
684
|
async #serverAnswer(frame) {
|
|
512
|
-
|
|
685
|
+
// No peer: the answer is for a session a restart already replaced.
|
|
686
|
+
const peer = this.#peer;
|
|
687
|
+
if (!peer)
|
|
688
|
+
return;
|
|
513
689
|
const sdp = frame.session_description.sdp;
|
|
514
690
|
// Reconnecting the signaling socket replays the exact initial offer. Relay
|
|
515
691
|
// returns its cached answer; applying that answer again in stable state is
|
|
516
692
|
// invalid WebRTC signaling, so recognize and ignore the replay.
|
|
517
693
|
if (peer.signalingState === "stable" && this.#initialAnswerSdp === sdp)
|
|
518
694
|
return;
|
|
519
|
-
|
|
695
|
+
const initial = this.#initialAnswerSdp === undefined;
|
|
696
|
+
if (initial)
|
|
520
697
|
this.#recordRemoteCandidates(sdp);
|
|
521
698
|
await peer.setRemoteDescription(frame.session_description);
|
|
699
|
+
if (this.#peer !== peer)
|
|
700
|
+
return;
|
|
522
701
|
this.#initialAnswerSdp ??= sdp;
|
|
702
|
+
if (initial && !this.#peerConnected)
|
|
703
|
+
this.#armConnectTimer(peer);
|
|
523
704
|
}
|
|
524
705
|
async #serverOffer(frame) {
|
|
525
|
-
const peer = this.#
|
|
706
|
+
const peer = this.#peer;
|
|
707
|
+
// A pull offer that arrives while this participant's restart offer is
|
|
708
|
+
// unanswered was sent for the replaced session: the room clears those
|
|
709
|
+
// pulls on restart and pulls again after the new session connects.
|
|
710
|
+
if (!peer || peer.signalingState === "have-local-offer")
|
|
711
|
+
return;
|
|
712
|
+
// `video` m-lines are answered receive-only by the engine and never decoded
|
|
713
|
+
// (`#remoteTrack` takes audio only).
|
|
526
714
|
await peer.setRemoteDescription(frame.session_description);
|
|
527
715
|
const answer = await peer.createAnswer();
|
|
528
716
|
await peer.setLocalDescription(answer);
|
|
529
717
|
await this.#waitForIceGathering(peer);
|
|
530
|
-
this.#
|
|
718
|
+
if (this.#peer !== peer)
|
|
719
|
+
return;
|
|
720
|
+
this.#room.send({ type: "answer", session_description: this.#localDescription(peer, "answer") });
|
|
531
721
|
}
|
|
532
722
|
#serverError(frame) {
|
|
533
723
|
this.#roomErrors.push(frame.message);
|
|
@@ -535,15 +725,25 @@ export class RelayCallTransport {
|
|
|
535
725
|
this.#rejectReady(error);
|
|
536
726
|
this.#emit("error", error);
|
|
537
727
|
}
|
|
538
|
-
#connectionStateChanged() {
|
|
539
|
-
const state =
|
|
540
|
-
if (state === "connected"
|
|
541
|
-
this.#
|
|
728
|
+
#connectionStateChanged(peer) {
|
|
729
|
+
const state = peer.connectionState;
|
|
730
|
+
if (state === "connected") {
|
|
731
|
+
this.#clearTimer("connect");
|
|
732
|
+
this.#clearTimer("disconnect");
|
|
733
|
+
if (this.#peerConnected)
|
|
734
|
+
return;
|
|
735
|
+
this.#peerConnected = true;
|
|
736
|
+
this.#failedAttempts = 0;
|
|
542
737
|
try {
|
|
738
|
+
// Sent for every new session: the room re-pulls a restarted participant's
|
|
739
|
+
// tracks once it reports `connected` (PROTOCOL.md section 2).
|
|
543
740
|
this.#room.connected();
|
|
544
|
-
this.#
|
|
545
|
-
|
|
546
|
-
|
|
741
|
+
if (!this.#reportedConnected) {
|
|
742
|
+
this.#reportedConnected = true;
|
|
743
|
+
this.#resolveReady();
|
|
744
|
+
this.#startStallGuard();
|
|
745
|
+
this.#emit("connected");
|
|
746
|
+
}
|
|
547
747
|
}
|
|
548
748
|
catch (error) {
|
|
549
749
|
const parsed = error instanceof Error ? error : new Error(String(error));
|
|
@@ -552,10 +752,113 @@ export class RelayCallTransport {
|
|
|
552
752
|
}
|
|
553
753
|
}
|
|
554
754
|
else if (state === "failed") {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
755
|
+
this.#requestRestart("failed", peer);
|
|
756
|
+
}
|
|
757
|
+
else if (state === "disconnected") {
|
|
758
|
+
if (this.#disconnectTimer)
|
|
759
|
+
return;
|
|
760
|
+
this.#disconnectTimer = setTimeout(() => {
|
|
761
|
+
this.#disconnectTimer = undefined;
|
|
762
|
+
if (peer.connectionState !== "connected")
|
|
763
|
+
this.#requestRestart("disconnected", peer);
|
|
764
|
+
}, RESTART_DISCONNECTED_MS);
|
|
765
|
+
this.#disconnectTimer.unref?.();
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
#armConnectTimer(peer) {
|
|
769
|
+
this.#clearTimer("connect");
|
|
770
|
+
this.#connectTimer = setTimeout(() => {
|
|
771
|
+
this.#connectTimer = undefined;
|
|
772
|
+
if (!this.#peerConnected)
|
|
773
|
+
this.#requestRestart("timeout", peer);
|
|
774
|
+
}, this.#sessionConnectTimeoutMs);
|
|
775
|
+
this.#connectTimer.unref?.();
|
|
776
|
+
}
|
|
777
|
+
#clearTimer(which) {
|
|
778
|
+
if (which === "connect") {
|
|
779
|
+
clearTimeout(this.#connectTimer);
|
|
780
|
+
this.#connectTimer = undefined;
|
|
558
781
|
}
|
|
782
|
+
else {
|
|
783
|
+
clearTimeout(this.#disconnectTimer);
|
|
784
|
+
this.#disconnectTimer = undefined;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
#callActive() {
|
|
788
|
+
if (this.#closed || this.#ended)
|
|
789
|
+
return false;
|
|
790
|
+
return this.#callStatus === undefined || ACTIVE_CALL_STATUSES.has(this.#callStatus);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Retire `peer` now, wait the backoff, then publish from a new peer on a new
|
|
794
|
+
* session. Unlimited while the Call is ringing or in progress; stops on
|
|
795
|
+
* `ended`, a terminal status, or `close()`.
|
|
796
|
+
*/
|
|
797
|
+
#requestRestart(reason, peer) {
|
|
798
|
+
if (this.#restartPending || peer !== this.#peer || !this.#callActive())
|
|
799
|
+
return;
|
|
800
|
+
const summary = this.diagnostics().summary;
|
|
801
|
+
this.#restartPending = true;
|
|
802
|
+
this.#restarts += 1;
|
|
803
|
+
this.#failedAttempts += 1;
|
|
804
|
+
const restarts = this.#restarts;
|
|
805
|
+
const delayMs = restartDelayMs(this.#failedAttempts);
|
|
806
|
+
this.#retirePeer();
|
|
807
|
+
this.#queueNegotiation(async () => {
|
|
808
|
+
await this.#restartBackoff(delayMs);
|
|
809
|
+
this.#restartPending = false;
|
|
810
|
+
if (!this.#callActive())
|
|
811
|
+
return;
|
|
812
|
+
try {
|
|
813
|
+
await this.#startPeer();
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
if (!this.#callActive())
|
|
817
|
+
return;
|
|
818
|
+
const parsed = error instanceof Error ? error : new Error(String(error));
|
|
819
|
+
this.#emit("error", new RelayCallTransportError(`Relay WebRTC restart ${restarts} failed: ${parsed.message}`, "restart_failed"));
|
|
820
|
+
this.#requestRestart("error", this.#peer);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (this.#peer)
|
|
824
|
+
this.#emit("restarted", { reason, summary, restarts, delayMs });
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
#restartBackoff(milliseconds) {
|
|
828
|
+
return new Promise((resolve) => {
|
|
829
|
+
const wake = () => {
|
|
830
|
+
clearTimeout(timer);
|
|
831
|
+
if (this.#wakeRestart === wake)
|
|
832
|
+
this.#wakeRestart = undefined;
|
|
833
|
+
resolve();
|
|
834
|
+
};
|
|
835
|
+
const timer = setTimeout(wake, milliseconds);
|
|
836
|
+
timer.unref?.();
|
|
837
|
+
this.#wakeRestart = wake;
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
/** Close the current peer and its sink; the audio source and local track stay for the next peer. */
|
|
841
|
+
#retirePeer() {
|
|
842
|
+
this.#clearTimer("connect");
|
|
843
|
+
this.#clearTimer("disconnect");
|
|
844
|
+
const peer = this.#peer;
|
|
845
|
+
this.#peer = undefined;
|
|
846
|
+
this.#peerConnected = false;
|
|
847
|
+
this.#publishTransceiver = undefined;
|
|
848
|
+
this.#initialAnswerSdp = undefined;
|
|
849
|
+
if (this.#remoteSink) {
|
|
850
|
+
this.#retiredSinkStats = this.#sinkStats();
|
|
851
|
+
this.#remoteSink.stop();
|
|
852
|
+
this.#remoteSink = undefined;
|
|
853
|
+
}
|
|
854
|
+
if (!peer)
|
|
855
|
+
return;
|
|
856
|
+
peer.onconnectionstatechange = null;
|
|
857
|
+
peer.ontrack = null;
|
|
858
|
+
peer.onicecandidate = null;
|
|
859
|
+
peer.onicegatheringstatechange = null;
|
|
860
|
+
peer.oniceconnectionstatechange = null;
|
|
861
|
+
closePeer(peer);
|
|
559
862
|
}
|
|
560
863
|
#remoteTrack(track) {
|
|
561
864
|
if (track.kind !== "audio" || !this.#factory)
|
|
@@ -577,6 +880,10 @@ export class RelayCallTransport {
|
|
|
577
880
|
sampleRate: data.sampleRate,
|
|
578
881
|
channelCount,
|
|
579
882
|
});
|
|
883
|
+
if (!this.#peerAudioArrived) {
|
|
884
|
+
this.#peerAudioArrived = true;
|
|
885
|
+
this.#checkPeerAudio();
|
|
886
|
+
}
|
|
580
887
|
};
|
|
581
888
|
}
|
|
582
889
|
#writeAudio(frame, generation) {
|
|
@@ -628,23 +935,6 @@ export class RelayCallTransport {
|
|
|
628
935
|
changed();
|
|
629
936
|
});
|
|
630
937
|
}
|
|
631
|
-
async #waitForConnection() {
|
|
632
|
-
if (this.#reportedConnected)
|
|
633
|
-
return;
|
|
634
|
-
await new Promise((resolve, reject) => {
|
|
635
|
-
const timeout = setTimeout(() => {
|
|
636
|
-
reject(new RelayCallTransportError(`Timed out connecting Relay WebRTC media (${this.diagnostics().summary})`));
|
|
637
|
-
}, this.#connectionTimeoutMs);
|
|
638
|
-
timeout.unref?.();
|
|
639
|
-
this.#ready.then(() => {
|
|
640
|
-
clearTimeout(timeout);
|
|
641
|
-
resolve();
|
|
642
|
-
}, (error) => {
|
|
643
|
-
clearTimeout(timeout);
|
|
644
|
-
reject(error);
|
|
645
|
-
});
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
938
|
/**
|
|
649
939
|
* werift emits these as W3C-style handler calls (peerConnection.js:314-341:
|
|
650
940
|
* `onicegatheringstatechange`, `oniceconnectionstatechange`,
|
|
@@ -691,27 +981,26 @@ export class RelayCallTransport {
|
|
|
691
981
|
this.#readySettled = true;
|
|
692
982
|
this.#readyReject?.(error);
|
|
693
983
|
}
|
|
694
|
-
#localDescription(type) {
|
|
695
|
-
const local =
|
|
984
|
+
#localDescription(peer, type) {
|
|
985
|
+
const local = peer.localDescription;
|
|
696
986
|
if (!local || local.type !== type || !local.sdp) {
|
|
697
987
|
throw new RelayCallTransportError(`Relay WebRTC did not produce a complete ${type} SDP.`);
|
|
698
988
|
}
|
|
699
989
|
return { type, sdp: local.sdp };
|
|
700
990
|
}
|
|
701
|
-
#requirePeer() {
|
|
702
|
-
if (!this.#peer)
|
|
703
|
-
throw new Error("Relay Call transport is not connected.");
|
|
704
|
-
return this.#peer;
|
|
705
|
-
}
|
|
706
991
|
#shutdownMedia() {
|
|
707
992
|
this.#stopStallGuard();
|
|
708
|
-
this.#
|
|
993
|
+
this.#clearTimer("connect");
|
|
994
|
+
this.#clearTimer("disconnect");
|
|
995
|
+
this.#wakeRestart?.();
|
|
996
|
+
this.#finalSinkStats ??= this.#sinkStats();
|
|
709
997
|
this.#finalSourceStats ??= this.#audioSource?.stats?.();
|
|
710
998
|
this.#remoteSink?.stop();
|
|
711
999
|
this.#remoteSink = undefined;
|
|
712
1000
|
this.#localTrack?.stop();
|
|
713
1001
|
this.#localTrack = undefined;
|
|
714
|
-
this.#peer
|
|
1002
|
+
if (this.#peer)
|
|
1003
|
+
closePeer(this.#peer);
|
|
715
1004
|
this.#peer = undefined;
|
|
716
1005
|
this.#audioSource = undefined;
|
|
717
1006
|
}
|