@relaymessenger/livekit 0.1.0-staging.1 → 0.1.0-staging.10

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/dist/transport.js CHANGED
@@ -1,6 +1,36 @@
1
+ /** Opus's native rate and Relay's wire channel count. */
2
+ export const DEFAULT_INBOUND_AUDIO = Object.freeze({
3
+ sampleRate: 48_000,
4
+ channelCount: 2,
5
+ });
6
+ const INBOUND_SAMPLE_RATES = new Set([8_000, 12_000, 16_000, 24_000, 48_000]);
1
7
  const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 10_000;
2
- const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
3
8
  const AUDIO_SLICE_MS = 10;
9
+ const STALL_CHECK_MS = 500;
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"]);
4
34
  export class RelayCallTransportError extends Error {
5
35
  code;
6
36
  constructor(message, code) {
@@ -23,7 +53,7 @@ const loadWebRTCFactory = async (engine) => {
23
53
  iceServers: config.iceServers,
24
54
  iceTransportPolicy: config.iceTransportPolicy,
25
55
  }),
26
- createAudioSource: () => new wrtc.nonstandard.RTCAudioSource(),
56
+ createAudioSource: () => new WrtcAudioSource(new wrtc.nonstandard.RTCAudioSource()),
27
57
  createAudioSink: (track) => new wrtc.nonstandard.RTCAudioSink(track),
28
58
  };
29
59
  };
@@ -60,11 +90,91 @@ const summarizeIce = (diagnostics) => {
60
90
  states.push("no connected");
61
91
  return `${localPart}; ${remotePart}; states: ${states.join(", ")}`;
62
92
  };
93
+ const span = (first, last) => first === undefined || last === undefined ? "no packets" : `first ${seconds(first)} last ${seconds(last)}`;
94
+ const summarizeInbound = (inbound) => `in: ${inbound.rtpPackets} rtp, ${inbound.decodeFailures} bad, ${inbound.frames} frames, `
95
+ + `${span(inbound.firstPacketAtMs, inbound.lastPacketAtMs)}, ${inbound.recentRtpPackets}/5s`;
96
+ const summarizeOutbound = (outbound) => `out: ${outbound.frames} frames, ${outbound.opusPackets} opus, ${outbound.rtpPackets} rtp, `
97
+ + `silence ${outbound.silencePackets}, `
98
+ + `${span(outbound.firstPacketAtMs, outbound.lastPacketAtMs)}, ${outbound.recentRtpPackets}/5s, `
99
+ + `queue ${outbound.queued}, pacer ${outbound.pacerAlive === undefined ? "n/a" : outbound.pacerAlive ? "alive" : "idle"}`;
100
+ const summarizeRoom = (room) => {
101
+ const parts = [`${room.roomStates} roomState`, `${room.offers} offer`];
102
+ if (room.endedReason !== undefined)
103
+ parts.push(`ended ${room.endedReason}`);
104
+ if (room.errors.length)
105
+ parts.push(`error ${room.errors.map((text) => JSON.stringify(text)).join(", ")}`);
106
+ return `room: ${parts.join(", ")}`;
107
+ };
108
+ const summarize = (diagnostics) => `${summarizeIce(diagnostics)}; ${summarizeInbound(diagnostics.inbound)}; `
109
+ + `${summarizeOutbound(diagnostics.outbound)}; ${summarizeRoom(diagnostics.room)}`
110
+ + (diagnostics.restarts ? `; restarts ${diagnostics.restarts}` : "");
111
+ const copyIceServers = (servers) => servers.map((server) => ({
112
+ urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
113
+ ...(server.username === undefined ? {} : { username: server.username }),
114
+ ...(server.credential === undefined ? {} : { credential: server.credential }),
115
+ }));
63
116
  const cloneSamples = (samples) => {
64
117
  const copy = new Int16Array(samples.length);
65
118
  copy.set(samples);
66
119
  return copy;
67
120
  };
