@relaymessenger/livekit 0.1.0-staging.3 → 0.1.0-staging.30
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 +20 -95
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/livekit.d.ts +39 -5
- package/dist/livekit.d.ts.map +1 -1
- package/dist/livekit.js +59 -9
- package/dist/livekit.js.map +1 -1
- package/package.json +7 -9
- package/dist/engine-werift.d.ts +0 -19
- package/dist/engine-werift.d.ts.map +0 -1
- package/dist/engine-werift.js +0 -309
- package/dist/engine-werift.js.map +0 -1
- package/dist/transport.d.ts +0 -170
- package/dist/transport.d.ts.map +0 -1
- package/dist/transport.js +0 -705
- package/dist/transport.js.map +0 -1
package/dist/transport.js
DELETED
|
@@ -1,705 +0,0 @@
|
|
|
1
|
-
const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 10_000;
|
|
2
|
-
const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
|
|
3
|
-
const AUDIO_SLICE_MS = 10;
|
|
4
|
-
const STALL_CHECK_MS = 500;
|
|
5
|
-
const STALL_AFTER_MS = 2_000;
|
|
6
|
-
export class RelayCallTransportError extends Error {
|
|
7
|
-
code;
|
|
8
|
-
constructor(message, code) {
|
|
9
|
-
super(message);
|
|
10
|
-
this.name = "RelayCallTransportError";
|
|
11
|
-
if (code !== undefined)
|
|
12
|
-
this.code = code;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
const DEFAULT_PEER_CONFIG = { iceServers: [], iceTransportPolicy: "all" };
|
|
16
|
-
const loadWebRTCFactory = async (engine) => {
|
|
17
|
-
if (engine === "werift") {
|
|
18
|
-
const { createWeriftWebRTCFactory } = await import("./engine-werift.js");
|
|
19
|
-
return createWeriftWebRTCFactory();
|
|
20
|
-
}
|
|
21
|
-
const wrtc = await import("@roamhq/wrtc");
|
|
22
|
-
return {
|
|
23
|
-
createPeerConnection: (config = DEFAULT_PEER_CONFIG) => new wrtc.RTCPeerConnection({
|
|
24
|
-
bundlePolicy: "max-bundle",
|
|
25
|
-
iceServers: config.iceServers,
|
|
26
|
-
iceTransportPolicy: config.iceTransportPolicy,
|
|
27
|
-
}),
|
|
28
|
-
createAudioSource: () => new WrtcAudioSource(new wrtc.nonstandard.RTCAudioSource()),
|
|
29
|
-
createAudioSink: (track) => new wrtc.nonstandard.RTCAudioSink(track),
|
|
30
|
-
};
|
|
31
|
-
};
|
|
32
|
-
/** `candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> ...` (RFC 5245 §15.1). */
|
|
33
|
-
const parseCandidate = (line) => {
|
|
34
|
-
const match = /candidate:\S+\s+\d+\s+(\S+)\s+\d+\s+\S+\s+(\d+)\s+typ\s+(\S+)/i.exec(line);
|
|
35
|
-
if (!match)
|
|
36
|
-
return undefined;
|
|
37
|
-
return { transport: match[1].toLowerCase(), port: Number(match[2]), type: match[3].toLowerCase() };
|
|
38
|
-
};
|
|
39
|
-
const seconds = (milliseconds) => `${(milliseconds / 1000).toFixed(1)}s`;
|
|
40
|
-
const summarizeIce = (diagnostics) => {
|
|
41
|
-
const { local, remote, transitions, connected } = diagnostics;
|
|
42
|
-
const localPart = `local: host ${local.host}, srflx ${local.srflx}, relay ${local.relay}`
|
|
43
|
-
+ (local.other ? `, other ${local.other}` : "");
|
|
44
|
-
const remotePart = remote.length
|
|
45
|
-
? `remote: ${remote.map((candidate) => `${candidate.transport} ${candidate.port}`).join(", ")}`
|
|
46
|
-
: "remote: none";
|
|
47
|
-
const states = [];
|
|
48
|
-
let lastGathering = "new";
|
|
49
|
-
for (const transition of transitions) {
|
|
50
|
-
if (transition.kind === "gathering") {
|
|
51
|
-
states.push(`${lastGathering}\u2192${transition.state} ${seconds(transition.atMs)}`);
|
|
52
|
-
lastGathering = transition.state;
|
|
53
|
-
}
|
|
54
|
-
else if (transition.kind === "ice") {
|
|
55
|
-
states.push(`ice ${transition.state} ${seconds(transition.atMs)}`);
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
states.push(`${transition.state} ${seconds(transition.atMs)}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
if (!connected)
|
|
62
|
-
states.push("no connected");
|
|
63
|
-
return `${localPart}; ${remotePart}; states: ${states.join(", ")}`;
|
|
64
|
-
};
|
|
65
|
-
const span = (first, last) => first === undefined || last === undefined ? "no packets" : `first ${seconds(first)} last ${seconds(last)}`;
|
|
66
|
-
const summarizeInbound = (inbound) => `in: ${inbound.rtpPackets} rtp, ${inbound.decodeFailures} bad, ${inbound.frames} frames, `
|
|
67
|
-
+ `${span(inbound.firstPacketAtMs, inbound.lastPacketAtMs)}, ${inbound.recentRtpPackets}/5s`;
|
|
68
|
-
const summarizeOutbound = (outbound) => `out: ${outbound.frames} frames, ${outbound.opusPackets} opus, ${outbound.rtpPackets} rtp, `
|
|
69
|
-
+ `${span(outbound.firstPacketAtMs, outbound.lastPacketAtMs)}, ${outbound.recentRtpPackets}/5s, `
|
|
70
|
-
+ `queue ${outbound.queued}, pacer ${outbound.pacerAlive === undefined ? "n/a" : outbound.pacerAlive ? "alive" : "idle"}`;
|
|
71
|
-
const summarizeRoom = (room) => {
|
|
72
|
-
const parts = [`${room.roomStates} roomState`, `${room.offers} offer`];
|
|
73
|
-
if (room.endedReason !== undefined)
|
|
74
|
-
parts.push(`ended ${room.endedReason}`);
|
|
75
|
-
if (room.errors.length)
|
|
76
|
-
parts.push(`error ${room.errors.map((text) => JSON.stringify(text)).join(", ")}`);
|
|
77
|
-
return `room: ${parts.join(", ")}`;
|
|
78
|
-
};
|
|
79
|
-
const summarize = (diagnostics) => `${summarizeIce(diagnostics)}; ${summarizeInbound(diagnostics.inbound)}; `
|
|
80
|
-
+ `${summarizeOutbound(diagnostics.outbound)}; ${summarizeRoom(diagnostics.room)}`;
|
|
81
|
-
const cloneSamples = (samples) => {
|
|
82
|
-
const copy = new Int16Array(samples.length);
|
|
83
|
-
copy.set(samples);
|
|
84
|
-
return copy;
|
|
85
|
-
};
|
|
86
|
-
/**
|
|
87
|
-
* `@roamhq/wrtc`'s `RTCAudioSource` plays each 10 ms slice through libwebrtc's
|
|
88
|
-
* own clock and exposes no queue, so this wrapper estimates it from wall time:
|
|
89
|
-
* queued = accepted since the run started minus the time elapsed. Best effort;
|
|
90
|
-
* the werift engine (the default) reports its real queue.
|
|
91
|
-
*/
|
|
92
|
-
class WrtcAudioSource {
|
|
93
|
-
#inner;
|
|
94
|
-
#runStartedAt = 0;
|
|
95
|
-
#runAcceptedMs = 0;
|
|
96
|
-
constructor(inner) {
|
|
97
|
-
this.#inner = inner;
|
|
98
|
-
}
|
|
99
|
-
createTrack() {
|
|
100
|
-
return this.#inner.createTrack();
|
|
101
|
-
}
|
|
102
|
-
onData(data) {
|
|
103
|
-
const now = Date.now();
|
|
104
|
-
if (this.queuedMs() === 0) {
|
|
105
|
-
this.#runStartedAt = now;
|
|
106
|
-
this.#runAcceptedMs = 0;
|
|
107
|
-
}
|
|
108
|
-
this.#runAcceptedMs += (data.numberOfFrames / data.sampleRate) * 1_000;
|
|
109
|
-
this.#inner.onData(data);
|
|
110
|
-
}
|
|
111
|
-
queuedMs() {
|
|
112
|
-
if (this.#runAcceptedMs === 0)
|
|
113
|
-
return 0;
|
|
114
|
-
return Math.max(0, this.#runAcceptedMs - (Date.now() - this.#runStartedAt));
|
|
115
|
-
}
|
|
116
|
-
async waitForDrain() {
|
|
117
|
-
const remaining = this.queuedMs();
|
|
118
|
-
if (remaining > 0)
|
|
119
|
-
await delay(remaining);
|
|
120
|
-
}
|
|
121
|
-
clear() {
|
|
122
|
-
// libwebrtc keeps slices already handed over; only the estimate can be reset.
|
|
123
|
-
this.#runAcceptedMs = 0;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const delay = (milliseconds) => new Promise((resolve) => {
|
|
127
|
-
const timer = setTimeout(resolve, milliseconds);
|
|
128
|
-
timer.unref?.();
|
|
129
|
-
});
|
|
130
|
-
/**
|
|
131
|
-
* Provider-neutral Node WebRTC bridge for Relay Call rooms.
|
|
132
|
-
*
|
|
133
|
-
* The class owns Relay media negotiation internally. Higher-level adapters only
|
|
134
|
-
* exchange PCM16 frames and call lifecycle events.
|
|
135
|
-
*/
|
|
136
|
-
export class RelayCallTransport {
|
|
137
|
-
#room;
|
|
138
|
-
#providedFactory;
|
|
139
|
-
#engine;
|
|
140
|
-
#iceGatheringTimeoutMs;
|
|
141
|
-
#connectionTimeoutMs;
|
|
142
|
-
#peerConfig;
|
|
143
|
-
#onWarning;
|
|
144
|
-
#connectStartedAt = 0;
|
|
145
|
-
#inboundFrames = 0;
|
|
146
|
-
#outboundFrames = 0;
|
|
147
|
-
#roomStates = 0;
|
|
148
|
-
#roomOffers = 0;
|
|
149
|
-
#endedReason;
|
|
150
|
-
#roomErrors = [];
|
|
151
|
-
/** Engine stats frozen when media shuts down, so diagnostics survive the call's end. */
|
|
152
|
-
#finalSinkStats;
|
|
153
|
-
#finalSourceStats;
|
|
154
|
-
#stallTimer;
|
|
155
|
-
#stallSince;
|
|
156
|
-
#stallWarned = false;
|
|
157
|
-
#iceLocal = { host: 0, srflx: 0, relay: 0, other: 0 };
|
|
158
|
-
#iceRemote = [];
|
|
159
|
-
#iceTransitions = [];
|
|
160
|
-
#listeners = new Map();
|
|
161
|
-
#factory;
|
|
162
|
-
#peer;
|
|
163
|
-
#audioSource;
|
|
164
|
-
#localTrack;
|
|
165
|
-
#remoteSink;
|
|
166
|
-
#publishTransceiver;
|
|
167
|
-
#publishFrame;
|
|
168
|
-
#initialAnswerSdp;
|
|
169
|
-
#negotiationTail = Promise.resolve();
|
|
170
|
-
#audioGeneration = 0;
|
|
171
|
-
/** `waitForPlayout()` callers released early by `clearAudio()` or `close()`. */
|
|
172
|
-
#playoutWaiters = new Set();
|
|
173
|
-
#reportedConnected = false;
|
|
174
|
-
#readySettled = false;
|
|
175
|
-
#readyResolve;
|
|
176
|
-
#readyReject;
|
|
177
|
-
#ready;
|
|
178
|
-
#handlersAttached = false;
|
|
179
|
-
#closed = false;
|
|
180
|
-
constructor(options) {
|
|
181
|
-
if (!options.callId.trim())
|
|
182
|
-
throw new Error("callId is required.");
|
|
183
|
-
this.#room = options.roomClient ?? options.relay.calls.room(options.callId, options.room);
|
|
184
|
-
this.#providedFactory = options.webRTC;
|
|
185
|
-
this.#engine = options.engine ?? "werift";
|
|
186
|
-
this.#onWarning = options.onWarning ?? (() => undefined);
|
|
187
|
-
if (this.#engine !== "werift" && this.#engine !== "wrtc") {
|
|
188
|
-
throw new Error('engine must be "werift" or "wrtc".');
|
|
189
|
-
}
|
|
190
|
-
this.#iceGatheringTimeoutMs = options.iceGatheringTimeoutMs ?? DEFAULT_ICE_GATHERING_TIMEOUT_MS;
|
|
191
|
-
this.#connectionTimeoutMs = options.mediaConnectTimeoutMs
|
|
192
|
-
?? options.connectionTimeoutMs
|
|
193
|
-
?? DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
194
|
-
const policy = options.iceTransportPolicy ?? "all";
|
|
195
|
-
if (policy !== "all" && policy !== "relay") {
|
|
196
|
-
throw new Error('iceTransportPolicy must be "all" or "relay".');
|
|
197
|
-
}
|
|
198
|
-
this.#peerConfig = {
|
|
199
|
-
iceServers: (options.iceServers ?? []).map((server) => ({
|
|
200
|
-
urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
|
|
201
|
-
...(server.username === undefined ? {} : { username: server.username }),
|
|
202
|
-
...(server.credential === undefined ? {} : { credential: server.credential }),
|
|
203
|
-
})),
|
|
204
|
-
iceTransportPolicy: policy,
|
|
205
|
-
};
|
|
206
|
-
if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
|
|
207
|
-
throw new Error("iceGatheringTimeoutMs must be greater than zero.");
|
|
208
|
-
}
|
|
209
|
-
if (!Number.isFinite(this.#connectionTimeoutMs) || this.#connectionTimeoutMs <= 0) {
|
|
210
|
-
throw new Error("mediaConnectTimeoutMs must be greater than zero.");
|
|
211
|
-
}
|
|
212
|
-
this.#ready = new Promise((resolve, reject) => {
|
|
213
|
-
this.#readyResolve = resolve;
|
|
214
|
-
this.#readyReject = reject;
|
|
215
|
-
});
|
|
216
|
-
void this.#ready.catch(() => undefined);
|
|
217
|
-
}
|
|
218
|
-
on(event, listener) {
|
|
219
|
-
let listeners = this.#listeners.get(event);
|
|
220
|
-
if (!listeners) {
|
|
221
|
-
listeners = new Set();
|
|
222
|
-
this.#listeners.set(event, listeners);
|
|
223
|
-
}
|
|
224
|
-
listeners.add(listener);
|
|
225
|
-
return this;
|
|
226
|
-
}
|
|
227
|
-
off(event, listener) {
|
|
228
|
-
this.#listeners.get(event)?.delete(listener);
|
|
229
|
-
return this;
|
|
230
|
-
}
|
|
231
|
-
async connect() {
|
|
232
|
-
if (this.#closed)
|
|
233
|
-
throw new Error("Relay Call transport is closed.");
|
|
234
|
-
this.#attachRoomHandlers();
|
|
235
|
-
await this.#room.connect();
|
|
236
|
-
if (this.#peer) {
|
|
237
|
-
await this.#waitForConnection();
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
this.#factory = this.#providedFactory ?? await loadWebRTCFactory(this.#engine);
|
|
241
|
-
this.#audioSource = this.#factory.createAudioSource();
|
|
242
|
-
this.#localTrack = this.#audioSource.createTrack();
|
|
243
|
-
if (this.#localTrack.kind !== "audio") {
|
|
244
|
-
throw new RelayCallTransportError("The WebRTC binding created a non-audio Relay track.");
|
|
245
|
-
}
|
|
246
|
-
this.#connectStartedAt = Date.now();
|
|
247
|
-
const peer = this.#factory.createPeerConnection(this.#peerConfig);
|
|
248
|
-
this.#peer = peer;
|
|
249
|
-
this.#publishTransceiver = peer.addTransceiver(this.#localTrack, { direction: "sendonly" });
|
|
250
|
-
this.#observeIce(peer);
|
|
251
|
-
peer.onconnectionstatechange = () => {
|
|
252
|
-
this.#recordTransition("connection", peer.connectionState);
|
|
253
|
-
this.#connectionStateChanged();
|
|
254
|
-
};
|
|
255
|
-
peer.ontrack = (event) => this.#remoteTrack(event.track);
|
|
256
|
-
try {
|
|
257
|
-
await this.#publishLocalAudio();
|
|
258
|
-
await this.#waitForConnection();
|
|
259
|
-
}
|
|
260
|
-
catch (error) {
|
|
261
|
-
this.close();
|
|
262
|
-
throw error;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
/** Replace only the room signaling socket and replay the identical publication. */
|
|
266
|
-
async reconnect() {
|
|
267
|
-
if (this.#closed)
|
|
268
|
-
throw new Error("Relay Call transport is closed.");
|
|
269
|
-
if (!this.#publishFrame)
|
|
270
|
-
throw new Error("Relay Call transport is not connected.");
|
|
271
|
-
await this.#room.reconnect();
|
|
272
|
-
this.#room.send(this.#publishFrame);
|
|
273
|
-
}
|
|
274
|
-
/**
|
|
275
|
-
* ICE candidates, state transitions, packet counts in both directions and
|
|
276
|
-
* room frame counts recorded for this call, with a one-line summary.
|
|
277
|
-
*/
|
|
278
|
-
diagnostics() {
|
|
279
|
-
const snapshot = {
|
|
280
|
-
local: { ...this.#iceLocal },
|
|
281
|
-
remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
|
|
282
|
-
transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
|
|
283
|
-
connected: this.#reportedConnected,
|
|
284
|
-
inbound: this.#inboundDiagnostics(),
|
|
285
|
-
outbound: this.#outboundDiagnostics(),
|
|
286
|
-
room: {
|
|
287
|
-
roomStates: this.#roomStates,
|
|
288
|
-
offers: this.#roomOffers,
|
|
289
|
-
endedReason: this.#endedReason,
|
|
290
|
-
errors: [...this.#roomErrors],
|
|
291
|
-
},
|
|
292
|
-
};
|
|
293
|
-
return { ...snapshot, summary: summarize(snapshot) };
|
|
294
|
-
}
|
|
295
|
-
#sinceConnect(epochMs) {
|
|
296
|
-
return epochMs === undefined ? undefined : epochMs - this.#connectStartedAt;
|
|
297
|
-
}
|
|
298
|
-
#inboundDiagnostics() {
|
|
299
|
-
const stats = this.#remoteSink?.stats?.() ?? this.#finalSinkStats;
|
|
300
|
-
return {
|
|
301
|
-
rtpPackets: stats?.rtpPackets ?? 0,
|
|
302
|
-
decodeFailures: stats?.decodeFailures ?? 0,
|
|
303
|
-
frames: this.#inboundFrames,
|
|
304
|
-
firstPacketAtMs: this.#sinceConnect(stats?.firstRtpAt),
|
|
305
|
-
lastPacketAtMs: this.#sinceConnect(stats?.lastRtpAt),
|
|
306
|
-
recentRtpPackets: stats?.recentRtpPackets ?? 0,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
#outboundDiagnostics() {
|
|
310
|
-
const stats = this.#audioSource?.stats?.() ?? this.#finalSourceStats;
|
|
311
|
-
return {
|
|
312
|
-
frames: this.#outboundFrames,
|
|
313
|
-
opusPackets: stats?.opusPackets ?? 0,
|
|
314
|
-
rtpPackets: stats?.rtpPackets ?? 0,
|
|
315
|
-
firstPacketAtMs: this.#sinceConnect(stats?.firstRtpAt),
|
|
316
|
-
lastPacketAtMs: this.#sinceConnect(stats?.lastRtpAt),
|
|
317
|
-
recentRtpPackets: stats?.recentRtpPackets ?? 0,
|
|
318
|
-
queued: stats?.queued ?? 0,
|
|
319
|
-
pacerAlive: stats?.pacerAlive,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Outbound stall guard: audio is queued for the pacer but no RTP packet has
|
|
324
|
-
* left for 2 s while connected. Warns once with the summary; restarts nothing.
|
|
325
|
-
*/
|
|
326
|
-
#checkStall() {
|
|
327
|
-
if (this.#stallWarned || !this.#reportedConnected)
|
|
328
|
-
return;
|
|
329
|
-
const stats = this.#audioSource?.stats?.();
|
|
330
|
-
if (!stats || stats.queued === 0) {
|
|
331
|
-
this.#stallSince = undefined;
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
const now = Date.now();
|
|
335
|
-
const idleSince = stats.lastRtpAt ?? (this.#stallSince ??= now);
|
|
336
|
-
if (now - idleSince < STALL_AFTER_MS)
|
|
337
|
-
return;
|
|
338
|
-
this.#stallWarned = true;
|
|
339
|
-
this.#stopStallGuard();
|
|
340
|
-
this.#onWarning(`Relay outbound audio stalled (${this.diagnostics().summary})`);
|
|
341
|
-
}
|
|
342
|
-
#startStallGuard() {
|
|
343
|
-
if (this.#stallTimer)
|
|
344
|
-
return;
|
|
345
|
-
this.#stallTimer = setInterval(() => this.#checkStall(), STALL_CHECK_MS);
|
|
346
|
-
this.#stallTimer.unref?.();
|
|
347
|
-
}
|
|
348
|
-
#stopStallGuard() {
|
|
349
|
-
if (!this.#stallTimer)
|
|
350
|
-
return;
|
|
351
|
-
clearInterval(this.#stallTimer);
|
|
352
|
-
this.#stallTimer = undefined;
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Feed interleaved PCM16 audio into Relay. Frames are split into 10 ms WebRTC
|
|
356
|
-
* source slices and handed to the engine at once; the engine's 20 ms pump
|
|
357
|
-
* paces the wire, so adapters may push faster than real time (LiveKit's
|
|
358
|
-
* `AudioSource.captureFrame` shape). Resolves once the slices are queued;
|
|
359
|
-
* `waitForPlayout()` tells when they have left.
|
|
360
|
-
*/
|
|
361
|
-
writeAudio(frame) {
|
|
362
|
-
if (this.#closed)
|
|
363
|
-
return Promise.reject(new Error("Relay Call transport is closed."));
|
|
364
|
-
if (!this.#audioSource)
|
|
365
|
-
return Promise.reject(new Error("Relay Call transport is not connected."));
|
|
366
|
-
if (!Number.isInteger(frame.sampleRate) || frame.sampleRate <= 0 || frame.sampleRate % 100 !== 0) {
|
|
367
|
-
return Promise.reject(new Error("Relay audio sampleRate must be a positive multiple of 100."));
|
|
368
|
-
}
|
|
369
|
-
if (!Number.isInteger(frame.channelCount) || frame.channelCount <= 0) {
|
|
370
|
-
return Promise.reject(new Error("Relay audio channelCount must be a positive integer."));
|
|
371
|
-
}
|
|
372
|
-
if (frame.samples.length % frame.channelCount !== 0) {
|
|
373
|
-
return Promise.reject(new Error("Relay audio samples must contain complete interleaved frames."));
|
|
374
|
-
}
|
|
375
|
-
const captured = {
|
|
376
|
-
samples: cloneSamples(frame.samples),
|
|
377
|
-
sampleRate: frame.sampleRate,
|
|
378
|
-
channelCount: frame.channelCount,
|
|
379
|
-
};
|
|
380
|
-
this.#writeAudio(captured, this.#audioGeneration);
|
|
381
|
-
return Promise.resolve();
|
|
382
|
-
}
|
|
383
|
-
/** Milliseconds of audio accepted by `writeAudio` but not yet written to RTP. */
|
|
384
|
-
queuedAudioMs() {
|
|
385
|
-
return this.#audioSource?.queuedMs?.() ?? 0;
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* Resolves when every accepted slice has been written to RTP and the engine's
|
|
389
|
-
* pump is idle; immediately when nothing is queued; early on `clearAudio()`
|
|
390
|
-
* or `close()` (the caller reads `queuedAudioMs()` to learn what was dropped).
|
|
391
|
-
*/
|
|
392
|
-
waitForPlayout() {
|
|
393
|
-
const source = this.#audioSource;
|
|
394
|
-
if (!source || this.#closed || this.queuedAudioMs() === 0)
|
|
395
|
-
return Promise.resolve();
|
|
396
|
-
return new Promise((resolve) => {
|
|
397
|
-
const release = () => {
|
|
398
|
-
this.#playoutWaiters.delete(release);
|
|
399
|
-
resolve();
|
|
400
|
-
};
|
|
401
|
-
this.#playoutWaiters.add(release);
|
|
402
|
-
const drained = source.waitForDrain?.() ?? Promise.resolve();
|
|
403
|
-
drained.then(release, release);
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
/** Drop outgoing PCM that has not reached RTP and release `waitForPlayout()` callers. */
|
|
407
|
-
clearAudio() {
|
|
408
|
-
this.#audioGeneration += 1;
|
|
409
|
-
this.#audioSource?.clear?.();
|
|
410
|
-
this.#releasePlayoutWaiters();
|
|
411
|
-
}
|
|
412
|
-
setMuted(muted) {
|
|
413
|
-
this.#room.userUpdate({ muted });
|
|
414
|
-
}
|
|
415
|
-
end() {
|
|
416
|
-
this.#room.end();
|
|
417
|
-
}
|
|
418
|
-
close() {
|
|
419
|
-
if (this.#closed)
|
|
420
|
-
return;
|
|
421
|
-
this.#closed = true;
|
|
422
|
-
this.#rejectReady(new RelayCallTransportError("Relay Call transport closed before media connected."));
|
|
423
|
-
this.#audioGeneration += 1;
|
|
424
|
-
this.#shutdownMedia();
|
|
425
|
-
this.#releasePlayoutWaiters();
|
|
426
|
-
this.#room.close();
|
|
427
|
-
}
|
|
428
|
-
async #publishLocalAudio() {
|
|
429
|
-
const peer = this.#requirePeer();
|
|
430
|
-
const offer = await peer.createOffer();
|
|
431
|
-
await peer.setLocalDescription(offer);
|
|
432
|
-
await this.#waitForIceGathering(peer);
|
|
433
|
-
const description = this.#localDescription("offer");
|
|
434
|
-
const mid = this.#publishTransceiver?.mid;
|
|
435
|
-
if (!mid)
|
|
436
|
-
throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
|
|
437
|
-
this.#publishFrame = {
|
|
438
|
-
type: "offer",
|
|
439
|
-
session_description: description,
|
|
440
|
-
tracks: [{ mid, name: "audio" }],
|
|
441
|
-
};
|
|
442
|
-
this.#room.send(this.#publishFrame);
|
|
443
|
-
}
|
|
444
|
-
#attachRoomHandlers() {
|
|
445
|
-
if (this.#handlersAttached)
|
|
446
|
-
return;
|
|
447
|
-
this.#handlersAttached = true;
|
|
448
|
-
this.#room.on("answer", (frame) => {
|
|
449
|
-
this.#queueNegotiation(() => this.#serverAnswer(frame));
|
|
450
|
-
});
|
|
451
|
-
this.#room.on("offer", (frame) => {
|
|
452
|
-
this.#roomOffers += 1;
|
|
453
|
-
this.#queueNegotiation(() => this.#serverOffer(frame));
|
|
454
|
-
});
|
|
455
|
-
this.#room.on("roomState", (frame) => {
|
|
456
|
-
this.#roomStates += 1;
|
|
457
|
-
this.#emit("roomState", frame);
|
|
458
|
-
});
|
|
459
|
-
this.#room.on("error", (error) => {
|
|
460
|
-
if (error instanceof Error) {
|
|
461
|
-
this.#rejectReady(error);
|
|
462
|
-
this.#emit("error", error);
|
|
463
|
-
}
|
|
464
|
-
else
|
|
465
|
-
this.#serverError(error);
|
|
466
|
-
});
|
|
467
|
-
this.#room.on("ended", (frame) => {
|
|
468
|
-
this.#endedReason = frame.reason;
|
|
469
|
-
this.#rejectReady(new RelayCallTransportError(`Relay Call ended before media connected (${frame.reason}).`));
|
|
470
|
-
this.clearAudio();
|
|
471
|
-
this.#shutdownMedia();
|
|
472
|
-
this.#emit("ended", frame);
|
|
473
|
-
});
|
|
474
|
-
this.#room.on("close", (event) => {
|
|
475
|
-
if (!this.#reportedConnected) {
|
|
476
|
-
this.#rejectReady(new RelayCallTransportError(`Relay Call room closed before media connected (${event.code}).`));
|
|
477
|
-
}
|
|
478
|
-
this.#emit("close", event);
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
#queueNegotiation(work) {
|
|
482
|
-
const run = this.#negotiationTail.then(work);
|
|
483
|
-
this.#negotiationTail = run.catch((error) => {
|
|
484
|
-
const parsed = error instanceof Error ? error : new Error(String(error));
|
|
485
|
-
this.#rejectReady(parsed);
|
|
486
|
-
this.#emit("error", parsed);
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
async #serverAnswer(frame) {
|
|
490
|
-
const peer = this.#requirePeer();
|
|
491
|
-
const sdp = frame.session_description.sdp;
|
|
492
|
-
// Reconnecting the signaling socket replays the exact initial offer. Relay
|
|
493
|
-
// returns its cached answer; applying that answer again in stable state is
|
|
494
|
-
// invalid WebRTC signaling, so recognize and ignore the replay.
|
|
495
|
-
if (peer.signalingState === "stable" && this.#initialAnswerSdp === sdp)
|
|
496
|
-
return;
|
|
497
|
-
if (this.#initialAnswerSdp === undefined)
|
|
498
|
-
this.#recordRemoteCandidates(sdp);
|
|
499
|
-
await peer.setRemoteDescription(frame.session_description);
|
|
500
|
-
this.#initialAnswerSdp ??= sdp;
|
|
501
|
-
}
|
|
502
|
-
async #serverOffer(frame) {
|
|
503
|
-
const peer = this.#requirePeer();
|
|
504
|
-
await peer.setRemoteDescription(frame.session_description);
|
|
505
|
-
const answer = await peer.createAnswer();
|
|
506
|
-
await peer.setLocalDescription(answer);
|
|
507
|
-
await this.#waitForIceGathering(peer);
|
|
508
|
-
this.#room.send({ type: "answer", session_description: this.#localDescription("answer") });
|
|
509
|
-
}
|
|
510
|
-
#serverError(frame) {
|
|
511
|
-
this.#roomErrors.push(frame.message);
|
|
512
|
-
const error = new RelayCallTransportError(frame.message, frame.code);
|
|
513
|
-
this.#rejectReady(error);
|
|
514
|
-
this.#emit("error", error);
|
|
515
|
-
}
|
|
516
|
-
#connectionStateChanged() {
|
|
517
|
-
const state = this.#peer?.connectionState;
|
|
518
|
-
if (state === "connected" && !this.#reportedConnected) {
|
|
519
|
-
this.#reportedConnected = true;
|
|
520
|
-
try {
|
|
521
|
-
this.#room.connected();
|
|
522
|
-
this.#resolveReady();
|
|
523
|
-
this.#startStallGuard();
|
|
524
|
-
this.#emit("connected");
|
|
525
|
-
}
|
|
526
|
-
catch (error) {
|
|
527
|
-
const parsed = error instanceof Error ? error : new Error(String(error));
|
|
528
|
-
this.#rejectReady(parsed);
|
|
529
|
-
this.#emit("error", parsed);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
else if (state === "failed") {
|
|
533
|
-
const error = new RelayCallTransportError("Relay WebRTC connection failed.");
|
|
534
|
-
this.#rejectReady(error);
|
|
535
|
-
this.#emit("error", error);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
#remoteTrack(track) {
|
|
539
|
-
if (track.kind !== "audio" || !this.#factory)
|
|
540
|
-
return;
|
|
541
|
-
this.#remoteSink?.stop();
|
|
542
|
-
const sink = this.#factory.createAudioSink(track);
|
|
543
|
-
this.#remoteSink = sink;
|
|
544
|
-
sink.ondata = (data) => {
|
|
545
|
-
if (this.#closed || this.#remoteSink !== sink)
|
|
546
|
-
return;
|
|
547
|
-
const channelCount = data.channelCount ?? 1;
|
|
548
|
-
if (data.bitsPerSample !== undefined && data.bitsPerSample !== 16) {
|
|
549
|
-
this.#emit("error", new RelayCallTransportError(`Relay WebRTC delivered unsupported ${data.bitsPerSample}-bit audio.`));
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
this.#inboundFrames += 1;
|
|
553
|
-
this.#emit("audio", {
|
|
554
|
-
samples: cloneSamples(data.samples),
|
|
555
|
-
sampleRate: data.sampleRate,
|
|
556
|
-
channelCount,
|
|
557
|
-
});
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
#writeAudio(frame, generation) {
|
|
561
|
-
const source = this.#audioSource;
|
|
562
|
-
if (!source || generation !== this.#audioGeneration)
|
|
563
|
-
return;
|
|
564
|
-
const samplesPerChannel = (frame.sampleRate * AUDIO_SLICE_MS) / 1000;
|
|
565
|
-
const sliceSamples = samplesPerChannel * frame.channelCount;
|
|
566
|
-
for (let offset = 0; offset < frame.samples.length; offset += sliceSamples) {
|
|
567
|
-
if (this.#closed || generation !== this.#audioGeneration)
|
|
568
|
-
return;
|
|
569
|
-
const remaining = Math.min(sliceSamples, frame.samples.length - offset);
|
|
570
|
-
const samples = new Int16Array(sliceSamples);
|
|
571
|
-
samples.set(frame.samples.subarray(offset, offset + remaining));
|
|
572
|
-
source.onData({
|
|
573
|
-
samples,
|
|
574
|
-
sampleRate: frame.sampleRate,
|
|
575
|
-
bitsPerSample: 16,
|
|
576
|
-
channelCount: frame.channelCount,
|
|
577
|
-
numberOfFrames: samplesPerChannel,
|
|
578
|
-
});
|
|
579
|
-
this.#outboundFrames += 1;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
#releasePlayoutWaiters() {
|
|
583
|
-
for (const release of [...this.#playoutWaiters])
|
|
584
|
-
release();
|
|
585
|
-
}
|
|
586
|
-
async #waitForIceGathering(peer) {
|
|
587
|
-
if (peer.iceGatheringState === "complete")
|
|
588
|
-
return;
|
|
589
|
-
await new Promise((resolve, reject) => {
|
|
590
|
-
const timeout = setTimeout(() => {
|
|
591
|
-
cleanup();
|
|
592
|
-
reject(new RelayCallTransportError("Timed out gathering Relay WebRTC ICE candidates."));
|
|
593
|
-
}, this.#iceGatheringTimeoutMs);
|
|
594
|
-
timeout.unref?.();
|
|
595
|
-
const changed = () => {
|
|
596
|
-
if (peer.iceGatheringState !== "complete")
|
|
597
|
-
return;
|
|
598
|
-
cleanup();
|
|
599
|
-
resolve();
|
|
600
|
-
};
|
|
601
|
-
const cleanup = () => {
|
|
602
|
-
clearTimeout(timeout);
|
|
603
|
-
peer.removeEventListener("icegatheringstatechange", changed);
|
|
604
|
-
};
|
|
605
|
-
peer.addEventListener("icegatheringstatechange", changed);
|
|
606
|
-
changed();
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
async #waitForConnection() {
|
|
610
|
-
if (this.#reportedConnected)
|
|
611
|
-
return;
|
|
612
|
-
await new Promise((resolve, reject) => {
|
|
613
|
-
const timeout = setTimeout(() => {
|
|
614
|
-
reject(new RelayCallTransportError(`Timed out connecting Relay WebRTC media (${this.diagnostics().summary})`));
|
|
615
|
-
}, this.#connectionTimeoutMs);
|
|
616
|
-
timeout.unref?.();
|
|
617
|
-
this.#ready.then(() => {
|
|
618
|
-
clearTimeout(timeout);
|
|
619
|
-
resolve();
|
|
620
|
-
}, (error) => {
|
|
621
|
-
clearTimeout(timeout);
|
|
622
|
-
reject(error);
|
|
623
|
-
});
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
/**
|
|
627
|
-
* werift emits these as W3C-style handler calls (peerConnection.js:314-341:
|
|
628
|
-
* `onicegatheringstatechange`, `oniceconnectionstatechange`,
|
|
629
|
-
* `onconnectionstatechange`, `onicecandidate` with `{ candidate }`).
|
|
630
|
-
*/
|
|
631
|
-
#observeIce(peer) {
|
|
632
|
-
peer.onicecandidate = (event) => {
|
|
633
|
-
const line = event?.candidate?.candidate;
|
|
634
|
-
if (!line)
|
|
635
|
-
return;
|
|
636
|
-
const type = parseCandidate(line)?.type;
|
|
637
|
-
if (type === "host" || type === "srflx" || type === "relay")
|
|
638
|
-
this.#iceLocal[type] += 1;
|
|
639
|
-
else
|
|
640
|
-
this.#iceLocal.other += 1;
|
|
641
|
-
};
|
|
642
|
-
peer.onicegatheringstatechange = () => this.#recordTransition("gathering", peer.iceGatheringState);
|
|
643
|
-
peer.oniceconnectionstatechange = () => {
|
|
644
|
-
if (peer.iceConnectionState !== undefined)
|
|
645
|
-
this.#recordTransition("ice", peer.iceConnectionState);
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
#recordTransition(kind, state) {
|
|
649
|
-
this.#iceTransitions.push({ kind, state, atMs: Date.now() - this.#connectStartedAt });
|
|
650
|
-
}
|
|
651
|
-
#recordRemoteCandidates(sdp) {
|
|
652
|
-
for (const line of sdp.split(/\r?\n/)) {
|
|
653
|
-
if (!line.startsWith("a=candidate:"))
|
|
654
|
-
continue;
|
|
655
|
-
const parsed = parseCandidate(line);
|
|
656
|
-
if (parsed)
|
|
657
|
-
this.#iceRemote.push({ transport: parsed.transport, port: parsed.port });
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
#resolveReady() {
|
|
661
|
-
if (this.#readySettled)
|
|
662
|
-
return;
|
|
663
|
-
this.#readySettled = true;
|
|
664
|
-
this.#readyResolve?.();
|
|
665
|
-
}
|
|
666
|
-
#rejectReady(error) {
|
|
667
|
-
if (this.#readySettled)
|
|
668
|
-
return;
|
|
669
|
-
this.#readySettled = true;
|
|
670
|
-
this.#readyReject?.(error);
|
|
671
|
-
}
|
|
672
|
-
#localDescription(type) {
|
|
673
|
-
const local = this.#requirePeer().localDescription;
|
|
674
|
-
if (!local || local.type !== type || !local.sdp) {
|
|
675
|
-
throw new RelayCallTransportError(`Relay WebRTC did not produce a complete ${type} SDP.`);
|
|
676
|
-
}
|
|
677
|
-
return { type, sdp: local.sdp };
|
|
678
|
-
}
|
|
679
|
-
#requirePeer() {
|
|
680
|
-
if (!this.#peer)
|
|
681
|
-
throw new Error("Relay Call transport is not connected.");
|
|
682
|
-
return this.#peer;
|
|
683
|
-
}
|
|
684
|
-
#shutdownMedia() {
|
|
685
|
-
this.#stopStallGuard();
|
|
686
|
-
this.#finalSinkStats ??= this.#remoteSink?.stats?.();
|
|
687
|
-
this.#finalSourceStats ??= this.#audioSource?.stats?.();
|
|
688
|
-
this.#remoteSink?.stop();
|
|
689
|
-
this.#remoteSink = undefined;
|
|
690
|
-
this.#localTrack?.stop();
|
|
691
|
-
this.#localTrack = undefined;
|
|
692
|
-
this.#peer?.close();
|
|
693
|
-
this.#peer = undefined;
|
|
694
|
-
this.#audioSource = undefined;
|
|
695
|
-
}
|
|
696
|
-
#emit(event, ...args) {
|
|
697
|
-
for (const listener of this.#listeners.get(event) ?? []) {
|
|
698
|
-
try {
|
|
699
|
-
listener(...args);
|
|
700
|
-
}
|
|
701
|
-
catch { /* adapters own listener failures */ }
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
//# sourceMappingURL=transport.js.map
|