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

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