121
+ /**
122
+ * `@roamhq/wrtc`'s `RTCAudioSource` plays each 10 ms slice through libwebrtc's
123
+ * own clock and exposes no queue, so this wrapper estimates it from wall time:
124
+ * queued = accepted since the run started minus the time elapsed. Best effort;
125
+ * the werift engine (the default) reports its real queue.
126
+ *
127
+ * It sends no silence of its own. The source has no timer: `onData` hands the
128
+ * slice to the track's sinks and returns (`RTCAudioSource::OnData` ->
129
+ * `PushData`, node-webrtc src/interfaces/rtc_audio_source.cc and
130
+ * rtc_audio_source.hh, the repository @roamhq/wrtc 0.10.0 is published
131
+ * from), so libwebrtc receives audio only while the caller writes it, and
132
+ * this wrapper has no `start()`. What libwebrtc puts on the wire between
133
+ * writes was not measured; a caller of this
134
+ * engine that needs the track to carry data between utterances writes
135
+ * silence itself.
136
+ */
137
+ class WrtcAudioSource {
138
+ #inner;
139
+ #runStartedAt = 0;
140
+ #runAcceptedMs = 0;
141
+ constructor(inner) {
142
+ this.#inner = inner;
143
+ }
144
+ createTrack() {
145
+ return this.#inner.createTrack();
146
+ }
147
+ onData(data) {
148
+ const now = Date.now();
149
+ if (this.queuedMs() === 0) {
150
+ this.#runStartedAt = now;
151
+ this.#runAcceptedMs = 0;
152
+ }
153
+ this.#runAcceptedMs += (data.numberOfFrames / data.sampleRate) * 1_000;
154
+ this.#inner.onData(data);
155
+ }
156
+ queuedMs() {
157
+ if (this.#runAcceptedMs === 0)
158
+ return 0;
159
+ return Math.max(0, this.#runAcceptedMs - (Date.now() - this.#runStartedAt));
160
+ }
161
+ async waitForDrain() {
162
+ const remaining = this.queuedMs();
163
+ if (remaining > 0)
164
+ await delay(remaining);
165
+ }
166
+ clear() {
167
+ // libwebrtc keeps slices already handed over; only the estimate can be reset.
168
+ this.#runAcceptedMs = 0;
169
+ }
170
+ }
171
+ /** werift's `close()` is async; a rejection there must not become an unhandled one. */
172
+ const closePeer = (peer) => {
173
+ try {
174
+ void Promise.resolve(peer.close()).catch(() => undefined);
175
+ }
176
+ catch { /* already closed */ }
177
+ };
68
178
  const delay = (milliseconds) => new Promise((resolve) => {
69
179
  const timer = setTimeout(resolve, milliseconds);
70
180
  timer.unref?.();
@@ -73,22 +183,46 @@ const delay = (milliseconds) => new Promise((resolve) => {
73
183
  * Provider-neutral Node WebRTC bridge for Relay Call rooms.
74
184
  *
75
185
  * The class owns Relay media negotiation internally. Higher-level adapters only
76
- * exchange PCM16 frames and call lifecycle events.
186
+ * exchange PCM16 frames and call lifecycle events. When an SFU session never
187
+ * connects or dies, the transport builds a new peer connection on a new
188
+ * session (PROTOCOL.md section 4); the audio source and the `audio` events
189
+ * carry on across the swap, so adapters only hear silence.
77
190
  */
78
191
  export class RelayCallTransport {
79
192
  #room;
80
193
  #providedFactory;
81
194
  #engine;
82
195
  #iceGatheringTimeoutMs;
83
- #connectionTimeoutMs;
84
- #peerConfig;
196
+ #sessionConnectTimeoutMs;
197
+ #iceServers;
198
+ #iceTransportPolicy;
199
+ #inboundAudio;
200
+ #onWarning;
85
201
  #connectStartedAt = 0;
202
+ #inboundFrames = 0;
203
+ #outboundFrames = 0;
204
+ #roomStates = 0;
205
+ #roomOffers = 0;
206
+ #endedReason;
207
+ #roomErrors = [];
208
+ /** Engine stats frozen when media shuts down, so diagnostics survive the call's end. */
209
+ #finalSinkStats;
210
+ #finalSourceStats;
211
+ /** Inbound counts from sinks of peers already replaced by a restart. */
212
+ #retiredSinkStats;
213
+ #stallTimer;
214
+ #stallSince;
215
+ #stallWarned = false;
86
216
  #iceLocal = { host: 0, srflx: 0, relay: 0, other: 0 };
87
217
  #iceRemote = [];
88
218
  #iceTransitions = [];
89
219
  #listeners = new Map();
90
220
  #factory;
91
221
  #peer;
222
+ /** Bumped for every peer built; work started for an older peer stops when it sees a newer one. */
223
+ #peerGeneration = 0;
224
+ /** The current peer has reached `connected` since it was built. */
225
+ #peerConnected = false;
92
226
  #audioSource;
93
227
  #localTrack;
94
228
  #remoteSink;
@@ -96,8 +230,23 @@ export class RelayCallTransport {
96
230
  #publishFrame;
97
231
  #initialAnswerSdp;
98
232
  #negotiationTail = Promise.resolve();
99
- #outputTail = Promise.resolve();
233
+ #restarts = 0;
234
+ /** Restarts since the last `connected`; sets the backoff. */
235
+ #failedAttempts = 0;
236
+ #restartPending = false;
237
+ #wakeRestart;
238
+ #connectTimer;
239
+ #disconnectTimer;
240
+ #callStatus;
241
+ #remoteVideo = false;
242
+ #personConnected = false;
243
+ #peerAudioArrived = false;
244
+ #peerAudioReady = false;
245
+ #peerAudioWaiters = new Set();
246
+ #ended = false;
100
247
  #audioGeneration = 0;
248
+ /** `waitForPlayout()` callers released early by `clearAudio()` or `close()`. */
249
+ #playoutWaiters = new Set();
101
250
  #reportedConnected = false;
102
251
  #readySettled = false;
103
252
  #readyResolve;
@@ -111,30 +260,42 @@ export class RelayCallTransport {
111
260
  this.#room = options.roomClient ?? options.relay.calls.room(options.callId, options.room);
112
261
  this.#providedFactory = options.webRTC;
113
262
  this.#engine = options.engine ?? "werift";
263
+ this.#onWarning = options.onWarning ?? (() => undefined);
114
264
  if (this.#engine !== "werift" && this.#engine !== "wrtc") {
115
265
  throw new Error('engine must be "werift" or "wrtc".');
116
266
  }
267
+ const inbound = options.inboundAudio ?? DEFAULT_INBOUND_AUDIO;
268
+ if (!INBOUND_SAMPLE_RATES.has(inbound.sampleRate)) {
269
+ throw new Error("inboundAudio.sampleRate must be 8000, 12000, 16000, 24000 or 48000.");
270
+ }
271
+ if (inbound.channelCount !== 1 && inbound.channelCount !== 2) {
272
+ throw new Error("inboundAudio.channelCount must be 1 or 2.");
273
+ }
274
+ const isDefaultInbound = inbound.sampleRate === DEFAULT_INBOUND_AUDIO.sampleRate
275
+ && inbound.channelCount === DEFAULT_INBOUND_AUDIO.channelCount;
276
+ if (!options.webRTC && this.#engine === "wrtc" && !isDefaultInbound) {
277
+ // `@roamhq/wrtc`'s nonstandard RTCAudioSink hands over libwebrtc's own
278
+ // PCM and takes no format; honouring another one would mean resampling here.
279
+ throw new Error('The "wrtc" engine cannot decode to inboundAudio; use the "werift" engine.');
280
+ }
281
+ this.#inboundAudio = { sampleRate: inbound.sampleRate, channelCount: inbound.channelCount };
117
282
  this.#iceGatheringTimeoutMs = options.iceGatheringTimeoutMs ?? DEFAULT_ICE_GATHERING_TIMEOUT_MS;
118
- this.#connectionTimeoutMs = options.mediaConnectTimeoutMs
283
+ this.#sessionConnectTimeoutMs = options.sessionConnectTimeoutMs
284
+ ?? options.mediaConnectTimeoutMs
119
285
  ?? options.connectionTimeoutMs
120
- ?? DEFAULT_CONNECTION_TIMEOUT_MS;
286
+ ?? RESTART_CONNECT_TIMEOUT_MS;
121
287
  const policy = options.iceTransportPolicy ?? "all";
122
288
  if (policy !== "all" && policy !== "relay") {
123
289
  throw new Error('iceTransportPolicy must be "all" or "relay".');
124
290
  }
125
- this.#peerConfig = {
126
- iceServers: (options.iceServers ?? []).map((server) => ({
127
- urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
128
- ...(server.username === undefined ? {} : { username: server.username }),
129
- ...(server.credential === undefined ? {} : { credential: server.credential }),
130
- })),
131
- iceTransportPolicy: policy,
132
- };
291
+ this.#iceTransportPolicy = policy;
292
+ const iceServers = options.iceServers ?? [];
293
+ this.#iceServers = typeof iceServers === "function" ? iceServers : copyIceServers(iceServers);
133
294
  if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
134
295
  throw new Error("iceGatheringTimeoutMs must be greater than zero.");
135
296
  }
136
- if (!Number.isFinite(this.#connectionTimeoutMs) || this.#connectionTimeoutMs <= 0) {
137
- throw new Error("mediaConnectTimeoutMs must be greater than zero.");
297
+ if (!Number.isFinite(this.#sessionConnectTimeoutMs) || this.#sessionConnectTimeoutMs <= 0) {
298
+ throw new Error("sessionConnectTimeoutMs must be greater than zero.");
138
299
  }
139
300
  this.#ready = new Promise((resolve, reject) => {
140
301
  this.#readyResolve = resolve;
@@ -155,13 +316,37 @@ export class RelayCallTransport {
155
316
  this.#listeners.get(event)?.delete(listener);
156
317
  return this;
157
318
  }
158
- async connect() {
319
+ /**
320
+ * Join the room, publish, and resolve on the first `connected`. Dead SFU
321
+ * sessions are replaced as they are found (PROTOCOL.md section 4), with no
322
+ * overall deadline: this rejects only when the Call ends, the room or
323
+ * transport closes, the room reports an error, or `signal` aborts (which
324
+ * also closes the transport).
325
+ */
326
+ async connect(options = {}) {
159
327
  if (this.#closed)
160
328
  throw new Error("Relay Call transport is closed.");
329
+ const { signal } = options;
330
+ const aborted = () => new RelayCallTransportError("Relay Call connect was aborted.", "aborted");
331
+ if (signal?.aborted)
332
+ throw aborted();
333
+ const abort = () => {
334
+ this.#rejectReady(aborted());
335
+ this.close();
336
+ };
337
+ signal?.addEventListener("abort", abort, { once: true });
338
+ try {
339
+ await this.#connect();
340
+ }
341
+ finally {
342
+ signal?.removeEventListener("abort", abort);
343
+ }
344
+ }
345
+ async #connect() {
161
346
  this.#attachRoomHandlers();
162
347
  await this.#room.connect();
163
- if (this.#peer) {
164
- await this.#waitForConnection();
348
+ if (this.#factory) {
349
+ await this.#ready;
165
350
  return;
166
351
  }
167
352
  this.#factory = this.#providedFactory ?? await loadWebRTCFactory(this.#engine);
@@ -171,18 +356,9 @@ export class RelayCallTransport {
171
356
  throw new RelayCallTransportError("The WebRTC binding created a non-audio Relay track.");
172
357
  }
173
358
  this.#connectStartedAt = Date.now();
174
- const peer = this.#factory.createPeerConnection(this.#peerConfig);
175
- this.#peer = peer;
176
- this.#publishTransceiver = peer.addTransceiver(this.#localTrack, { direction: "sendonly" });
177
- this.#observeIce(peer);
178
- peer.onconnectionstatechange = () => {
179
- this.#recordTransition("connection", peer.connectionState);
180
- this.#connectionStateChanged();
181
- };
182
- peer.ontrack = (event) => this.#remoteTrack(event.track);
183
359
  try {
184
- await this.#publishLocalAudio();
185
- await this.#waitForConnection();
360
+ await this.#startPeer();
361
+ await this.#ready;
186
362
  }
187
363
  catch (error) {
188
364
  this.close();
@@ -198,19 +374,110 @@ export class RelayCallTransport {
198
374
  await this.#room.reconnect();
199
375
  this.#room.send(this.#publishFrame);
200
376
  }
201
- /** ICE candidates and state transitions recorded for this call, with a one-line summary. */
377
+ /**
378
+ * ICE candidates, state transitions, packet counts in both directions,
379
+ * room frame counts and restarts recorded for this call, with a one-line summary.
380
+ */
202
381
  diagnostics() {
203
382
  const snapshot = {
204
383
  local: { ...this.#iceLocal },
205
384
  remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
206
385
  transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
207
- connected: this.#reportedConnected,
386
+ connected: this.#peerConnected,
387
+ inbound: this.#inboundDiagnostics(),
388
+ outbound: this.#outboundDiagnostics(),
389
+ room: {
390
+ roomStates: this.#roomStates,
391
+ offers: this.#roomOffers,
392
+ endedReason: this.#endedReason,
393
+ errors: [...this.#roomErrors],
394
+ },
395
+ restarts: this.#restarts,
396
+ };
397
+ return { ...snapshot, summary: summarize(snapshot) };
398
+ }
399
+ #sinceConnect(epochMs) {
400
+ return epochMs === undefined ? undefined : epochMs - this.#connectStartedAt;
401
+ }
402
+ /** The live sink's counts added to those of sinks retired by restarts. */
403
+ #sinkStats() {
404
+ const current = this.#remoteSink?.stats?.();
405
+ const retired = this.#retiredSinkStats;
406
+ if (!retired)
407
+ return current;
408
+ if (!current)
409
+ return { ...retired, recentRtpPackets: 0 };
410
+ return {
411
+ rtpPackets: retired.rtpPackets + current.rtpPackets,
412
+ decodeFailures: retired.decodeFailures + current.decodeFailures,
413
+ firstRtpAt: retired.firstRtpAt ?? current.firstRtpAt,
414
+ lastRtpAt: current.lastRtpAt ?? retired.lastRtpAt,
415
+ recentRtpPackets: current.recentRtpPackets,
416
+ };
417
+ }
418
+ #inboundDiagnostics() {
419
+ const stats = this.#finalSinkStats ?? this.#sinkStats();
420
+ return {
421
+ rtpPackets: stats?.rtpPackets ?? 0,
422
+ decodeFailures: stats?.decodeFailures ?? 0,
423
+ frames: this.#inboundFrames,
424
+ firstPacketAtMs: this.#sinceConnect(stats?.firstRtpAt),
425
+ lastPacketAtMs: this.#sinceConnect(stats?.lastRtpAt),
426
+ recentRtpPackets: stats?.recentRtpPackets ?? 0,
208
427
  };
209
- return { ...snapshot, summary: summarizeIce(snapshot) };
428
+ }
429
+ #outboundDiagnostics() {
430
+ const stats = this.#audioSource?.stats?.() ?? this.#finalSourceStats;
431
+ return {
432
+ frames: this.#outboundFrames,
433
+ opusPackets: stats?.opusPackets ?? 0,
434
+ rtpPackets: stats?.rtpPackets ?? 0,
435
+ silencePackets: stats?.silencePackets ?? 0,
436
+ firstPacketAtMs: this.#sinceConnect(stats?.firstRtpAt),
437
+ lastPacketAtMs: this.#sinceConnect(stats?.lastRtpAt),
438
+ recentRtpPackets: stats?.recentRtpPackets ?? 0,
439
+ queued: stats?.queued ?? 0,
440
+ pacerAlive: stats?.pacerAlive,
441
+ };
442
+ }
443
+ /**
444
+ * Outbound stall guard: audio is queued for the pacer but no RTP packet has
445
+ * left for 2 s while connected. Warns once with the summary; restarts nothing.
446
+ */
447
+ #checkStall() {
448
+ if (this.#stallWarned || !this.#reportedConnected)
449
+ return;
450
+ const stats = this.#audioSource?.stats?.();
451
+ if (!stats || stats.queued === 0) {
452
+ this.#stallSince = undefined;
453
+ return;
454
+ }
455
+ const now = Date.now();
456
+ const idleSince = stats.lastRtpAt ?? (this.#stallSince ??= now);
457
+ if (now - idleSince < STALL_AFTER_MS)
458
+ return;
459
+ this.#stallWarned = true;
460
+ this.#stopStallGuard();
461
+ this.#onWarning(`Relay outbound audio stalled (${this.diagnostics().summary})`);
462
+ }
463
+ #startStallGuard() {
464
+ if (this.#stallTimer)
465
+ return;
466
+ this.#stallTimer = setInterval(() => this.#checkStall(), STALL_CHECK_MS);
467
+ this.#stallTimer.unref?.();
468
+ }
469
+ #stopStallGuard() {
470
+ if (!this.#stallTimer)
471
+ return;
472
+ clearInterval(this.#stallTimer);
473
+ this.#stallTimer = undefined;
210
474
  }
211
475
  /**
212
476
  * Feed interleaved PCM16 audio into Relay. Frames are split into 10 ms WebRTC
213
- * source slices and paced in real time, so adapters may push larger chunks.
477
+ * source slices and handed to the engine at once; the engine's 20 ms pump
478
+ * paces the wire, so adapters may push faster than real time (LiveKit's
479
+ * `AudioSource.captureFrame` shape). Resolves once the slices are queued;
480
+ * `waitForPlayout()` tells when they have left.
214
481
  */
215
482
  writeAudio(frame) {
216
483
  if (this.#closed)
@@ -231,14 +498,72 @@ export class RelayCallTransport {
231
498
  sampleRate: frame.sampleRate,
232
499
  channelCount: frame.channelCount,
233
500
  };
234
- const generation = this.#audioGeneration;
235
- const queued = this.#outputTail.then(() => this.#writeAudio(captured, generation));
236
- this.#outputTail = queued.catch(() => undefined);
237
- return queued;
501
+ this.#writeAudio(captured, this.#audioGeneration);
502
+ return Promise.resolve();
238
503
  }
239
- /** Drop queued outgoing PCM. At most one already-submitted 10 ms slice remains. */
504
+ /** Milliseconds of audio accepted by `writeAudio` but not yet written to RTP. */
505
+ queuedAudioMs() {
506
+ return this.#audioSource?.queuedMs?.() ?? 0;
507
+ }
508
+ /**
509
+ * Resolves when every accepted slice has been written to RTP and the engine's
510
+ * pump is idle; immediately when nothing is queued; early on `clearAudio()`
511
+ * or `close()` (the caller reads `queuedAudioMs()` to learn what was dropped).
512
+ */
513
+ waitForPlayout() {
514
+ const source = this.#audioSource;
515
+ if (!source || this.#closed || this.queuedAudioMs() === 0)
516
+ return Promise.resolve();
517
+ return new Promise((resolve) => {
518
+ const release = () => {
519
+ this.#playoutWaiters.delete(release);
520
+ resolve();
521
+ };
522
+ this.#playoutWaiters.add(release);
523
+ const drained = source.waitForDrain?.() ?? Promise.resolve();
524
+ drained.then(release, release);
525
+ });
526
+ }
527
+ /** Drop outgoing PCM that has not reached RTP and release `waitForPlayout()` callers. */
240
528
  clearAudio() {
241
529
  this.#audioGeneration += 1;
530
+ this.#audioSource?.clear?.();
531
+ this.#releasePlayoutWaiters();
532
+ }
533
+ /**
534
+ * Resolves once `peerAudio` has fired (at once if it already has). Rejects
535
+ * after `timeoutMs`, or when the Call ends or the transport closes first.
536
+ */
537
+ waitForPeerAudio(timeoutMs) {
538
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
539
+ return Promise.reject(new Error("waitForPeerAudio timeoutMs must be greater than zero."));
540
+ }
541
+ if (this.#peerAudioReady)
542
+ return Promise.resolve();
543
+ if (this.#closed || this.#ended) {
544
+ return Promise.reject(new RelayCallTransportError("Relay Call ended before the person's audio arrived."));
545
+ }
546
+ return new Promise((resolve, reject) => {
547
+ const waiter = {
548
+ resolve: () => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); resolve(); },
549
+ reject: (error) => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); reject(error); },
550
+ };
551
+ const timer = setTimeout(() => waiter.reject(new RelayCallTransportError(`Timed out waiting for the person's audio (${this.diagnostics().summary})`)), timeoutMs);
552
+ timer.unref?.();
553
+ this.#peerAudioWaiters.add(waiter);
554
+ });
555
+ }
556
+ #checkPeerAudio() {
557
+ if (this.#peerAudioReady || !this.#peerAudioArrived || !this.#personConnected)
558
+ return;
559
+ this.#peerAudioReady = true;
560
+ for (const waiter of [...this.#peerAudioWaiters])
561
+ waiter.resolve();
562
+ this.#emit("peerAudio");
563
+ }
564
+ #rejectPeerAudio(error) {
565
+ for (const waiter of [...this.#peerAudioWaiters])
566
+ waiter.reject(error);
242
567
  }
243
568
  setMuted(muted) {
244
569
  this.#room.userUpdate({ muted });
@@ -251,16 +576,54 @@ export class RelayCallTransport {
251
576
  return;
252
577
  this.#closed = true;
253
578
  this.#rejectReady(new RelayCallTransportError("Relay Call transport closed before media connected."));
254
- this.clearAudio();
579
+ this.#rejectPeerAudio(new RelayCallTransportError("Relay Call transport closed before the person's audio arrived."));
580
+ this.#audioGeneration += 1;
255
581
  this.#shutdownMedia();
582
+ this.#releasePlayoutWaiters();
256
583
  this.#room.close();
257
584
  }
258
- async #publishLocalAudio() {
259
- const peer = this.#requirePeer();
585
+ /**
586
+ * Build a peer connection around the one local track and publish it. The
587
+ * first peer sends a plain `offer`; every later one is a restart onto a new
588
+ * SFU session (`restart: true`) with ICE servers fetched again.
589
+ */
590
+ async #startPeer() {
591
+ const factory = this.#factory;
592
+ const track = this.#localTrack;
593
+ if (!factory || !track)
594
+ throw new Error("Relay Call transport is not connected.");
595
+ const restarts = this.#restarts;
596
+ const generation = ++this.#peerGeneration;
597
+ const iceServers = typeof this.#iceServers === "function"
598
+ ? copyIceServers(await this.#iceServers({ restarts }))
599
+ : copyIceServers(this.#iceServers);
600
+ if (this.#closed || this.#ended || generation !== this.#peerGeneration)
601
+ return;
602
+ const peer = factory.createPeerConnection({ iceServers, iceTransportPolicy: this.#iceTransportPolicy });
603
+ this.#peer = peer;
604
+ this.#peerConnected = false;
605
+ this.#initialAnswerSdp = undefined;
606
+ this.#publishTransceiver = peer.addTransceiver(track, { direction: "sendonly" });
607
+ this.#observeIce(peer);
608
+ peer.onconnectionstatechange = () => {
609
+ if (this.#peer !== peer)
610
+ return;
611
+ this.#recordTransition("connection", peer.connectionState);
612
+ this.#connectionStateChanged(peer);
613
+ };
614
+ peer.ontrack = (event) => {
615
+ if (this.#peer === peer)
616
+ this.#remoteTrack(event.track);
617
+ };
618
+ await this.#publishLocalAudio(peer, restarts > 0);
619
+ }
620
+ async #publishLocalAudio(peer, restart) {
260
621
  const offer = await peer.createOffer();
261
622
  await peer.setLocalDescription(offer);
262
623
  await this.#waitForIceGathering(peer);
263
- const description = this.#localDescription("offer");
624
+ if (this.#peer !== peer)
625
+ return;
626
+ const description = this.#localDescription(peer, "offer");
264
627
  const mid = this.#publishTransceiver?.mid;
265
628
  if (!mid)
266
629
  throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
@@ -268,6 +631,7 @@ export class RelayCallTransport {
268
631
  type: "offer",
269
632
  session_description: description,
270
633
  tracks: [{ mid, name: "audio" }],
634
+ ...(restart ? { restart: true } : {}),
271
635
  };
272
636
  this.#room.send(this.#publishFrame);
273
637
  }
@@ -279,10 +643,23 @@ export class RelayCallTransport {
279
643
  this.#queueNegotiation(() => this.#serverAnswer(frame));
280
644
  });
281
645
  this.#room.on("offer", (frame) => {
646
+ this.#roomOffers += 1;
282
647
  this.#queueNegotiation(() => this.#serverOffer(frame));
283
648
  });
284
649
  this.#room.on("roomState", (frame) => {
650
+ this.#roomStates += 1;
651
+ this.#callStatus = frame.call?.status;
652
+ // A Call has exactly one agent; the transport is that agent, so the
653
+ // person is the other participant.
654
+ const person = frame.participants?.find((participant) => participant.kind === "user");
655
+ const video = person?.video === true;
656
+ const videoChanged = video !== this.#remoteVideo;
657
+ this.#remoteVideo = video;
658
+ this.#personConnected = person?.connected === true;
285
659
  this.#emit("roomState", frame);
660
+ if (videoChanged)
661
+ this.#emit("remoteVideo", video);
662
+ this.#checkPeerAudio();
286
663
  });
287
664
  this.#room.on("error", (error) => {
288
665
  if (error instanceof Error) {
@@ -293,7 +670,10 @@ export class RelayCallTransport {
293
670
  this.#serverError(error);
294
671
  });
295
672
  this.#room.on("ended", (frame) => {
673
+ this.#ended = true;
674
+ this.#endedReason = frame.reason;
296
675
  this.#rejectReady(new RelayCallTransportError(`Relay Call ended before media connected (${frame.reason}).`));
676
+ this.#rejectPeerAudio(new RelayCallTransportError(`Relay Call ended before the person's audio arrived (${frame.reason}).`));
297
677
  this.clearAudio();
298
678
  this.#shutdownMedia();
299
679
  this.#emit("ended", frame);
@@ -314,39 +694,71 @@ export class RelayCallTransport {
314
694
  });
315
695
  }
316
696
  async #serverAnswer(frame) {
317
- const peer = this.#requirePeer();
697
+ // No peer: the answer is for a session a restart already replaced.
698
+ const peer = this.#peer;
699
+ if (!peer)
700
+ return;
318
701
  const sdp = frame.session_description.sdp;
319
702
  // Reconnecting the signaling socket replays the exact initial offer. Relay
320
703
  // returns its cached answer; applying that answer again in stable state is
321
704
  // invalid WebRTC signaling, so recognize and ignore the replay.
322
705
  if (peer.signalingState === "stable" && this.#initialAnswerSdp === sdp)
323
706
  return;
324
- if (this.#initialAnswerSdp === undefined)
707
+ const initial = this.#initialAnswerSdp === undefined;
708
+ if (initial)
325
709
  this.#recordRemoteCandidates(sdp);
326
710
  await peer.setRemoteDescription(frame.session_description);
711
+ if (this.#peer !== peer)
712
+ return;
327
713
  this.#initialAnswerSdp ??= sdp;
714
+ if (initial && !this.#peerConnected)
715
+ this.#armConnectTimer(peer);
328
716
  }
329
717
  async #serverOffer(frame) {
330
- const peer = this.#requirePeer();
718
+ const peer = this.#peer;
719
+ // A pull offer that arrives while this participant's restart offer is
720
+ // unanswered was sent for the replaced session: the room clears those
721
+ // pulls on restart and pulls again after the new session connects.
722
+ if (!peer || peer.signalingState === "have-local-offer")
723
+ return;
724
+ // `video` m-lines are answered receive-only by the engine and never decoded
725
+ // (`#remoteTrack` takes audio only).
331
726
  await peer.setRemoteDescription(frame.session_description);
332
727
  const answer = await peer.createAnswer();
333
728
  await peer.setLocalDescription(answer);
334
729
  await this.#waitForIceGathering(peer);
335
- this.#room.send({ type: "answer", session_description: this.#localDescription("answer") });
730
+ if (this.#peer !== peer)
731
+ return;
732
+ this.#room.send({ type: "answer", session_description: this.#localDescription(peer, "answer") });
336
733
  }
337
734
  #serverError(frame) {
735
+ this.#roomErrors.push(frame.message);
338
736
  const error = new RelayCallTransportError(frame.message, frame.code);
339
737
  this.#rejectReady(error);
340
738
  this.#emit("error", error);
341
739
  }
342
- #connectionStateChanged() {
343
- const state = this.#peer?.connectionState;
344
- if (state === "connected" && !this.#reportedConnected) {
345
- this.#reportedConnected = true;
740
+ #connectionStateChanged(peer) {
741
+ const state = peer.connectionState;
742
+ if (state === "connected") {
743
+ this.#clearTimer("connect");
744
+ this.#clearTimer("disconnect");
745
+ if (this.#peerConnected)
746
+ return;
747
+ this.#peerConnected = true;
748
+ this.#failedAttempts = 0;
749
+ // The SFU will not pull a track that has carried no RTP, so the source
750
+ // sends silence from here on until it is closed.
751
+ this.#audioSource?.start?.();
346
752
  try {
753
+ // Sent for every new session: the room re-pulls a restarted participant's
754
+ // tracks once it reports `connected` (PROTOCOL.md section 2).
347
755
  this.#room.connected();
348
- this.#resolveReady();
349
- this.#emit("connected");
756
+ if (!this.#reportedConnected) {
757
+ this.#reportedConnected = true;
758
+ this.#resolveReady();
759
+ this.#startStallGuard();
760
+ this.#emit("connected");
761
+ }
350
762
  }
351
763
  catch (error) {
352
764
  const parsed = error instanceof Error ? error : new Error(String(error));
@@ -355,16 +767,119 @@ export class RelayCallTransport {
355
767
  }
356
768
  }
357
769
  else if (state === "failed") {
358
- const error = new RelayCallTransportError("Relay WebRTC connection failed.");
359
- this.#rejectReady(error);
360
- this.#emit("error", error);
770
+ this.#requestRestart("failed", peer);
771
+ }
772
+ else if (state === "disconnected") {
773
+ if (this.#disconnectTimer)
774
+ return;
775
+ this.#disconnectTimer = setTimeout(() => {
776
+ this.#disconnectTimer = undefined;
777
+ if (peer.connectionState !== "connected")
778
+ this.#requestRestart("disconnected", peer);
779
+ }, RESTART_DISCONNECTED_MS);
780
+ this.#disconnectTimer.unref?.();
781
+ }
782
+ }
783
+ #armConnectTimer(peer) {
784
+ this.#clearTimer("connect");
785
+ this.#connectTimer = setTimeout(() => {
786
+ this.#connectTimer = undefined;
787
+ if (!this.#peerConnected)
788
+ this.#requestRestart("timeout", peer);
789
+ }, this.#sessionConnectTimeoutMs);
790
+ this.#connectTimer.unref?.();
791
+ }
792
+ #clearTimer(which) {
793
+ if (which === "connect") {
794
+ clearTimeout(this.#connectTimer);
795
+ this.#connectTimer = undefined;
796
+ }
797
+ else {
798
+ clearTimeout(this.#disconnectTimer);
799
+ this.#disconnectTimer = undefined;
800
+ }
801
+ }
802
+ #callActive() {
803
+ if (this.#closed || this.#ended)
804
+ return false;
805
+ return this.#callStatus === undefined || ACTIVE_CALL_STATUSES.has(this.#callStatus);
806
+ }
807
+ /**
808
+ * Retire `peer` now, wait the backoff, then publish from a new peer on a new
809
+ * session. Unlimited while the Call is ringing or in progress; stops on
810
+ * `ended`, a terminal status, or `close()`.
811
+ */
812
+ #requestRestart(reason, peer) {
813
+ if (this.#restartPending || peer !== this.#peer || !this.#callActive())
814
+ return;
815
+ const summary = this.diagnostics().summary;
816
+ this.#restartPending = true;
817
+ this.#restarts += 1;
818
+ this.#failedAttempts += 1;
819
+ const restarts = this.#restarts;
820
+ const delayMs = restartDelayMs(this.#failedAttempts);
821
+ this.#retirePeer();
822
+ this.#queueNegotiation(async () => {
823
+ await this.#restartBackoff(delayMs);
824
+ this.#restartPending = false;
825
+ if (!this.#callActive())
826
+ return;
827
+ try {
828
+ await this.#startPeer();
829
+ }
830
+ catch (error) {
831
+ if (!this.#callActive())
832
+ return;
833
+ const parsed = error instanceof Error ? error : new Error(String(error));
834
+ this.#emit("error", new RelayCallTransportError(`Relay WebRTC restart ${restarts} failed: ${parsed.message}`, "restart_failed"));
835
+ this.#requestRestart("error", this.#peer);
836
+ return;
837
+ }
838
+ if (this.#peer)
839
+ this.#emit("restarted", { reason, summary, restarts, delayMs });
840
+ });
841
+ }
842
+ #restartBackoff(milliseconds) {
843
+ return new Promise((resolve) => {
844
+ const wake = () => {
845
+ clearTimeout(timer);
846
+ if (this.#wakeRestart === wake)
847
+ this.#wakeRestart = undefined;
848
+ resolve();
849
+ };
850
+ const timer = setTimeout(wake, milliseconds);
851
+ timer.unref?.();
852
+ this.#wakeRestart = wake;
853
+ });
854
+ }
855
+ /** Close the current peer and its sink; the audio source and local track stay for the next peer. */
856
+ #retirePeer() {
857
+ this.#clearTimer("connect");
858
+ this.#clearTimer("disconnect");
859
+ const peer = this.#peer;
860
+ this.#peer = undefined;
861
+ this.#peerConnected = false;
862
+ this.#publishTransceiver = undefined;
863
+ this.#initialAnswerSdp = undefined;
864
+ if (this.#remoteSink) {
865
+ this.#retiredSinkStats = this.#sinkStats();
866
+ this.#remoteSink.stop();
867
+ this.#remoteSink = undefined;
361
868
  }
869
+ if (!peer)
870
+ return;
871
+ peer.onconnectionstatechange = null;
872
+ peer.ontrack = null;
873
+ peer.onicecandidate = null;
874
+ peer.onicegatheringstatechange = null;
875
+ peer.oniceconnectionstatechange = null;
876
+ closePeer(peer);
362
877
  }
363
878
  #remoteTrack(track) {
364
879
  if (track.kind !== "audio" || !this.#factory)
365
880
  return;
366
881
  this.#remoteSink?.stop();
367
- const sink = this.#factory.createAudioSink(track);
882
+ const sink = this.#factory.createAudioSink(track, this.#inboundAudio);
368
883
  this.#remoteSink = sink;
369
884
  sink.ondata = (data) => {
370
885
  if (this.#closed || this.#remoteSink !== sink)
@@ -374,18 +889,23 @@ export class RelayCallTransport {
374
889
  this.#emit("error", new RelayCallTransportError(`Relay WebRTC delivered unsupported ${data.bitsPerSample}-bit audio.`));
375
890
  return;
376
891
  }
892
+ this.#inboundFrames += 1;
377
893
  this.#emit("audio", {
378
894
  samples: cloneSamples(data.samples),
379
895
  sampleRate: data.sampleRate,
380
896
  channelCount,
381
897
  });
898
+ if (!this.#peerAudioArrived) {
899
+ this.#peerAudioArrived = true;
900
+ this.#checkPeerAudio();
901
+ }
382
902
  };
383
903
  }
384
- async #writeAudio(frame, generation) {
904
+ #writeAudio(frame, generation) {
385
905
  const source = this.#audioSource;
386
906
  if (!source || generation !== this.#audioGeneration)
387
907
  return;
388
- const samplesPerChannel = frame.sampleRate / 100;
908
+ const samplesPerChannel = (frame.sampleRate * AUDIO_SLICE_MS) / 1000;
389
909
  const sliceSamples = samplesPerChannel * frame.channelCount;
390
910
  for (let offset = 0; offset < frame.samples.length; offset += sliceSamples) {
391
911
  if (this.#closed || generation !== this.#audioGeneration)
@@ -400,9 +920,13 @@ export class RelayCallTransport {
400
920
  channelCount: frame.channelCount,
401
921
  numberOfFrames: samplesPerChannel,
402
922
  });
403
- await delay(AUDIO_SLICE_MS);
923
+ this.#outboundFrames += 1;
404
924
  }
405
925
  }
926
+ #releasePlayoutWaiters() {
927
+ for (const release of [...this.#playoutWaiters])
928
+ release();
929
+ }
406
930
  async #waitForIceGathering(peer) {
407
931
  if (peer.iceGatheringState === "complete")
408
932
  return;
@@ -426,23 +950,6 @@ export class RelayCallTransport {
426
950
  changed();
427
951
  });
428
952
  }
429
- async #waitForConnection() {
430
- if (this.#reportedConnected)
431
- return;
432
- await new Promise((resolve, reject) => {
433
- const timeout = setTimeout(() => {
434
- reject(new RelayCallTransportError(`Timed out connecting Relay WebRTC media (${this.diagnostics().summary})`));
435
- }, this.#connectionTimeoutMs);
436
- timeout.unref?.();
437
- this.#ready.then(() => {
438
- clearTimeout(timeout);
439
- resolve();
440
- }, (error) => {
441
- clearTimeout(timeout);
442
- reject(error);
443
- });
444
- });
445
- }
446
953
  /**
447
954
  * werift emits these as W3C-style handler calls (peerConnection.js:314-341:
448
955
  * `onicegatheringstatechange`, `oniceconnectionstatechange`,
@@ -489,24 +996,26 @@ export class RelayCallTransport {
489
996
  this.#readySettled = true;
490
997
  this.#readyReject?.(error);
491
998
  }
492
- #localDescription(type) {
493
- const local = this.#requirePeer().localDescription;
999
+ #localDescription(peer, type) {
1000
+ const local = peer.localDescription;
494
1001
  if (!local || local.type !== type || !local.sdp) {
495
1002
  throw new RelayCallTransportError(`Relay WebRTC did not produce a complete ${type} SDP.`);
496
1003
  }
497
1004
  return { type, sdp: local.sdp };
498
1005
  }
499
- #requirePeer() {
500
- if (!this.#peer)
501
- throw new Error("Relay Call transport is not connected.");
502
- return this.#peer;
503
- }
504
1006
  #shutdownMedia() {
1007
+ this.#stopStallGuard();
1008
+ this.#clearTimer("connect");
1009
+ this.#clearTimer("disconnect");
1010
+ this.#wakeRestart?.();
1011
+ this.#finalSinkStats ??= this.#sinkStats();
1012
+ this.#finalSourceStats ??= this.#audioSource?.stats?.();
505
1013
  this.#remoteSink?.stop();
506
1014
  this.#remoteSink = undefined;
507
1015
  this.#localTrack?.stop();
508
1016
  this.#localTrack = undefined;
509
- this.#peer?.close();
1017
+ if (this.#peer)
1018
+ closePeer(this.#peer);
510
1019
  this.#peer = undefined;
511
1020
  this.#audioSource = undefined;
512
1021
  }