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