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