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