@relaymessenger/livekit 0.1.0-staging.15 → 0.1.0-staging.17

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 DELETED
@@ -1,1303 +0,0 @@
1
- import { RemoteVideoTrack, } from "./video.js";
2
- export { LocalVideoTrack, RemoteVideoTrack, VideoBufferType, VideoCodec, VideoFrame, VideoRotation, VideoSource, VideoStream, } from "./video.js";
3
- /** Opus's native rate and Relay's wire channel count. */
4
- export const DEFAULT_INBOUND_AUDIO = Object.freeze({
5
- sampleRate: 48_000,
6
- channelCount: 2,
7
- });
8
- const INBOUND_SAMPLE_RATES = new Set([8_000, 12_000, 16_000, 24_000, 48_000]);
9
- const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 10_000;
10
- const AUDIO_SLICE_MS = 10;
11
- const STALL_CHECK_MS = 500;
12
- const STALL_AFTER_MS = 2_000;
13
- /**
14
- * Restart rule, copied from PartyTracks and Cloudflare (PROTOCOL.md section 4):
15
- * not `connected` 5 s after the SFU answer (Cloudflare's echo example waits
16
- * 5000 ms; SFU operations block up to 5 s awaiting `connected`), `failed`, or
17
- * `disconnected` for 7 s (PartyTracks.ts `timeoutSeconds = 7`). Backoff 250 ms
18
- * x1.1 per attempt, capped at 10 s (PartyTracks `retryWithBackoff`
19
- * `backoffFactor: 1.1`, rxjs-helpers.ts defaults). werift never reports
20
- * `failed` on a session whose checks go unanswered (ice.js:983-986), so the
21
- * 5 s timer is the trigger that fires in practice.
22
- */
23
- export const RESTART_CONNECT_TIMEOUT_MS = 5_000;
24
- export const RESTART_DISCONNECTED_MS = 7_000;
25
- export const RESTART_INITIAL_DELAY_MS = 250;
26
- export const RESTART_BACKOFF_FACTOR = 1.1;
27
- export const RESTART_MAX_DELAY_MS = 10_000;
28
- /**
29
- * Backoff before restart number `attempt` (1-based) counted since the last
30
- * session that connected. PartyTracks passes `resetOnSuccess: true` to rxjs
31
- * `retry` (rxjs-helpers.ts:18, :39); this transport reads "success" as a
32
- * session reaching `connected`.
33
- */
34
- export const restartDelayMs = (attempt) => Math.min(RESTART_INITIAL_DELAY_MS * RESTART_BACKOFF_FACTOR ** (attempt - 1), RESTART_MAX_DELAY_MS);
35
- const ACTIVE_CALL_STATUSES = new Set(["ringing", "in-progress"]);
36
- export class RelayCallTransportError extends Error {
37
- code;
38
- constructor(message, code) {
39
- super(message);
40
- this.name = "RelayCallTransportError";
41
- if (code !== undefined)
42
- this.code = code;
43
- }
44
- }
45
- const DEFAULT_PEER_CONFIG = { iceServers: [], iceTransportPolicy: "all" };
46
- /**
47
- * Cloudflare Realtime's echo example builds its peer with exactly this list
48
- * (cloudflare/realtime-examples echo/index.html `createPeerConnection`).
49
- */
50
- const DEFAULT_ICE_SERVERS = [{ urls: "stun:stun.cloudflare.com:3478" }];
51
- const loadWebRTCFactory = async (engine) => {
52
- if (engine === "werift") {
53
- const { createWeriftWebRTCFactory } = await import("./engine-werift.js");
54
- return createWeriftWebRTCFactory();
55
- }
56
- const wrtc = await import("@roamhq/wrtc");
57
- return {
58
- createPeerConnection: (config = DEFAULT_PEER_CONFIG) => new wrtc.RTCPeerConnection({
59
- bundlePolicy: "max-bundle",
60
- iceServers: config.iceServers,
61
- iceTransportPolicy: config.iceTransportPolicy,
62
- }),
63
- createAudioSource: () => new WrtcAudioSource(new wrtc.nonstandard.RTCAudioSource()),
64
- createAudioSink: (track) => new wrtc.nonstandard.RTCAudioSink(track),
65
- };
66
- };
67
- /** `candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> ...` (RFC 5245 §15.1). */
68
- const parseCandidate = (line) => {
69
- const match = /candidate:\S+\s+\d+\s+(\S+)\s+\d+\s+\S+\s+(\d+)\s+typ\s+(\S+)/i.exec(line);
70
- if (!match)
71
- return undefined;
72
- return { transport: match[1].toLowerCase(), port: Number(match[2]), type: match[3].toLowerCase() };
73
- };
74
- const seconds = (milliseconds) => `${(milliseconds / 1000).toFixed(1)}s`;
75
- const summarizeIce = (diagnostics) => {
76
- const { local, remote, transitions, connected } = diagnostics;
77
- const localPart = `local: host ${local.host}, srflx ${local.srflx}, relay ${local.relay}`
78
- + (local.other ? `, other ${local.other}` : "")
79
- + (diagnostics.selectedPair ? `, pair ${diagnostics.selectedPair}` : "");
80
- const remotePart = remote.length
81
- ? `remote: ${remote.map((candidate) => `${candidate.transport} ${candidate.port}`).join(", ")}`
82
- : "remote: none";
83
- const states = [];
84
- let lastGathering = "new";
85
- for (const transition of transitions) {
86
- if (transition.kind === "gathering") {
87
- states.push(`${lastGathering}\u2192${transition.state} ${seconds(transition.atMs)}`);
88
- lastGathering = transition.state;
89
- }
90
- else if (transition.kind === "ice") {
91
- states.push(`ice ${transition.state} ${seconds(transition.atMs)}`);
92
- }
93
- else {
94
- states.push(`${transition.state} ${seconds(transition.atMs)}`);
95
- }
96
- }
97
- if (!connected)
98
- states.push("no connected");
99
- return `${localPart}; ${remotePart}; states: ${states.join(", ")}`;
100
- };
101
- const span = (first, last) => first === undefined || last === undefined ? "no packets" : `first ${seconds(first)} last ${seconds(last)}`;
102
- const summarizeInbound = (inbound) => `in: ${inbound.rtpPackets} rtp, ${inbound.decodeFailures} bad, ${inbound.frames} frames, `
103
- + `${span(inbound.firstPacketAtMs, inbound.lastPacketAtMs)}, ${inbound.recentRtpPackets}/5s`;
104
- const summarizeOutbound = (outbound) => `out: ${outbound.frames} frames, ${outbound.opusPackets} opus, ${outbound.rtpPackets} rtp, `
105
- + `silence ${outbound.silencePackets}, `
106
- + `${span(outbound.firstPacketAtMs, outbound.lastPacketAtMs)}, ${outbound.recentRtpPackets}/5s, `
107
- + `queue ${outbound.queued}, pacer ${outbound.pacerAlive === undefined ? "n/a" : outbound.pacerAlive ? "alive" : "idle"}`;
108
- const summarizeRoom = (room) => {
109
- const parts = [`${room.roomStates} roomState`, `${room.offers} offer`];
110
- if (room.endedReason !== undefined)
111
- parts.push(`ended ${room.endedReason}`);
112
- if (room.errors.length)
113
- parts.push(`error ${room.errors.map((text) => JSON.stringify(text)).join(", ")}`);
114
- return `room: ${parts.join(", ")}`;
115
- };
116
- const summarize = (diagnostics) => `${summarizeIce(diagnostics)}; ${summarizeInbound(diagnostics.inbound)}; `
117
- + `${summarizeOutbound(diagnostics.outbound)}; ${summarizeRoom(diagnostics.room)}`
118
- + (diagnostics.restarts ? `; restarts ${diagnostics.restarts}` : "");
119
- const copyIceServers = (servers) => servers.map((server) => ({
120
- urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
121
- ...(server.username === undefined ? {} : { username: server.username }),
122
- ...(server.credential === undefined ? {} : { credential: server.credential }),
123
- }));
124
- const cloneSamples = (samples) => {
125
- const copy = new Int16Array(samples.length);
126
- copy.set(samples);
127
- return copy;
128
- };
129
- /**
130
- * `@roamhq/wrtc`'s `RTCAudioSource` plays each 10 ms slice through libwebrtc's
131
- * own clock and exposes no queue, so this wrapper estimates it from wall time:
132
- * queued = accepted since the run started minus the time elapsed. Best effort;
133
- * the werift engine (the default) reports its real queue.
134
- *
135
- * It sends no silence of its own. The source has no timer: `onData` hands the
136
- * slice to the track's sinks and returns (`RTCAudioSource::OnData` ->
137
- * `PushData`, node-webrtc src/interfaces/rtc_audio_source.cc and
138
- * rtc_audio_source.hh, the repository @roamhq/wrtc 0.10.0 is published
139
- * from), so libwebrtc receives audio only while the caller writes it, and
140
- * this wrapper has no `start()`. What libwebrtc puts on the wire between
141
- * writes was not measured; a caller of this
142
- * engine that needs the track to carry data between utterances writes
143
- * silence itself.
144
- */
145
- class WrtcAudioSource {
146
- #inner;
147
- #runStartedAt = 0;
148
- #runAcceptedMs = 0;
149
- constructor(inner) {
150
- this.#inner = inner;
151
- }
152
- createTrack() {
153
- return this.#inner.createTrack();
154
- }
155
- onData(data) {
156
- const now = Date.now();
157
- if (this.queuedMs() === 0) {
158
- this.#runStartedAt = now;
159
- this.#runAcceptedMs = 0;
160
- }
161
- this.#runAcceptedMs += (data.numberOfFrames / data.sampleRate) * 1_000;
162
- this.#inner.onData(data);
163
- }
164
- queuedMs() {
165
- if (this.#runAcceptedMs === 0)
166
- return 0;
167
- return Math.max(0, this.#runAcceptedMs - (Date.now() - this.#runStartedAt));
168
- }
169
- async waitForDrain() {
170
- const remaining = this.queuedMs();
171
- if (remaining > 0)
172
- await delay(remaining);
173
- }
174
- clear() {
175
- // libwebrtc keeps slices already handed over; only the estimate can be reset.
176
- this.#runAcceptedMs = 0;
177
- }
178
- }
179
- /** werift's `close()` is async; a rejection there must not become an unhandled one. */
180
- const closePeer = (peer) => {
181
- try {
182
- void Promise.resolve(peer.close()).catch(() => undefined);
183
- }
184
- catch { /* already closed */ }
185
- };
186
- const delay = (milliseconds) => new Promise((resolve) => {
187
- const timer = setTimeout(resolve, milliseconds);
188
- timer.unref?.();
189
- });
190
- /**
191
- * Provider-neutral Node WebRTC bridge for Relay Call rooms.
192
- *
193
- * The class owns Relay media negotiation internally. Higher-level adapters only
194
- * exchange PCM16 frames and call lifecycle events. When an SFU session never
195
- * connects or dies, the transport builds a new peer connection on a new
196
- * session (PROTOCOL.md section 4); the audio source and the `audio` events
197
- * carry on across the swap, so adapters only hear silence.
198
- */
199
- export class RelayCallTransport {
200
- #room;
201
- #providedFactory;
202
- #engine;
203
- #iceGatheringTimeoutMs;
204
- #sessionConnectTimeoutMs;
205
- /** `undefined`: the application passed none, so the room's servers are used. */
206
- #iceServers;
207
- #iceTransportPolicy;
208
- #inboundAudio;
209
- #onWarning;
210
- #connectStartedAt = 0;
211
- #inboundFrames = 0;
212
- #outboundFrames = 0;
213
- #roomStates = 0;
214
- #roomOffers = 0;
215
- #endedReason;
216
- #roomErrors = [];
217
- /** Engine stats frozen when media shuts down, so diagnostics survive the call's end. */
218
- #finalSinkStats;
219
- #finalSourceStats;
220
- /** Inbound counts from sinks of peers already replaced by a restart. */
221
- #retiredSinkStats;
222
- #stallTimer;
223
- #stallSince;
224
- #stallWarned = false;
225
- #iceLocal = { host: 0, srflx: 0, relay: 0, other: 0 };
226
- #iceRemote = [];
227
- #selectedPair;
228
- #iceTransitions = [];
229
- #listeners = new Map();
230
- #factory;
231
- #peer;
232
- /** Bumped for every peer built; work started for an older peer stops when it sees a newer one. */
233
- #peerGeneration = 0;
234
- /** The current peer has reached `connected` since it was built. */
235
- #peerConnected = false;
236
- #audioSource;
237
- #localTrack;
238
- /** Set while an offer waits for its first local candidate. */
239
- #firstLocalCandidate;
240
- #remoteSink;
241
- /** The track `#remoteSink` decodes; a repeated `ontrack` for it keeps the sink. */
242
- #remoteSinkTrack;
243
- #publishTransceiver;
244
- #publishFrame;
245
- #initialAnswerSdp;
246
- #negotiationTail = Promise.resolve();
247
- #restarts = 0;
248
- /** Restarts since the last `connected`; sets the backoff. */
249
- #failedAttempts = 0;
250
- #restartPending = false;
251
- #wakeRestart;
252
- #connectTimer;
253
- #disconnectTimer;
254
- #callStatus;
255
- #remoteVideo = false;
256
- #personConnected = false;
257
- #peerAudioArrived = false;
258
- #peerAudioReady = false;
259
- #peerAudioWaiters = new Set();
260
- #ended = false;
261
- #audioGeneration = 0;
262
- /** `waitForPlayout()` callers released early by `clearAudio()` or `close()`. */
263
- #playoutWaiters = new Set();
264
- #reportedConnected = false;
265
- #readySettled = false;
266
- #readyResolve;
267
- #readyReject;
268
- #ready;
269
- #handlersAttached = false;
270
- /** Releases a peer build waiting on the room's first `iceServers` or `roomState`. */
271
- #roomIceServersWaiter;
272
- #closed = false;
273
- #muted = false;
274
- /** The published camera: one per call, kept across restarts (PROTOCOL.md section 1). */
275
- #video;
276
- #videoTransceiver;
277
- #remoteVideoTrack;
278
- /** The engine track the current video receiver reads. */
279
- #remoteVideoEngineTrack;
280
- /** An add-track offer is out; a pull offer that crosses it waits for its answer. */
281
- #addTrackPending = false;
282
- #deferredOffer;
283
- #lastAnswerSdp;
284
- constructor(options) {
285
- if (!options.callId.trim())
286
- throw new Error("callId is required.");
287
- this.#room = options.roomClient ?? options.relay.calls.room(options.callId, options.room);
288
- this.#providedFactory = options.webRTC;
289
- this.#engine = options.engine ?? "werift";
290
- this.#onWarning = options.onWarning ?? (() => undefined);
291
- if (this.#engine !== "werift" && this.#engine !== "wrtc") {
292
- throw new Error('engine must be "werift" or "wrtc".');
293
- }
294
- const inbound = options.inboundAudio ?? DEFAULT_INBOUND_AUDIO;
295
- if (!INBOUND_SAMPLE_RATES.has(inbound.sampleRate)) {
296
- throw new Error("inboundAudio.sampleRate must be 8000, 12000, 16000, 24000 or 48000.");
297
- }
298
- if (inbound.channelCount !== 1 && inbound.channelCount !== 2) {
299
- throw new Error("inboundAudio.channelCount must be 1 or 2.");
300
- }
301
- const isDefaultInbound = inbound.sampleRate === DEFAULT_INBOUND_AUDIO.sampleRate
302
- && inbound.channelCount === DEFAULT_INBOUND_AUDIO.channelCount;
303
- if (!options.webRTC && this.#engine === "wrtc" && !isDefaultInbound) {
304
- // `@roamhq/wrtc`'s nonstandard RTCAudioSink hands over libwebrtc's own
305
- // PCM and takes no format; honouring another one would mean resampling here.
306
- throw new Error('The "wrtc" engine cannot decode to inboundAudio; use the "werift" engine.');
307
- }
308
- this.#inboundAudio = { sampleRate: inbound.sampleRate, channelCount: inbound.channelCount };
309
- this.#iceGatheringTimeoutMs = options.iceGatheringTimeoutMs ?? DEFAULT_ICE_GATHERING_TIMEOUT_MS;
310
- this.#sessionConnectTimeoutMs = options.sessionConnectTimeoutMs
311
- ?? options.mediaConnectTimeoutMs
312
- ?? options.connectionTimeoutMs
313
- ?? RESTART_CONNECT_TIMEOUT_MS;
314
- const policy = options.iceTransportPolicy ?? "all";
315
- if (policy !== "all" && policy !== "relay") {
316
- throw new Error('iceTransportPolicy must be "all" or "relay".');
317
- }
318
- this.#iceTransportPolicy = policy;
319
- const iceServers = options.iceServers;
320
- this.#iceServers = iceServers === undefined || typeof iceServers === "function"
321
- ? iceServers
322
- : copyIceServers(iceServers);
323
- if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
324
- throw new Error("iceGatheringTimeoutMs must be greater than zero.");
325
- }
326
- if (!Number.isFinite(this.#sessionConnectTimeoutMs) || this.#sessionConnectTimeoutMs <= 0) {
327
- throw new Error("sessionConnectTimeoutMs must be greater than zero.");
328
- }
329
- this.#ready = new Promise((resolve, reject) => {
330
- this.#readyResolve = resolve;
331
- this.#readyReject = reject;
332
- });
333
- void this.#ready.catch(() => undefined);
334
- }
335
- on(event, listener) {
336
- let listeners = this.#listeners.get(event);
337
- if (!listeners) {
338
- listeners = new Set();
339
- this.#listeners.set(event, listeners);
340
- }
341
- listeners.add(listener);
342
- return this;
343
- }
344
- off(event, listener) {
345
- this.#listeners.get(event)?.delete(listener);
346
- return this;
347
- }
348
- /**
349
- * Join the room, publish, and resolve on the first `connected`. Dead SFU
350
- * sessions are replaced as they are found (PROTOCOL.md section 4), with no
351
- * overall deadline: this rejects only when the Call ends, the room or
352
- * transport closes, the room reports an error, or `signal` aborts (which
353
- * also closes the transport).
354
- */
355
- async connect(options = {}) {
356
- if (this.#closed)
357
- throw new Error("Relay Call transport is closed.");
358
- const { signal } = options;
359
- const aborted = () => new RelayCallTransportError("Relay Call connect was aborted.", "aborted");
360
- if (signal?.aborted)
361
- throw aborted();
362
- const abort = () => {
363
- this.#rejectReady(aborted());
364
- this.close();
365
- };
366
- signal?.addEventListener("abort", abort, { once: true });
367
- try {
368
- await this.#connect();
369
- }
370
- finally {
371
- signal?.removeEventListener("abort", abort);
372
- }
373
- }
374
- async #connect() {
375
- this.#attachRoomHandlers();
376
- await this.#room.connect();
377
- if (this.#factory) {
378
- await this.#ready;
379
- return;
380
- }
381
- this.#factory = this.#providedFactory ?? await loadWebRTCFactory(this.#engine);
382
- this.#audioSource = this.#factory.createAudioSource();
383
- this.#localTrack = this.#audioSource.createTrack();
384
- if (this.#localTrack.kind !== "audio") {
385
- throw new RelayCallTransportError("The WebRTC binding created a non-audio Relay track.");
386
- }
387
- this.#connectStartedAt = Date.now();
388
- try {
389
- await this.#startPeer();
390
- await this.#ready;
391
- }
392
- catch (error) {
393
- this.close();
394
- throw error;
395
- }
396
- }
397
- /** Replace only the room signaling socket and replay the identical publication. */
398
- async reconnect() {
399
- if (this.#closed)
400
- throw new Error("Relay Call transport is closed.");
401
- if (!this.#publishFrame)
402
- throw new Error("Relay Call transport is not connected.");
403
- await this.#room.reconnect();
404
- this.#room.send(this.#publishFrame);
405
- }
406
- /**
407
- * ICE candidates, state transitions, packet counts in both directions,
408
- * room frame counts and restarts recorded for this call, with a one-line summary.
409
- */
410
- diagnostics() {
411
- const snapshot = {
412
- local: { ...this.#iceLocal },
413
- remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
414
- transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
415
- selectedPair: this.#selectedPair,
416
- connected: this.#peerConnected,
417
- inbound: this.#inboundDiagnostics(),
418
- outbound: this.#outboundDiagnostics(),
419
- room: {
420
- roomStates: this.#roomStates,
421
- offers: this.#roomOffers,
422
- endedReason: this.#endedReason,
423
- errors: [...this.#roomErrors],
424
- },
425
- restarts: this.#restarts,
426
- };
427
- return { ...snapshot, summary: summarize(snapshot) };
428
- }
429
- #sinceConnect(epochMs) {
430
- return epochMs === undefined ? undefined : epochMs - this.#connectStartedAt;
431
- }
432
- /** The live sink's counts added to those of sinks retired by restarts. */
433
- #sinkStats() {
434
- const current = this.#remoteSink?.stats?.();
435
- const retired = this.#retiredSinkStats;
436
- if (!retired)
437
- return current;
438
- if (!current)
439
- return { ...retired, recentRtpPackets: 0 };
440
- return {
441
- rtpPackets: retired.rtpPackets + current.rtpPackets,
442
- decodeFailures: retired.decodeFailures + current.decodeFailures,
443
- firstRtpAt: retired.firstRtpAt ?? current.firstRtpAt,
444
- lastRtpAt: current.lastRtpAt ?? retired.lastRtpAt,
445
- recentRtpPackets: current.recentRtpPackets,
446
- };
447
- }
448
- #inboundDiagnostics() {
449
- const stats = this.#finalSinkStats ?? this.#sinkStats();
450
- return {
451
- rtpPackets: stats?.rtpPackets ?? 0,
452
- decodeFailures: stats?.decodeFailures ?? 0,
453
- frames: this.#inboundFrames,
454
- firstPacketAtMs: this.#sinceConnect(stats?.firstRtpAt),
455
- lastPacketAtMs: this.#sinceConnect(stats?.lastRtpAt),
456
- recentRtpPackets: stats?.recentRtpPackets ?? 0,
457
- };
458
- }
459
- #outboundDiagnostics() {
460
- const stats = this.#audioSource?.stats?.() ?? this.#finalSourceStats;
461
- return {
462
- frames: this.#outboundFrames,
463
- opusPackets: stats?.opusPackets ?? 0,
464
- rtpPackets: stats?.rtpPackets ?? 0,
465
- silencePackets: stats?.silencePackets ?? 0,
466
- firstPacketAtMs: this.#sinceConnect(stats?.firstRtpAt),
467
- lastPacketAtMs: this.#sinceConnect(stats?.lastRtpAt),
468
- recentRtpPackets: stats?.recentRtpPackets ?? 0,
469
- queued: stats?.queued ?? 0,
470
- pacerAlive: stats?.pacerAlive,
471
- };
472
- }
473
- /**
474
- * Outbound stall guard: audio is queued for the pacer but no RTP packet has
475
- * left for 2 s while connected. Warns once with the summary; restarts nothing.
476
- */
477
- #checkStall() {
478
- if (this.#stallWarned || !this.#reportedConnected)
479
- return;
480
- const stats = this.#audioSource?.stats?.();
481
- if (!stats || stats.queued === 0) {
482
- this.#stallSince = undefined;
483
- return;
484
- }
485
- const now = Date.now();
486
- const idleSince = stats.lastRtpAt ?? (this.#stallSince ??= now);
487
- if (now - idleSince < STALL_AFTER_MS)
488
- return;
489
- this.#stallWarned = true;
490
- this.#stopStallGuard();
491
- this.#onWarning(`Relay outbound audio stalled (${this.diagnostics().summary})`);
492
- }
493
- #startStallGuard() {
494
- if (this.#stallTimer)
495
- return;
496
- this.#stallTimer = setInterval(() => this.#checkStall(), STALL_CHECK_MS);
497
- this.#stallTimer.unref?.();
498
- }
499
- #stopStallGuard() {
500
- if (!this.#stallTimer)
501
- return;
502
- clearInterval(this.#stallTimer);
503
- this.#stallTimer = undefined;
504
- }
505
- /**
506
- * Feed interleaved PCM16 audio into Relay. Frames are split into 10 ms WebRTC
507
- * source slices and handed to the engine at once; the engine's 20 ms pump
508
- * paces the wire, so adapters may push faster than real time (LiveKit's
509
- * `AudioSource.captureFrame` shape). Resolves once the slices are queued;
510
- * `waitForPlayout()` tells when they have left.
511
- */
512
- writeAudio(frame) {
513
- if (this.#closed)
514
- return Promise.reject(new Error("Relay Call transport is closed."));
515
- if (!this.#audioSource)
516
- return Promise.reject(new Error("Relay Call transport is not connected."));
517
- if (!Number.isInteger(frame.sampleRate) || frame.sampleRate <= 0 || frame.sampleRate % 100 !== 0) {
518
- return Promise.reject(new Error("Relay audio sampleRate must be a positive multiple of 100."));
519
- }
520
- if (!Number.isInteger(frame.channelCount) || frame.channelCount <= 0) {
521
- return Promise.reject(new Error("Relay audio channelCount must be a positive integer."));
522
- }
523
- if (frame.samples.length % frame.channelCount !== 0) {
524
- return Promise.reject(new Error("Relay audio samples must contain complete interleaved frames."));
525
- }
526
- const captured = {
527
- samples: cloneSamples(frame.samples),
528
- sampleRate: frame.sampleRate,
529
- channelCount: frame.channelCount,
530
- };
531
- this.#writeAudio(captured, this.#audioGeneration);
532
- return Promise.resolve();
533
- }
534
- /** Milliseconds of audio accepted by `writeAudio` but not yet written to RTP. */
535
- queuedAudioMs() {
536
- return this.#audioSource?.queuedMs?.() ?? 0;
537
- }
538
- /**
539
- * Resolves when every accepted slice has been written to RTP and the engine's
540
- * pump is idle; immediately when nothing is queued; early on `clearAudio()`
541
- * or `close()` (the caller reads `queuedAudioMs()` to learn what was dropped).
542
- */
543
- waitForPlayout() {
544
- const source = this.#audioSource;
545
- if (!source || this.#closed || this.queuedAudioMs() === 0)
546
- return Promise.resolve();
547
- return new Promise((resolve) => {
548
- const release = () => {
549
- this.#playoutWaiters.delete(release);
550
- resolve();
551
- };
552
- this.#playoutWaiters.add(release);
553
- const drained = source.waitForDrain?.() ?? Promise.resolve();
554
- drained.then(release, release);
555
- });
556
- }
557
- /** Drop outgoing PCM that has not reached RTP and release `waitForPlayout()` callers. */
558
- clearAudio() {
559
- this.#audioGeneration += 1;
560
- this.#audioSource?.clear?.();
561
- this.#releasePlayoutWaiters();
562
- }
563
- /**
564
- * Resolves once `peerAudio` has fired (at once if it already has). Rejects
565
- * after `timeoutMs`, or when the Call ends or the transport closes first.
566
- */
567
- waitForPeerAudio(timeoutMs) {
568
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
569
- return Promise.reject(new Error("waitForPeerAudio timeoutMs must be greater than zero."));
570
- }
571
- if (this.#peerAudioReady)
572
- return Promise.resolve();
573
- if (this.#closed || this.#ended) {
574
- return Promise.reject(new RelayCallTransportError("Relay Call ended before the person's audio arrived."));
575
- }
576
- return new Promise((resolve, reject) => {
577
- const waiter = {
578
- resolve: () => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); resolve(); },
579
- reject: (error) => { clearTimeout(timer); this.#peerAudioWaiters.delete(waiter); reject(error); },
580
- };
581
- const timer = setTimeout(() => waiter.reject(new RelayCallTransportError(`Timed out waiting for the person's audio (${this.diagnostics().summary})`)), timeoutMs);
582
- timer.unref?.();
583
- this.#peerAudioWaiters.add(waiter);
584
- });
585
- }
586
- #checkPeerAudio() {
587
- if (this.#peerAudioReady || !this.#peerAudioArrived || !this.#personConnected)
588
- return;
589
- this.#peerAudioReady = true;
590
- for (const waiter of [...this.#peerAudioWaiters])
591
- waiter.resolve();
592
- this.#emit("peerAudio");
593
- }
594
- #rejectPeerAudio(error) {
595
- for (const waiter of [...this.#peerAudioWaiters])
596
- waiter.reject(error);
597
- }
598
- setMuted(muted) {
599
- this.#muted = muted;
600
- this.#room.userUpdate({ muted });
601
- }
602
- /**
603
- * Publish the camera (LiveKit `LocalParticipant.publishTrack`). The first
604
- * call adds a `video` track to the SFU session with an add-track offer, then
605
- * announces `userUpdate { video: true }`; later calls, after
606
- * `unpublishTrack`, only resume sending and announce it again
607
- * (PROTOCOL.md sections 1-2). Requires `connect()` and the werift engine.
608
- */
609
- async publishTrack(track, options = {}) {
610
- if (this.#closed || this.#ended)
611
- throw new Error("Relay Call transport is closed.");
612
- if (!this.#factory || !this.#reportedConnected)
613
- throw new Error("Relay Call transport is not connected.");
614
- if (this.#video) {
615
- if (this.#video.track !== track)
616
- throw new RelayCallTransportError("A Relay call publishes one video track.");
617
- this.#video.sender.setEnabled(true);
618
- this.#room.userUpdate({ muted: this.#muted, video: true });
619
- return;
620
- }
621
- const factory = this.#factory;
622
- if (!factory.createVideoSender) {
623
- throw new RelayCallTransportError('This engine cannot send video; use the "werift" engine.');
624
- }
625
- const sender = await factory.createVideoSender(options);
626
- if (this.#closed || this.#ended) {
627
- sender.close();
628
- throw new Error("Relay Call transport is closed.");
629
- }
630
- const detach = track.source._attach((frame, timestampUs, rotation) => sender.capture(frame, timestampUs, rotation));
631
- this.#video = { track, sender, detach };
632
- await this.#negotiate(async () => {
633
- // No peer: a restart is pending, and the next peer publishes video with audio.
634
- const peer = this.#peer;
635
- if (!peer)
636
- return;
637
- this.#addVideoTransceiver(peer);
638
- await this.#publishLocalAudio(peer, false);
639
- this.#addTrackPending = this.#peer === peer;
640
- });
641
- this.#room.userUpdate({ muted: this.#muted, video: true });
642
- }
643
- /**
644
- * Stop sending the camera and announce `userUpdate { video: false }`. The
645
- * track stays negotiated on the SFU (PROTOCOL.md section 1), so a later
646
- * `publishTrack` with the same track resumes it without renegotiating.
647
- */
648
- async unpublishTrack(track) {
649
- if (!this.#video || this.#video.track !== track)
650
- return;
651
- this.#video.sender.setEnabled(false);
652
- if (!this.#closed && !this.#ended)
653
- this.#room.userUpdate({ muted: this.#muted, video: false });
654
- }
655
- /** The person's video track, once it has reached this peer. */
656
- get remoteVideoTrack() {
657
- return this.#remoteVideoTrack;
658
- }
659
- /** Frame, packet and keyframe counters for both video directions. */
660
- videoStats() {
661
- return { outbound: this.#video?.sender.stats(), inbound: this.#remoteVideoTrack?.stats() };
662
- }
663
- /** Runs `work` in the negotiation queue and hands its result or error to the caller. */
664
- #negotiate(work) {
665
- const run = this.#negotiationTail.then(work);
666
- this.#negotiationTail = run.then(() => undefined, () => undefined);
667
- return run;
668
- }
669
- #addVideoTransceiver(peer) {
670
- const video = this.#video;
671
- if (!video)
672
- return;
673
- this.#videoTransceiver = peer.addTransceiver(video.sender.track, { direction: "sendonly" });
674
- video.sender.bind(this.#videoTransceiver, peer);
675
- }
676
- end() {
677
- this.#room.end();
678
- }
679
- close() {
680
- if (this.#closed)
681
- return;
682
- this.#closed = true;
683
- this.#rejectReady(new RelayCallTransportError("Relay Call transport closed before media connected."));
684
- this.#rejectPeerAudio(new RelayCallTransportError("Relay Call transport closed before the person's audio arrived."));
685
- this.#audioGeneration += 1;
686
- this.#shutdownMedia();
687
- this.#releasePlayoutWaiters();
688
- this.#roomIceServersWaiter?.();
689
- this.#room.close();
690
- }
691
- /**
692
- * Build a peer connection around the one local track and publish it. The
693
- * first peer sends a plain `offer`; every later one is a restart onto a new
694
- * SFU session (`restart: true`) with ICE servers fetched again.
695
- */
696
- async #startPeer() {
697
- const factory = this.#factory;
698
- const track = this.#localTrack;
699
- if (!factory || !track)
700
- throw new Error("Relay Call transport is not connected.");
701
- const restarts = this.#restarts;
702
- const generation = ++this.#peerGeneration;
703
- const iceServers = this.#iceServers === undefined
704
- ? copyIceServers(await this.#roomIceServers())
705
- : typeof this.#iceServers === "function"
706
- ? copyIceServers(await this.#iceServers({ restarts }))
707
- : copyIceServers(this.#iceServers);
708
- if (this.#closed || this.#ended || generation !== this.#peerGeneration)
709
- return;
710
- const peer = factory.createPeerConnection({ iceServers, iceTransportPolicy: this.#iceTransportPolicy });
711
- this.#peer = peer;
712
- this.#peerConnected = false;
713
- this.#initialAnswerSdp = undefined;
714
- this.#publishTransceiver = peer.addTransceiver(track, { direction: "sendonly" });
715
- this.#addVideoTransceiver(peer);
716
- this.#observeIce(peer);
717
- peer.onconnectionstatechange = () => {
718
- if (this.#peer !== peer)
719
- return;
720
- this.#recordTransition("connection", peer.connectionState);
721
- this.#connectionStateChanged(peer);
722
- };
723
- peer.ontrack = (event) => {
724
- if (this.#peer === peer)
725
- this.#remoteTrack(event.track, event.transceiver);
726
- };
727
- await this.#publishLocalAudio(peer, restarts > 0);
728
- }
729
- /**
730
- * Sends the offer without waiting for ICE gathering, as Cloudflare's echo
731
- * example does (`setLocalDescription(offer)`, then `tracks/new` at once): the
732
- * SFU is ICE-lite and learns this peer's address from its connectivity
733
- * checks. werift's `setLocalDescription` resolves only after every host,
734
- * STUN and TURN candidate has settled (peerConnection.js `await
735
- * this.gatherCandidates()`; ice.js `await Promise.allSettled(candidatePromises)`),
736
- * which took 6.6 s on a staging call on 2026-09-22, so the offer leaves as
737
- * soon as the description is applied and the first local candidate exists.
738
- */
739
- async #publishLocalAudio(peer, restart) {
740
- const offer = await peer.createOffer();
741
- await this.#applyLocalOffer(peer, offer);
742
- if (this.#peer !== peer)
743
- return;
744
- const description = this.#localDescription(peer, "offer");
745
- const mid = this.#publishTransceiver?.mid;
746
- if (!mid)
747
- throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
748
- const videoMid = this.#video ? this.#videoTransceiver?.mid : undefined;
749
- if (this.#video && !videoMid)
750
- throw new RelayCallTransportError("Relay video publication has no WebRTC MID.");
751
- this.#publishFrame = {
752
- type: "offer",
753
- session_description: description,
754
- tracks: videoMid ? [{ mid, name: "audio" }, { mid: videoMid, name: "video" }] : [{ mid, name: "audio" }],
755
- ...(restart ? { restart: true } : {}),
756
- };
757
- this.#room.send(this.#publishFrame);
758
- }
759
- /**
760
- * The room's latest servers (PROTOCOL.md section 6). Relay sends
761
- * `iceServers` after it accepts `join` and before the first `roomState`, so
762
- * a `roomState` with no `iceServers` before it means a room that sends none:
763
- * Cloudflare's STUN server is used. Otherwise waits for whichever arrives
764
- * first, or for the room to close, end or fail.
765
- */
766
- async #roomIceServers() {
767
- const settled = () => {
768
- if (this.#room.iceServers)
769
- return this.#room.iceServers;
770
- if (this.#room.state || this.#closed || this.#ended)
771
- return DEFAULT_ICE_SERVERS;
772
- return undefined;
773
- };
774
- const now = settled();
775
- if (now)
776
- return now;
777
- await new Promise((resolve) => {
778
- const events = ["iceServers", "roomState", "ended", "error", "close"];
779
- const done = () => {
780
- for (const event of events)
781
- this.#room.off(event, done);
782
- this.#roomIceServersWaiter = undefined;
783
- resolve();
784
- };
785
- for (const event of events)
786
- this.#room.on(event, done);
787
- this.#roomIceServersWaiter = done;
788
- });
789
- return settled() ?? DEFAULT_ICE_SERVERS;
790
- }
791
- #attachRoomHandlers() {
792
- if (this.#handlersAttached)
793
- return;
794
- this.#handlersAttached = true;
795
- this.#room.on("answer", (frame) => {
796
- this.#queueNegotiation(() => this.#serverAnswer(frame));
797
- });
798
- this.#room.on("offer", (frame) => {
799
- this.#roomOffers += 1;
800
- this.#queueNegotiation(() => this.#serverOffer(frame));
801
- });
802
- this.#room.on("roomState", (frame) => {
803
- this.#roomStates += 1;
804
- this.#callStatus = frame.call?.status;
805
- // A Call has exactly one agent; the transport is that agent, so the
806
- // person is the other participant.
807
- const person = frame.participants?.find((participant) => participant.kind === "user");
808
- const video = person?.video === true;
809
- const videoChanged = video !== this.#remoteVideo;
810
- this.#remoteVideo = video;
811
- this.#personConnected = person?.connected === true;
812
- this.#emit("roomState", frame);
813
- if (videoChanged)
814
- this.#emit("remoteVideo", video);
815
- this.#checkPeerAudio();
816
- });
817
- this.#room.on("error", (error) => {
818
- if (error instanceof Error) {
819
- this.#rejectReady(error);
820
- this.#emit("error", error);
821
- }
822
- else
823
- this.#serverError(error);
824
- });
825
- this.#room.on("ended", (frame) => {
826
- this.#ended = true;
827
- this.#endedReason = frame.reason;
828
- this.#rejectReady(new RelayCallTransportError(`Relay Call ended before media connected (${frame.reason}).`));
829
- this.#rejectPeerAudio(new RelayCallTransportError(`Relay Call ended before the person's audio arrived (${frame.reason}).`));
830
- this.clearAudio();
831
- this.#shutdownMedia();
832
- this.#emit("ended", frame);
833
- });
834
- this.#room.on("close", (event) => {
835
- if (!this.#reportedConnected) {
836
- this.#rejectReady(new RelayCallTransportError(`Relay Call room closed before media connected (${event.code}).`));
837
- }
838
- this.#emit("close", event);
839
- });
840
- }
841
- #queueNegotiation(work) {
842
- const run = this.#negotiationTail.then(work);
843
- this.#negotiationTail = run.catch((error) => {
844
- const parsed = error instanceof Error ? error : new Error(String(error));
845
- this.#rejectReady(parsed);
846
- this.#emit("error", parsed);
847
- });
848
- }
849
- async #serverAnswer(frame) {
850
- // No peer: the answer is for a session a restart already replaced.
851
- const peer = this.#peer;
852
- if (!peer)
853
- return;
854
- const sdp = frame.session_description.sdp;
855
- // Reconnecting the signaling socket replays the exact initial offer. Relay
856
- // returns its cached answer; applying that answer again in stable state is
857
- // invalid WebRTC signaling, so recognize and ignore the replay.
858
- if (peer.signalingState === "stable" && (this.#initialAnswerSdp === sdp || this.#lastAnswerSdp === sdp))
859
- return;
860
- const initial = this.#initialAnswerSdp === undefined;
861
- if (initial)
862
- this.#recordRemoteCandidates(sdp);
863
- await peer.setRemoteDescription(frame.session_description);
864
- if (this.#peer !== peer)
865
- return;
866
- this.#initialAnswerSdp ??= sdp;
867
- this.#lastAnswerSdp = sdp;
868
- if (initial && !this.#peerConnected)
869
- this.#armConnectTimer(peer);
870
- if (this.#addTrackPending) {
871
- this.#addTrackPending = false;
872
- const deferred = this.#deferredOffer;
873
- this.#deferredOffer = undefined;
874
- if (deferred)
875
- await this.#serverOffer(deferred);
876
- }
877
- }
878
- async #serverOffer(frame) {
879
- const peer = this.#peer;
880
- // A pull offer that crosses this participant's add-track offer is for the
881
- // live session: it is answered once the add-track answer is applied.
882
- if (peer && this.#addTrackPending) {
883
- this.#deferredOffer = frame;
884
- return;
885
- }
886
- // A pull offer that arrives while this participant's restart offer is
887
- // unanswered was sent for the replaced session: the room clears those
888
- // pulls on restart and pulls again after the new session connects.
889
- if (!peer || peer.signalingState === "have-local-offer")
890
- return;
891
- // `video` m-lines are answered receive-only; `#remoteTrack` decodes them
892
- // when the engine has video.
893
- await peer.setRemoteDescription(frame.session_description);
894
- const answer = await peer.createAnswer();
895
- await peer.setLocalDescription(answer);
896
- if (this.#peer !== peer)
897
- return;
898
- this.#room.send({ type: "answer", session_description: this.#localDescription(peer, "answer") });
899
- }
900
- #serverError(frame) {
901
- this.#roomErrors.push(frame.message);
902
- const error = new RelayCallTransportError(frame.message, frame.code);
903
- this.#rejectReady(error);
904
- this.#emit("error", error);
905
- }
906
- #connectionStateChanged(peer) {
907
- const state = peer.connectionState;
908
- if (state === "connected") {
909
- this.#clearTimer("connect");
910
- this.#clearTimer("disconnect");
911
- if (this.#peerConnected)
912
- return;
913
- this.#peerConnected = true;
914
- this.#failedAttempts = 0;
915
- void this.#readSelectedPair(peer);
916
- // The SFU will not pull a track that has carried no RTP, so the source
917
- // sends silence from here on until it is closed.
918
- this.#audioSource?.start?.();
919
- try {
920
- // Sent for every new session: the room re-pulls a restarted participant's
921
- // tracks once it reports `connected` (PROTOCOL.md section 2).
922
- this.#room.connected();
923
- if (!this.#reportedConnected) {
924
- this.#reportedConnected = true;
925
- this.#resolveReady();
926
- this.#startStallGuard();
927
- this.#emit("connected");
928
- }
929
- }
930
- catch (error) {
931
- const parsed = error instanceof Error ? error : new Error(String(error));
932
- this.#rejectReady(parsed);
933
- this.#emit("error", parsed);
934
- }
935
- }
936
- else if (state === "failed") {
937
- this.#requestRestart("failed", peer);
938
- }
939
- else if (state === "disconnected") {
940
- if (this.#disconnectTimer)
941
- return;
942
- this.#disconnectTimer = setTimeout(() => {
943
- this.#disconnectTimer = undefined;
944
- if (peer.connectionState !== "connected")
945
- this.#requestRestart("disconnected", peer);
946
- }, RESTART_DISCONNECTED_MS);
947
- this.#disconnectTimer.unref?.();
948
- }
949
- }
950
- #armConnectTimer(peer) {
951
- this.#clearTimer("connect");
952
- this.#connectTimer = setTimeout(() => {
953
- this.#connectTimer = undefined;
954
- if (!this.#peerConnected)
955
- this.#requestRestart("timeout", peer);
956
- }, this.#sessionConnectTimeoutMs);
957
- this.#connectTimer.unref?.();
958
- }
959
- #clearTimer(which) {
960
- if (which === "connect") {
961
- clearTimeout(this.#connectTimer);
962
- this.#connectTimer = undefined;
963
- }
964
- else {
965
- clearTimeout(this.#disconnectTimer);
966
- this.#disconnectTimer = undefined;
967
- }
968
- }
969
- #callActive() {
970
- if (this.#closed || this.#ended)
971
- return false;
972
- return this.#callStatus === undefined || ACTIVE_CALL_STATUSES.has(this.#callStatus);
973
- }
974
- /**
975
- * Retire `peer` now, wait the backoff, then publish from a new peer on a new
976
- * session. Unlimited while the Call is ringing or in progress; stops on
977
- * `ended`, a terminal status, or `close()`.
978
- */
979
- #requestRestart(reason, peer) {
980
- if (this.#restartPending || peer !== this.#peer || !this.#callActive())
981
- return;
982
- const summary = this.diagnostics().summary;
983
- this.#restartPending = true;
984
- this.#restarts += 1;
985
- this.#failedAttempts += 1;
986
- const restarts = this.#restarts;
987
- const delayMs = restartDelayMs(this.#failedAttempts);
988
- this.#retirePeer();
989
- this.#queueNegotiation(async () => {
990
- await this.#restartBackoff(delayMs);
991
- this.#restartPending = false;
992
- if (!this.#callActive())
993
- return;
994
- try {
995
- await this.#startPeer();
996
- }
997
- catch (error) {
998
- if (!this.#callActive())
999
- return;
1000
- const parsed = error instanceof Error ? error : new Error(String(error));
1001
- this.#emit("error", new RelayCallTransportError(`Relay WebRTC restart ${restarts} failed: ${parsed.message}`, "restart_failed"));
1002
- this.#requestRestart("error", this.#peer);
1003
- return;
1004
- }
1005
- if (this.#peer)
1006
- this.#emit("restarted", { reason, summary, restarts, delayMs });
1007
- });
1008
- }
1009
- #restartBackoff(milliseconds) {
1010
- return new Promise((resolve) => {
1011
- const wake = () => {
1012
- clearTimeout(timer);
1013
- if (this.#wakeRestart === wake)
1014
- this.#wakeRestart = undefined;
1015
- resolve();
1016
- };
1017
- const timer = setTimeout(wake, milliseconds);
1018
- timer.unref?.();
1019
- this.#wakeRestart = wake;
1020
- });
1021
- }
1022
- /** Close the current peer and its sink; the audio source and local track stay for the next peer. */
1023
- #retirePeer() {
1024
- this.#clearTimer("connect");
1025
- this.#clearTimer("disconnect");
1026
- const peer = this.#peer;
1027
- this.#peer = undefined;
1028
- this.#peerConnected = false;
1029
- this.#publishTransceiver = undefined;
1030
- this.#videoTransceiver = undefined;
1031
- this.#initialAnswerSdp = undefined;
1032
- this.#lastAnswerSdp = undefined;
1033
- this.#addTrackPending = false;
1034
- this.#deferredOffer = undefined;
1035
- this.#remoteVideoTrack?._detach();
1036
- this.#remoteVideoEngineTrack = undefined;
1037
- if (this.#remoteSink) {
1038
- this.#retiredSinkStats = this.#sinkStats();
1039
- this.#remoteSink.stop();
1040
- this.#remoteSink = undefined;
1041
- this.#remoteSinkTrack = undefined;
1042
- }
1043
- if (!peer)
1044
- return;
1045
- peer.onconnectionstatechange = null;
1046
- peer.ontrack = null;
1047
- peer.onicecandidate = null;
1048
- peer.onicegatheringstatechange = null;
1049
- peer.oniceconnectionstatechange = null;
1050
- closePeer(peer);
1051
- }
1052
- /**
1053
- * werift fires `ontrack` again for every sending m-line on each
1054
- * `setRemoteDescription` (transceiverManager.js `setRemoteRTP`), so a pull
1055
- * offer that adds the person's video re-announces the audio track already
1056
- * decoded. That track keeps its sink, decoder and packet counts; a different
1057
- * audio track retires the old sink's counts into the call totals, as a restart does.
1058
- */
1059
- #remoteTrack(track, transceiver) {
1060
- if (track.kind === "video") {
1061
- this.#subscribeRemoteVideo(track, transceiver);
1062
- return;
1063
- }
1064
- if (track.kind !== "audio" || !this.#factory)
1065
- return;
1066
- if (this.#remoteSink && this.#remoteSinkTrack === track)
1067
- return;
1068
- if (this.#remoteSink) {
1069
- this.#retiredSinkStats = this.#sinkStats();
1070
- this.#remoteSink.stop();
1071
- }
1072
- const sink = this.#factory.createAudioSink(track, this.#inboundAudio);
1073
- this.#remoteSink = sink;
1074
- this.#remoteSinkTrack = track;
1075
- sink.ondata = (data) => {
1076
- if (this.#closed || this.#remoteSink !== sink)
1077
- return;
1078
- const channelCount = data.channelCount ?? 1;
1079
- if (data.bitsPerSample !== undefined && data.bitsPerSample !== 16) {
1080
- this.#emit("error", new RelayCallTransportError(`Relay WebRTC delivered unsupported ${data.bitsPerSample}-bit audio.`));
1081
- return;
1082
- }
1083
- this.#inboundFrames += 1;
1084
- this.#emit("audio", {
1085
- samples: cloneSamples(data.samples),
1086
- sampleRate: data.sampleRate,
1087
- channelCount,
1088
- });
1089
- if (!this.#peerAudioArrived) {
1090
- this.#peerAudioArrived = true;
1091
- this.#checkPeerAudio();
1092
- }
1093
- };
1094
- }
1095
- /**
1096
- * One `RemoteVideoTrack` per call; each new session's receiver is attached
1097
- * to it. werift fires `ontrack` again for every sending m-line on each
1098
- * `setRemoteDescription` (transceiverManager.js `setRemoteRTP`), so the
1099
- * track already being decoded keeps its receiver.
1100
- */
1101
- #subscribeRemoteVideo(track, transceiver) {
1102
- const factory = this.#factory;
1103
- if (!factory?.createVideoReceiver || this.#closed)
1104
- return;
1105
- if (this.#remoteVideoEngineTrack === track)
1106
- return;
1107
- this.#remoteVideoEngineTrack = track;
1108
- const receiver = factory.createVideoReceiver(track, transceiver);
1109
- const existing = this.#remoteVideoTrack;
1110
- const remote = existing ?? new RemoteVideoTrack();
1111
- this.#remoteVideoTrack = remote;
1112
- remote._attach(receiver);
1113
- if (!existing)
1114
- this.#emit("trackSubscribed", remote);
1115
- }
1116
- #writeAudio(frame, generation) {
1117
- const source = this.#audioSource;
1118
- if (!source || generation !== this.#audioGeneration)
1119
- return;
1120
- const samplesPerChannel = (frame.sampleRate * AUDIO_SLICE_MS) / 1000;
1121
- const sliceSamples = samplesPerChannel * frame.channelCount;
1122
- for (let offset = 0; offset < frame.samples.length; offset += sliceSamples) {
1123
- if (this.#closed || generation !== this.#audioGeneration)
1124
- return;
1125
- const remaining = Math.min(sliceSamples, frame.samples.length - offset);
1126
- const samples = new Int16Array(sliceSamples);
1127
- samples.set(frame.samples.subarray(offset, offset + remaining));
1128
- source.onData({
1129
- samples,
1130
- sampleRate: frame.sampleRate,
1131
- bitsPerSample: 16,
1132
- channelCount: frame.channelCount,
1133
- numberOfFrames: samplesPerChannel,
1134
- });
1135
- this.#outboundFrames += 1;
1136
- }
1137
- }
1138
- #releasePlayoutWaiters() {
1139
- for (const release of [...this.#playoutWaiters])
1140
- release();
1141
- }
1142
- /**
1143
- * Resolves once the offer is applied and has a first local candidate, or
1144
- * once `setLocalDescription` resolves (W3C engines resolve before gathering),
1145
- * whichever comes first.
1146
- */
1147
- async #applyLocalOffer(peer, offer) {
1148
- const applied = peer.setLocalDescription(offer);
1149
- let settled = false;
1150
- applied.catch((error) => {
1151
- if (!settled || this.#peer !== peer)
1152
- return;
1153
- const parsed = error instanceof Error ? error : new Error(String(error));
1154
- this.#rejectReady(parsed);
1155
- this.#emit("error", parsed);
1156
- });
1157
- try {
1158
- await new Promise((resolve, reject) => {
1159
- const timeout = setTimeout(() => {
1160
- cleanup();
1161
- reject(new RelayCallTransportError("Timed out gathering Relay WebRTC ICE candidates."));
1162
- }, this.#iceGatheringTimeoutMs);
1163
- timeout.unref?.();
1164
- const cleanup = () => {
1165
- clearTimeout(timeout);
1166
- if (this.#firstLocalCandidate === done)
1167
- this.#firstLocalCandidate = undefined;
1168
- };
1169
- const done = () => {
1170
- cleanup();
1171
- resolve();
1172
- };
1173
- this.#firstLocalCandidate = done;
1174
- applied.then(done, (error) => {
1175
- cleanup();
1176
- reject(error);
1177
- });
1178
- });
1179
- }
1180
- finally {
1181
- settled = true;
1182
- }
1183
- }
1184
- /**
1185
- * werift emits these as W3C-style handler calls (peerConnection.js:314-341:
1186
- * `onicegatheringstatechange`, `oniceconnectionstatechange`,
1187
- * `onconnectionstatechange`, `onicecandidate` with `{ candidate }`).
1188
- */
1189
- #observeIce(peer) {
1190
- peer.onicecandidate = (event) => {
1191
- const line = event?.candidate?.candidate;
1192
- if (!line)
1193
- return;
1194
- const type = parseCandidate(line)?.type;
1195
- if (type === "host" || type === "srflx" || type === "relay")
1196
- this.#iceLocal[type] += 1;
1197
- else
1198
- this.#iceLocal.other += 1;
1199
- if (this.#peer === peer && peer.localDescription)
1200
- this.#firstLocalCandidate?.();
1201
- };
1202
- peer.onicegatheringstatechange = () => this.#recordTransition("gathering", peer.iceGatheringState);
1203
- peer.oniceconnectionstatechange = () => {
1204
- if (peer.iceConnectionState !== undefined)
1205
- this.#recordTransition("ice", peer.iceConnectionState);
1206
- };
1207
- }
1208
- /**
1209
- * W3C stats path: `transport.selectedCandidatePairId` -> `candidate-pair`
1210
- * -> `local-candidate.candidateType` (werift transport/dtls.js and
1211
- * transport/ice.js `getStats`); a nominated, succeeded pair when the
1212
- * transport names none.
1213
- */
1214
- async #readSelectedPair(peer) {
1215
- let report;
1216
- try {
1217
- report = await peer.getStats?.();
1218
- }
1219
- catch {
1220
- return;
1221
- }
1222
- if (!report || this.#peer !== peer)
1223
- return;
1224
- const stats = new Map();
1225
- report.forEach((stat) => stats.set(String(stat.id), stat));
1226
- const all = [...stats.values()];
1227
- const selectedId = all.find((stat) => stat.type === "transport" && stat.selectedCandidatePairId)
1228
- ?.selectedCandidatePairId;
1229
- const pair = selectedId !== undefined
1230
- ? stats.get(String(selectedId))
1231
- : all.find((stat) => stat.type === "candidate-pair" && stat.nominated === true && stat.state === "succeeded");
1232
- const local = pair ? stats.get(String(pair.localCandidateId)) : undefined;
1233
- if (typeof local?.candidateType !== "string")
1234
- return;
1235
- this.#selectedPair = `${local.candidateType} ${typeof local.protocol === "string" ? local.protocol : "?"}`;
1236
- }
1237
- #recordTransition(kind, state) {
1238
- this.#iceTransitions.push({ kind, state, atMs: Date.now() - this.#connectStartedAt });
1239
- }
1240
- #recordRemoteCandidates(sdp) {
1241
- for (const line of sdp.split(/\r?\n/)) {
1242
- if (!line.startsWith("a=candidate:"))
1243
- continue;
1244
- const parsed = parseCandidate(line);
1245
- if (parsed)
1246
- this.#iceRemote.push({ transport: parsed.transport, port: parsed.port });
1247
- }
1248
- }
1249
- #resolveReady() {
1250
- if (this.#readySettled)
1251
- return;
1252
- this.#readySettled = true;
1253
- this.#readyResolve?.();
1254
- }
1255
- #rejectReady(error) {
1256
- if (this.#readySettled)
1257
- return;
1258
- this.#readySettled = true;
1259
- this.#readyReject?.(error);
1260
- }
1261
- #localDescription(peer, type) {
1262
- const local = peer.localDescription;
1263
- if (!local || local.type !== type || !local.sdp) {
1264
- throw new RelayCallTransportError(`Relay WebRTC did not produce a complete ${type} SDP.`);
1265
- }
1266
- return { type, sdp: local.sdp };
1267
- }
1268
- #shutdownMedia() {
1269
- this.#stopStallGuard();
1270
- this.#clearTimer("connect");
1271
- this.#clearTimer("disconnect");
1272
- this.#wakeRestart?.();
1273
- this.#finalSinkStats ??= this.#sinkStats();
1274
- this.#finalSourceStats ??= this.#audioSource?.stats?.();
1275
- this.#remoteSink?.stop();
1276
- this.#remoteSink = undefined;
1277
- this.#remoteSinkTrack = undefined;
1278
- this.#localTrack?.stop();
1279
- this.#localTrack = undefined;
1280
- if (this.#video) {
1281
- this.#video.detach();
1282
- this.#video.sender.close();
1283
- }
1284
- const remoteVideo = this.#remoteVideoTrack;
1285
- if (remoteVideo) {
1286
- remoteVideo._end();
1287
- this.#emit("trackUnsubscribed", remoteVideo);
1288
- }
1289
- if (this.#peer)
1290
- closePeer(this.#peer);
1291
- this.#peer = undefined;
1292
- this.#audioSource = undefined;
1293
- }
1294
- #emit(event, ...args) {
1295
- for (const listener of this.#listeners.get(event) ?? []) {
1296
- try {
1297
- listener(...args);
1298
- }
1299
- catch { /* adapters own listener failures */ }
1300
- }
1301
- }
1302
- }
1303
- //# sourceMappingURL=transport.js.map