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