@relaymessenger/livekit 0.1.0-staging.0 → 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) {
@@ -10,6 +40,7 @@ export class RelayCallTransportError extends Error {
10
40
  this.code = code;
11
41
  }
12
42
  }
43
+ const DEFAULT_PEER_CONFIG = { iceServers: [], iceTransportPolicy: "all" };
13
44
  const loadWebRTCFactory = async (engine) => {
14
45
  if (engine === "werift") {
15
46
  const { createWeriftWebRTCFactory } = await import("./engine-werift.js");
@@ -17,19 +48,133 @@ const loadWebRTCFactory = async (engine) => {
17
48
  }
18
49
  const wrtc = await import("@roamhq/wrtc");
19
50
  return {
20
- createPeerConnection: () => new wrtc.RTCPeerConnection({
51
+ createPeerConnection: (config = DEFAULT_PEER_CONFIG) => new wrtc.RTCPeerConnection({
21
52
  bundlePolicy: "max-bundle",
22
- iceServers: [],
53
+ iceServers: config.iceServers,
54
+ iceTransportPolicy: config.iceTransportPolicy,
23
55
  }),
24
- createAudioSource: () => new wrtc.nonstandard.RTCAudioSource(),
56
+ createAudioSource: () => new WrtcAudioSource(new wrtc.nonstandard.RTCAudioSource()),
25
57
  createAudioSink: (track) => new wrtc.nonstandard.RTCAudioSink(track),
26
58
  };
27
59
  };
60
+ /** `candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> ...` (RFC 5245 §15.1). */
61
+ const parseCandidate = (line) => {
62
+ const match = /candidate:\S+\s+\d+\s+(\S+)\s+\d+\s+\S+\s+(\d+)\s+typ\s+(\S+)/i.exec(line);
63
+ if (!match)
64
+ return undefined;
65
+ return { transport: match[1].toLowerCase(), port: Number(match[2]), type: match[3].toLowerCase() };
66
+ };
67
+ const seconds = (milliseconds) => `${(milliseconds / 1000).toFixed(1)}s`;
68
+ const summarizeIce = (diagnostics) => {
69
+ const { local, remote, transitions, connected } = diagnostics;
70
+ const localPart = `local: host ${local.host}, srflx ${local.srflx}, relay ${local.relay}`
71
+ + (local.other ? `, other ${local.other}` : "");
72
+ const remotePart = remote.length
73
+ ? `remote: ${remote.map((candidate) => `${candidate.transport} ${candidate.port}`).join(", ")}`
74
+ : "remote: none";
75
+ const states = [];
76
+ let lastGathering = "new";
77
+ for (const transition of transitions) {
78
+ if (transition.kind === "gathering") {
79
+ states.push(`${lastGathering}\u2192${transition.state} ${seconds(transition.atMs)}`);
80
+ lastGathering = transition.state;
81
+ }
82
+ else if (transition.kind === "ice") {
83
+ states.push(`ice ${transition.state} ${seconds(transition.atMs)}`);
84
+ }
85
+ else {
86
+ states.push(`${transition.state} ${seconds(transition.atMs)}`);
87
+ }
88
+ }
89
+ if (!connected)
90
+ states.push("no connected");
91
+ return `${localPart}; ${remotePart}; states: ${states.join(", ")}`;
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
+ }));
28
116
  const cloneSamples = (samples) => {
29
117
  const copy = new Int16Array(samples.length);
30
118
  copy.set(samples);
31
119
  return copy;
32
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
+ };
33
178
  const delay = (milliseconds) => new Promise((resolve) => {
34
179
  const timer = setTimeout(resolve, milliseconds);
35
180
  timer.unref?.();
@@ -38,17 +183,46 @@ const delay = (milliseconds) => new Promise((resolve) => {
38
183
  * Provider-neutral Node WebRTC bridge for Relay Call rooms.
39
184
  *
40
185
  * The class owns Relay media negotiation internally. Higher-level adapters only
41
- * 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.
42
190
  */
43
191
  export class RelayCallTransport {
44
192
  #room;
45
193
  #providedFactory;
46
194
  #engine;
47
195
  #iceGatheringTimeoutMs;
48
- #connectionTimeoutMs;
196
+ #sessionConnectTimeoutMs;
197
+ #iceServers;
198
+ #iceTransportPolicy;
199
+ #inboundAudio;
200
+ #onWarning;
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;
216
+ #iceLocal = { host: 0, srflx: 0, relay: 0, other: 0 };
217
+ #iceRemote = [];
218
+ #iceTransitions = [];
49
219
  #listeners = new Map();
50
220
  #factory;
51
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;
52
226
  #audioSource;
53
227
  #localTrack;
54
228
  #remoteSink;
@@ -56,8 +230,23 @@ export class RelayCallTransport {
56
230
  #publishFrame;
57
231
  #initialAnswerSdp;
58
232
  #negotiationTail = Promise.resolve();
59
- #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;
60
247
  #audioGeneration = 0;
248
+ /** `waitForPlayout()` callers released early by `clearAudio()` or `close()`. */
249
+ #playoutWaiters = new Set();
61
250
  #reportedConnected = false;
62
251
  #readySettled = false;
63
252
  #readyResolve;
@@ -71,16 +260,42 @@ export class RelayCallTransport {
71
260
  this.#room = options.roomClient ?? options.relay.calls.room(options.callId, options.room);
72
261
  this.#providedFactory = options.webRTC;
73
262
  this.#engine = options.engine ?? "werift";
263
+ this.#onWarning = options.onWarning ?? (() => undefined);
74
264
  if (this.#engine !== "werift" && this.#engine !== "wrtc") {
75
265
  throw new Error('engine must be "werift" or "wrtc".');
76
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 };
77
282
  this.#iceGatheringTimeoutMs = options.iceGatheringTimeoutMs ?? DEFAULT_ICE_GATHERING_TIMEOUT_MS;
78
- this.#connectionTimeoutMs = options.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS;
283
+ this.#sessionConnectTimeoutMs = options.sessionConnectTimeoutMs
284
+ ?? options.mediaConnectTimeoutMs
285
+ ?? options.connectionTimeoutMs
286
+ ?? RESTART_CONNECT_TIMEOUT_MS;
287
+ const policy = options.iceTransportPolicy ?? "all";
288
+ if (policy !== "all" && policy !== "relay") {
289
+ throw new Error('iceTransportPolicy must be "all" or "relay".');
290
+ }
291
+ this.#iceTransportPolicy = policy;
292
+ const iceServers = options.iceServers ?? [];
293
+ this.#iceServers = typeof iceServers === "function" ? iceServers : copyIceServers(iceServers);
79
294
  if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
80
295
  throw new Error("iceGatheringTimeoutMs must be greater than zero.");
81
296
  }
82
- if (!Number.isFinite(this.#connectionTimeoutMs) || this.#connectionTimeoutMs <= 0) {
83
- throw new Error("connectionTimeoutMs must be greater than zero.");
297
+ if (!Number.isFinite(this.#sessionConnectTimeoutMs) || this.#sessionConnectTimeoutMs <= 0) {
298
+ throw new Error("sessionConnectTimeoutMs must be greater than zero.");
84
299
  }
85
300
  this.#ready = new Promise((resolve, reject) => {
86
301
  this.#readyResolve = resolve;
@@ -101,13 +316,37 @@ export class RelayCallTransport {
101
316
  this.#listeners.get(event)?.delete(listener);
102
317
  return this;
103
318
  }
104
- 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 = {}) {
105
327
  if (this.#closed)
106
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() {
107
346
  this.#attachRoomHandlers();
108
347
  await this.#room.connect();
109
- if (this.#peer) {
110
- await this.#waitForConnection();
348
+ if (this.#factory) {
349
+ await this.#ready;
111
350
  return;
112
351
  }
113
352
  this.#factory = this.#providedFactory ?? await loadWebRTCFactory(this.#engine);
@@ -116,14 +355,10 @@ export class RelayCallTransport {
116
355
  if (this.#localTrack.kind !== "audio") {
117
356
  throw new RelayCallTransportError("The WebRTC binding created a non-audio Relay track.");
118
357
  }
119
- const peer = this.#factory.createPeerConnection();
120
- this.#peer = peer;
121
- this.#publishTransceiver = peer.addTransceiver(this.#localTrack, { direction: "sendonly" });
122
- peer.onconnectionstatechange = () => this.#connectionStateChanged();
123
- peer.ontrack = (event) => this.#remoteTrack(event.track);
358
+ this.#connectStartedAt = Date.now();
124
359
  try {
125
- await this.#publishLocalAudio();
126
- await this.#waitForConnection();
360
+ await this.#startPeer();
361
+ await this.#ready;
127
362
  }
128
363
  catch (error) {
129
364
  this.close();
@@ -139,9 +374,110 @@ export class RelayCallTransport {
139
374
  await this.#room.reconnect();
140
375
  this.#room.send(this.#publishFrame);
141
376
  }
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
+ */
381
+ diagnostics() {
382
+ const snapshot = {
383
+ local: { ...this.#iceLocal },
384
+ remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
385
+ transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
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,
427
+ };
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;
474
+ }
142
475
  /**
143
476
  * Feed interleaved PCM16 audio into Relay. Frames are split into 10 ms WebRTC
144
- * 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.
145
481
  */
146
482
  writeAudio(frame) {
147
483
  if (this.#closed)
@@ -162,14 +498,72 @@ export class RelayCallTransport {
162
498
  sampleRate: frame.sampleRate,
163
499
  channelCount: frame.channelCount,
164
500
  };
165
- const generation = this.#audioGeneration;
166
- const queued = this.#outputTail.then(() => this.#writeAudio(captured, generation));
167
- this.#outputTail = queued.catch(() => undefined);
168
- return queued;
501
+ this.#writeAudio(captured, this.#audioGeneration);
502
+ return Promise.resolve();
503
+ }
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
+ });
169
526
  }
170
- /** Drop queued outgoing PCM. At most one already-submitted 10 ms slice remains. */
527
+ /** Drop outgoing PCM that has not reached RTP and release `waitForPlayout()` callers. */
171
528
  clearAudio() {
172
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);
173
567
  }
174
568
  setMuted(muted) {
175
569
  this.#room.userUpdate({ muted });
@@ -182,16 +576,54 @@ export class RelayCallTransport {
182
576
  return;
183
577
  this.#closed = true;
184
578
  this.#rejectReady(new RelayCallTransportError("Relay Call transport closed before media connected."));
185
- this.clearAudio();
579
+ this.#rejectPeerAudio(new RelayCallTransportError("Relay Call transport closed before the person's audio arrived."));
580
+ this.#audioGeneration += 1;
186
581
  this.#shutdownMedia();
582
+ this.#releasePlayoutWaiters();
187
583
  this.#room.close();
188
584
  }
189
- async #publishLocalAudio() {
190
- 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) {
191
621
  const offer = await peer.createOffer();
192
622
  await peer.setLocalDescription(offer);
193
623
  await this.#waitForIceGathering(peer);
194
- const description = this.#localDescription("offer");
624
+ if (this.#peer !== peer)
625
+ return;
626
+ const description = this.#localDescription(peer, "offer");
195
627
  const mid = this.#publishTransceiver?.mid;
196
628
  if (!mid)
197
629
  throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
@@ -199,6 +631,7 @@ export class RelayCallTransport {
199
631
  type: "offer",
200
632
  session_description: description,
201
633
  tracks: [{ mid, name: "audio" }],
634
+ ...(restart ? { restart: true } : {}),
202
635
  };
203
636
  this.#room.send(this.#publishFrame);
204
637
  }
@@ -210,10 +643,23 @@ export class RelayCallTransport {
210
643
  this.#queueNegotiation(() => this.#serverAnswer(frame));
211
644
  });
212
645
  this.#room.on("offer", (frame) => {
646
+ this.#roomOffers += 1;
213
647
  this.#queueNegotiation(() => this.#serverOffer(frame));
214
648
  });
215
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;
216
659
  this.#emit("roomState", frame);
660
+ if (videoChanged)
661
+ this.#emit("remoteVideo", video);
662
+ this.#checkPeerAudio();
217
663
  });
218
664
  this.#room.on("error", (error) => {
219
665
  if (error instanceof Error) {
@@ -224,7 +670,10 @@ export class RelayCallTransport {
224
670
  this.#serverError(error);
225
671
  });
226
672
  this.#room.on("ended", (frame) => {
673
+ this.#ended = true;
674
+ this.#endedReason = frame.reason;
227
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}).`));
228
677
  this.clearAudio();
229
678
  this.#shutdownMedia();
230
679
  this.#emit("ended", frame);
@@ -245,37 +694,71 @@ export class RelayCallTransport {
245
694
  });
246
695
  }
247
696
  async #serverAnswer(frame) {
248
- 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;
249
701
  const sdp = frame.session_description.sdp;
250
702
  // Reconnecting the signaling socket replays the exact initial offer. Relay
251
703
  // returns its cached answer; applying that answer again in stable state is
252
704
  // invalid WebRTC signaling, so recognize and ignore the replay.
253
705
  if (peer.signalingState === "stable" && this.#initialAnswerSdp === sdp)
254
706
  return;
707
+ const initial = this.#initialAnswerSdp === undefined;
708
+ if (initial)
709
+ this.#recordRemoteCandidates(sdp);
255
710
  await peer.setRemoteDescription(frame.session_description);
711
+ if (this.#peer !== peer)
712
+ return;
256
713
  this.#initialAnswerSdp ??= sdp;
714
+ if (initial && !this.#peerConnected)
715
+ this.#armConnectTimer(peer);
257
716
  }
258
717
  async #serverOffer(frame) {
259
- 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).
260
726
  await peer.setRemoteDescription(frame.session_description);
261
727
  const answer = await peer.createAnswer();
262
728
  await peer.setLocalDescription(answer);
263
729
  await this.#waitForIceGathering(peer);
264
- 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") });
265
733
  }
266
734
  #serverError(frame) {
735
+ this.#roomErrors.push(frame.message);
267
736
  const error = new RelayCallTransportError(frame.message, frame.code);
268
737
  this.#rejectReady(error);
269
738
  this.#emit("error", error);
270
739
  }
271
- #connectionStateChanged() {
272
- const state = this.#peer?.connectionState;
273
- if (state === "connected" && !this.#reportedConnected) {
274
- 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?.();
275
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).
276
755
  this.#room.connected();
277
- this.#resolveReady();
278
- this.#emit("connected");
756
+ if (!this.#reportedConnected) {
757
+ this.#reportedConnected = true;
758
+ this.#resolveReady();
759
+ this.#startStallGuard();
760
+ this.#emit("connected");
761
+ }
279
762
  }
280
763
  catch (error) {
281
764
  const parsed = error instanceof Error ? error : new Error(String(error));
@@ -284,16 +767,119 @@ export class RelayCallTransport {
284
767
  }
285
768
  }
286
769
  else if (state === "failed") {
287
- const error = new RelayCallTransportError("Relay WebRTC connection failed.");
288
- this.#rejectReady(error);
289
- 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;
290
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);
291
877
  }
292
878
  #remoteTrack(track) {
293
879
  if (track.kind !== "audio" || !this.#factory)
294
880
  return;
295
881
  this.#remoteSink?.stop();
296
- const sink = this.#factory.createAudioSink(track);
882
+ const sink = this.#factory.createAudioSink(track, this.#inboundAudio);
297
883
  this.#remoteSink = sink;
298
884
  sink.ondata = (data) => {
299
885
  if (this.#closed || this.#remoteSink !== sink)
@@ -303,18 +889,23 @@ export class RelayCallTransport {
303
889
  this.#emit("error", new RelayCallTransportError(`Relay WebRTC delivered unsupported ${data.bitsPerSample}-bit audio.`));
304
890
  return;
305
891
  }
892
+ this.#inboundFrames += 1;
306
893
  this.#emit("audio", {
307
894
  samples: cloneSamples(data.samples),
308
895
  sampleRate: data.sampleRate,
309
896
  channelCount,
310
897
  });
898
+ if (!this.#peerAudioArrived) {
899
+ this.#peerAudioArrived = true;
900
+ this.#checkPeerAudio();
901
+ }
311
902
  };
312
903
  }
313
- async #writeAudio(frame, generation) {
904
+ #writeAudio(frame, generation) {
314
905
  const source = this.#audioSource;
315
906
  if (!source || generation !== this.#audioGeneration)
316
907
  return;
317
- const samplesPerChannel = frame.sampleRate / 100;
908
+ const samplesPerChannel = (frame.sampleRate * AUDIO_SLICE_MS) / 1000;
318
909
  const sliceSamples = samplesPerChannel * frame.channelCount;
319
910
  for (let offset = 0; offset < frame.samples.length; offset += sliceSamples) {
320
911
  if (this.#closed || generation !== this.#audioGeneration)
@@ -329,9 +920,13 @@ export class RelayCallTransport {
329
920
  channelCount: frame.channelCount,
330
921
  numberOfFrames: samplesPerChannel,
331
922
  });
332
- await delay(AUDIO_SLICE_MS);
923
+ this.#outboundFrames += 1;
333
924
  }
334
925
  }
926
+ #releasePlayoutWaiters() {
927
+ for (const release of [...this.#playoutWaiters])
928
+ release();
929
+ }
335
930
  async #waitForIceGathering(peer) {
336
931
  if (peer.iceGatheringState === "complete")
337
932
  return;
@@ -355,22 +950,39 @@ export class RelayCallTransport {
355
950
  changed();
356
951
  });
357
952
  }
358
- async #waitForConnection() {
359
- if (this.#reportedConnected)
360
- return;
361
- await new Promise((resolve, reject) => {
362
- const timeout = setTimeout(() => {
363
- reject(new RelayCallTransportError("Timed out connecting Relay WebRTC media."));
364
- }, this.#connectionTimeoutMs);
365
- timeout.unref?.();
366
- this.#ready.then(() => {
367
- clearTimeout(timeout);
368
- resolve();
369
- }, (error) => {
370
- clearTimeout(timeout);
371
- reject(error);
372
- });
373
- });
953
+ /**
954
+ * werift emits these as W3C-style handler calls (peerConnection.js:314-341:
955
+ * `onicegatheringstatechange`, `oniceconnectionstatechange`,
956
+ * `onconnectionstatechange`, `onicecandidate` with `{ candidate }`).
957
+ */
958
+ #observeIce(peer) {
959
+ peer.onicecandidate = (event) => {
960
+ const line = event?.candidate?.candidate;
961
+ if (!line)
962
+ return;
963
+ const type = parseCandidate(line)?.type;
964
+ if (type === "host" || type === "srflx" || type === "relay")
965
+ this.#iceLocal[type] += 1;
966
+ else
967
+ this.#iceLocal.other += 1;
968
+ };
969
+ peer.onicegatheringstatechange = () => this.#recordTransition("gathering", peer.iceGatheringState);
970
+ peer.oniceconnectionstatechange = () => {
971
+ if (peer.iceConnectionState !== undefined)
972
+ this.#recordTransition("ice", peer.iceConnectionState);
973
+ };
974
+ }
975
+ #recordTransition(kind, state) {
976
+ this.#iceTransitions.push({ kind, state, atMs: Date.now() - this.#connectStartedAt });
977
+ }
978
+ #recordRemoteCandidates(sdp) {
979
+ for (const line of sdp.split(/\r?\n/)) {
980
+ if (!line.startsWith("a=candidate:"))
981
+ continue;
982
+ const parsed = parseCandidate(line);
983
+ if (parsed)
984
+ this.#iceRemote.push({ transport: parsed.transport, port: parsed.port });
985
+ }
374
986
  }
375
987
  #resolveReady() {
376
988
  if (this.#readySettled)
@@ -384,24 +996,26 @@ export class RelayCallTransport {
384
996
  this.#readySettled = true;
385
997
  this.#readyReject?.(error);
386
998
  }
387
- #localDescription(type) {
388
- const local = this.#requirePeer().localDescription;
999
+ #localDescription(peer, type) {
1000
+ const local = peer.localDescription;
389
1001
  if (!local || local.type !== type || !local.sdp) {
390
1002
  throw new RelayCallTransportError(`Relay WebRTC did not produce a complete ${type} SDP.`);
391
1003
  }
392
1004
  return { type, sdp: local.sdp };
393
1005
  }
394
- #requirePeer() {
395
- if (!this.#peer)
396
- throw new Error("Relay Call transport is not connected.");
397
- return this.#peer;
398
- }
399
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?.();
400
1013
  this.#remoteSink?.stop();
401
1014
  this.#remoteSink = undefined;
402
1015
  this.#localTrack?.stop();
403
1016
  this.#localTrack = undefined;
404
- this.#peer?.close();
1017
+ if (this.#peer)
1018
+ closePeer(this.#peer);
405
1019
  this.#peer = undefined;
406
1020
  this.#audioSource = undefined;
407
1021
  }