@furious.luke/argus-js 0.3.1 → 0.5.0
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 +141 -25
- package/dist/index.cjs +1455 -176
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +337 -24
- package/dist/index.d.ts +337 -24
- package/dist/index.js +1454 -176
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.cjs
CHANGED
|
@@ -22,6 +22,7 @@ var src_exports = {};
|
|
|
22
22
|
__export(src_exports, {
|
|
23
23
|
Publisher: () => Publisher,
|
|
24
24
|
captureCamera: () => captureCamera,
|
|
25
|
+
captureMicrophone: () => captureMicrophone,
|
|
25
26
|
captureScreen: () => captureScreen
|
|
26
27
|
});
|
|
27
28
|
module.exports = __toCommonJS(src_exports);
|
|
@@ -53,11 +54,11 @@ var SignalingChannel = class _SignalingChannel {
|
|
|
53
54
|
ws.onclose = () => ch.onClose?.();
|
|
54
55
|
return ch;
|
|
55
56
|
}
|
|
56
|
-
/** Sends a JSON message
|
|
57
|
+
/** Sends a JSON message when the socket is open and reports whether it was sent. */
|
|
57
58
|
send(msg) {
|
|
58
|
-
if (this.ws.readyState
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
if (this.ws.readyState !== WebSocket.OPEN) return false;
|
|
60
|
+
this.ws.send(JSON.stringify(msg));
|
|
61
|
+
return true;
|
|
61
62
|
}
|
|
62
63
|
/** Closes the underlying WebSocket. */
|
|
63
64
|
close() {
|
|
@@ -73,30 +74,132 @@ function parseSignal(data) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
// src/publisher.ts
|
|
77
|
+
function selectGatewayTURNURLs(advertised, policy = "all") {
|
|
78
|
+
if (policy === "all") return advertised;
|
|
79
|
+
const selected = advertised.filter((raw) => {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(raw);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const transport = (parsed.searchParams.get("transport") ?? "").toLowerCase();
|
|
87
|
+
if (policy === "tls") {
|
|
88
|
+
return parsed.protocol.toLowerCase() === "turns:" && (transport === "" || transport === "tcp");
|
|
89
|
+
}
|
|
90
|
+
return parsed.protocol.toLowerCase() === "turn:" && (transport === "" || transport === "udp");
|
|
91
|
+
});
|
|
92
|
+
if (selected.length === 0) {
|
|
93
|
+
throw new Error(`gateway advertised no TURN URLs for required ${policy} transport`);
|
|
94
|
+
}
|
|
95
|
+
return selected;
|
|
96
|
+
}
|
|
76
97
|
var defaultSignalingReconnectTimeoutMs = 2e4;
|
|
98
|
+
var defaultGatewayHandshakeTimeoutMs = 2e4;
|
|
99
|
+
var defaultPeerConnectionTimeoutMs = 3e4;
|
|
100
|
+
var initialGatewayAttemptTimeoutMs = 3e3;
|
|
77
101
|
var signalingResumeAttemptTimeoutMs = 3e3;
|
|
78
102
|
var signalingResumeMaxBackoffMs = 3e3;
|
|
79
103
|
var senderRestartPauseMs = 100;
|
|
80
104
|
var senderRecoveryWaitMs = 4e3;
|
|
81
105
|
var iceRecoveryWaitMs = 8e3;
|
|
106
|
+
var minimumNegotiationAnswerTimeoutMs = 15e3;
|
|
107
|
+
var negotiationReconnectGraceMs = 5e3;
|
|
108
|
+
var minimumIntentionalTrackEndRetentionMs = 35e3;
|
|
109
|
+
var maxUserTextBytes = 4 * 1024;
|
|
110
|
+
var maxRetainedICECandidates = 64;
|
|
111
|
+
var ReportedPublisherError = class extends Error {
|
|
112
|
+
constructor(message, fatal = false) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.fatal = fatal;
|
|
115
|
+
}
|
|
116
|
+
fatal;
|
|
117
|
+
};
|
|
118
|
+
var NegotiationTimeoutError = class extends Error {
|
|
119
|
+
};
|
|
120
|
+
var SenderRestoreError = class extends Error {
|
|
121
|
+
};
|
|
122
|
+
var PublisherStoppedError = class extends Error {
|
|
123
|
+
};
|
|
82
124
|
var Publisher = class {
|
|
83
125
|
opts;
|
|
84
126
|
sig = null;
|
|
85
127
|
pc = null;
|
|
86
128
|
hasAnswer = false;
|
|
87
129
|
pendingRemoteCandidates = [];
|
|
88
|
-
|
|
130
|
+
// Setting a local description starts ICE gathering. Candidates may therefore
|
|
131
|
+
// arrive before the corresponding offer has crossed the signaling socket.
|
|
132
|
+
// Hold them until sendOffer confirms that the offer was sent, then trickle
|
|
133
|
+
// them in order. This keeps startup fast without allowing candidate/offer
|
|
134
|
+
// reordering at the media server.
|
|
135
|
+
pendingLocalCandidates = [];
|
|
136
|
+
localCandidateOfferSent = false;
|
|
137
|
+
// WebSocket.send() only proves local queueing. Retain the current ICE
|
|
138
|
+
// generation so a replacement signaling socket can replay candidates whose
|
|
139
|
+
// delivery on the old socket was ambiguous.
|
|
140
|
+
retainedLocalCandidates = [];
|
|
141
|
+
retainedLocalCandidateKeys = /* @__PURE__ */ new Set();
|
|
142
|
+
localCandidateGeneration = null;
|
|
143
|
+
// Both peers replay after reconnect. Receiving the same candidate must be
|
|
144
|
+
// idempotent, whether it is still buffered behind an answer or already
|
|
145
|
+
// applied to the peer connection.
|
|
146
|
+
remoteCandidateKeys = /* @__PURE__ */ new Set();
|
|
147
|
+
remoteCandidateOrder = [];
|
|
148
|
+
remoteCandidateGeneration = null;
|
|
89
149
|
readToken = null;
|
|
90
|
-
|
|
150
|
+
gatewayURL = null;
|
|
151
|
+
lastReportedICEPath = null;
|
|
152
|
+
watchedICETransports = /* @__PURE__ */ new WeakSet();
|
|
91
153
|
stopped = true;
|
|
154
|
+
// Every start/stop boundary advances lifecycleGeneration. Async work captures
|
|
155
|
+
// the generation it belongs to and may never mutate or terminate a later run.
|
|
156
|
+
lifecycleGeneration = 0;
|
|
157
|
+
runAbort = null;
|
|
158
|
+
peerConnectionTimer = null;
|
|
92
159
|
reconnecting = false;
|
|
93
160
|
reconnectGeneration = 0;
|
|
94
161
|
resumeSocket = null;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
162
|
+
signalingWaiters = /* @__PURE__ */ new Set();
|
|
163
|
+
pendingOffer = null;
|
|
164
|
+
recoverySequence = 0;
|
|
165
|
+
recoveryStates = /* @__PURE__ */ new Map();
|
|
166
|
+
// ICE restart applies to the whole peer connection. Keep one attempt shared
|
|
167
|
+
// by every track currently recovering so simultaneous camera/screen stalls do
|
|
168
|
+
// not create duplicate ICE offers.
|
|
169
|
+
iceRestartSequence = 0;
|
|
170
|
+
iceRestartAttempt = null;
|
|
99
171
|
trackEndHandlers = /* @__PURE__ */ new Map();
|
|
172
|
+
// published is the source of truth for the live video tracks and their logical
|
|
173
|
+
// types. It drives the track labels sent on every offer, per-track recovery
|
|
174
|
+
// reporting, and add/remove of individual tracks. At most one track per type is
|
|
175
|
+
// kept (publishing a second track of a type replaces the first).
|
|
176
|
+
published = /* @__PURE__ */ new Map();
|
|
177
|
+
publishedStreams = /* @__PURE__ */ new Map();
|
|
178
|
+
// Own the active sender for each logical type. Active source replacement uses
|
|
179
|
+
// replaceTrack on that sender. Unpublish removes the mapping because addTrack
|
|
180
|
+
// may later reuse any compatible inactive transceiver, not necessarily the one
|
|
181
|
+
// that previously carried the same logical type.
|
|
182
|
+
typeSenders = /* @__PURE__ */ new Map();
|
|
183
|
+
// intentionalTrackEnds holds physical track ids removed by publish/unpublish
|
|
184
|
+
// whose server-side `media_track_ended` has not yet arrived. Correlating by id
|
|
185
|
+
// keeps a delayed end for an old screen track from being mistaken for failure
|
|
186
|
+
// of a newly-published screen track.
|
|
187
|
+
intentionalTrackEnds = /* @__PURE__ */ new Map();
|
|
188
|
+
// negotiationChain serializes every offer/answer exchange, including recovery.
|
|
189
|
+
// User operations do not resolve until their answer is applied, so no caller
|
|
190
|
+
// or recovery timer can create a second offer while one is outstanding.
|
|
191
|
+
negotiationChain = Promise.resolve();
|
|
192
|
+
// pendingAnswer resolves the one in-flight negotiation once its matching answer
|
|
193
|
+
// arrives, or rejects it on timeout/teardown. All offers, including recovery,
|
|
194
|
+
// pass through negotiationChain, so this slot is never intentionally replaced.
|
|
195
|
+
pendingAnswer = null;
|
|
196
|
+
// negotiationSeq stamps each offer with a monotonically increasing id.
|
|
197
|
+
negotiationSeq = 0;
|
|
198
|
+
textChannel = null;
|
|
199
|
+
speechEnabled = false;
|
|
200
|
+
speechPending = false;
|
|
201
|
+
speechTransceiver = null;
|
|
202
|
+
microphoneTransceiver = null;
|
|
100
203
|
constructor(opts) {
|
|
101
204
|
this.opts = opts;
|
|
102
205
|
}
|
|
@@ -104,96 +207,336 @@ var Publisher = class {
|
|
|
104
207
|
get frameReadToken() {
|
|
105
208
|
return this.readToken;
|
|
106
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The signaling gateway URL that won the initial race, or null before start.
|
|
212
|
+
* Relay this with frameReadToken so the application server can reach the same
|
|
213
|
+
* region for frame reads and change-notification subscriptions.
|
|
214
|
+
*/
|
|
215
|
+
get selectedGatewayURL() {
|
|
216
|
+
return this.gatewayURL;
|
|
217
|
+
}
|
|
218
|
+
/** Requests the persistent outbound `speech` track. This is explicit user
|
|
219
|
+
* opt-in and renegotiates only once; the track remains silent between turns. */
|
|
220
|
+
async enableSpeech() {
|
|
221
|
+
if (this.speechEnabled) return;
|
|
222
|
+
await this.enqueueNegotiation(() => {
|
|
223
|
+
const pc = this.pc;
|
|
224
|
+
if (!pc || this.speechEnabled || this.speechPending) return false;
|
|
225
|
+
if (!this.speechTransceiver) {
|
|
226
|
+
this.speechTransceiver = pc.addTransceiver("audio", { direction: "recvonly" });
|
|
227
|
+
} else {
|
|
228
|
+
this.speechTransceiver.direction = "recvonly";
|
|
229
|
+
}
|
|
230
|
+
this.speechPending = true;
|
|
231
|
+
return {
|
|
232
|
+
commit: () => {
|
|
233
|
+
this.speechPending = false;
|
|
234
|
+
this.speechEnabled = true;
|
|
235
|
+
},
|
|
236
|
+
rollback: () => {
|
|
237
|
+
this.speechPending = false;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** Sends typed input over the reliable ordered Argus text channel. */
|
|
243
|
+
sendUserText(messageId, text) {
|
|
244
|
+
if (!messageId || !text.trim()) throw new Error("messageId and text are required");
|
|
245
|
+
if (new TextEncoder().encode(text).byteLength > maxUserTextBytes) {
|
|
246
|
+
throw new Error("text must not exceed 4 KiB");
|
|
247
|
+
}
|
|
248
|
+
if (!this.textChannel || this.textChannel.readyState !== "open") {
|
|
249
|
+
throw new Error("Argus text channel is not open");
|
|
250
|
+
}
|
|
251
|
+
this.textChannel.send(JSON.stringify({ type: "user_text", message_id: messageId, text }));
|
|
252
|
+
}
|
|
107
253
|
/**
|
|
108
254
|
* Starts the publisher: races all gateways to find the fastest, completes
|
|
109
255
|
* the two-phase handshake, creates the peer connection, and sends the SDP
|
|
110
256
|
* offer. Resolves when the offer has been sent (not when ICE completes —
|
|
111
257
|
* use onConnected for that).
|
|
258
|
+
*
|
|
259
|
+
* The stream's single video track is published under `type` (default `"camera"`),
|
|
260
|
+
* declared to the server so reads and change notifications can address them by
|
|
261
|
+
* type. Add or remove further tracks live with {@link Publisher.publish} and
|
|
262
|
+
* {@link Publisher.unpublish}.
|
|
263
|
+
*/
|
|
264
|
+
async start(stream, type = "camera") {
|
|
265
|
+
const track = this.requireSingleVideoTrack(stream);
|
|
266
|
+
await this.startSession({ track, stream, type, watchForRecovery: true });
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Starts the publisher with a microphone track and no video — a fully valid
|
|
270
|
+
* audio-only stream, the natural starting point for a voice agent. Exactly one
|
|
271
|
+
* audio track must be present in `stream`. Video can be added later with
|
|
272
|
+
* {@link Publisher.publish}; a stream carries at most one microphone track.
|
|
273
|
+
*
|
|
274
|
+
* Like {@link Publisher.start} it races the gateways, completes the handshake,
|
|
275
|
+
* and sends the offer; it resolves once the offer is sent. The microphone is not
|
|
276
|
+
* subject to the video recovery ladder — a mic that stops simply ends
|
|
277
|
+
* transcription for the stream.
|
|
278
|
+
*/
|
|
279
|
+
async startAudioOnly(stream) {
|
|
280
|
+
const track = this.requireSingleAudioTrack(stream);
|
|
281
|
+
await this.startSession({ track, stream, type: "audio", watchForRecovery: false });
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Starts a WebRTC session with only the ordered `argus.text` data channel.
|
|
285
|
+
* This is the natural entry point for a typed, text-only agent: it requests no
|
|
286
|
+
* camera or microphone permission and publishes no media. Camera, screen, or
|
|
287
|
+
* microphone tracks can be added later with {@link Publisher.publish} or
|
|
288
|
+
* {@link Publisher.publishMicrophone}; {@link Publisher.enableSpeech} can add
|
|
289
|
+
* the optional inbound speech track independently.
|
|
290
|
+
*/
|
|
291
|
+
async startTextOnly() {
|
|
292
|
+
await this.startSession(null);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Shared startup for video, audio-only, and text-only entry points: race the
|
|
296
|
+
* gateways, build the peer connection and text channel, optionally add an
|
|
297
|
+
* initial media track, and send the first offer.
|
|
112
298
|
*/
|
|
113
|
-
async
|
|
299
|
+
async startSession(initialTrack) {
|
|
300
|
+
if (!this.stopped || this.pc || this.published.size > 0) {
|
|
301
|
+
throw new Error("publisher already started");
|
|
302
|
+
}
|
|
303
|
+
const trackKind = initialTrack ? initialTrack.type === "audio" ? "audio" : "video" : null;
|
|
304
|
+
const generation = ++this.lifecycleGeneration;
|
|
305
|
+
const runAbort = new AbortController();
|
|
306
|
+
this.runAbort = runAbort;
|
|
307
|
+
this.negotiationChain = Promise.resolve();
|
|
114
308
|
this.stopped = false;
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
117
|
-
this.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
this.
|
|
122
|
-
}
|
|
123
|
-
const iceServers = [...this.opts.iceServers ?? []];
|
|
124
|
-
if (readyInfo.turn_urls && readyInfo.turn_urls.length > 0) {
|
|
125
|
-
iceServers.push({
|
|
126
|
-
urls: readyInfo.turn_urls,
|
|
127
|
-
username: readyInfo.turn_username,
|
|
128
|
-
credential: readyInfo.turn_credential
|
|
129
|
-
});
|
|
309
|
+
this.recoveryStates.clear();
|
|
310
|
+
this.typeSenders.clear();
|
|
311
|
+
this.lastReportedICEPath = null;
|
|
312
|
+
this.watchedICETransports = /* @__PURE__ */ new WeakSet();
|
|
313
|
+
if (initialTrack) {
|
|
314
|
+
this.published.set(initialTrack.track, initialTrack.type);
|
|
315
|
+
this.publishedStreams.set(initialTrack.track, initialTrack.stream);
|
|
130
316
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
this.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
317
|
+
let startupWS = null;
|
|
318
|
+
try {
|
|
319
|
+
const { ws, readyInfo, gatewayURL } = await this.raceGateways(runAbort.signal);
|
|
320
|
+
startupWS = ws;
|
|
321
|
+
this.assertActiveRun(generation);
|
|
322
|
+
if (initialTrack && trackKind) this.requireLiveTrack(initialTrack.track, trackKind);
|
|
323
|
+
this.gatewayURL = gatewayURL;
|
|
324
|
+
if (readyInfo.read_token) {
|
|
325
|
+
this.readToken = readyInfo.read_token;
|
|
326
|
+
}
|
|
327
|
+
const iceServers = [...this.opts.iceServers ?? []];
|
|
328
|
+
const advertisedTURNURLs = readyInfo.turn_urls ?? [];
|
|
329
|
+
if (advertisedTURNURLs.length > 0 || this.opts.turnTransportPolicy !== void 0) {
|
|
330
|
+
const turnURLs = selectGatewayTURNURLs(
|
|
331
|
+
advertisedTURNURLs,
|
|
332
|
+
this.opts.turnTransportPolicy
|
|
333
|
+
);
|
|
334
|
+
if (turnURLs.length > 0) {
|
|
335
|
+
iceServers.push({
|
|
336
|
+
urls: turnURLs,
|
|
337
|
+
username: readyInfo.turn_username,
|
|
338
|
+
credential: readyInfo.turn_credential
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const pc = new RTCPeerConnection({
|
|
343
|
+
iceServers,
|
|
344
|
+
iceTransportPolicy: this.opts.iceTransportPolicy
|
|
141
345
|
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.
|
|
346
|
+
this.pc = pc;
|
|
347
|
+
this.textChannel = pc.createDataChannel("argus.text", { ordered: true });
|
|
348
|
+
this.textChannel.onmessage = (event) => this.handleTextMessage(event.data);
|
|
349
|
+
pc.ontrack = (event) => {
|
|
350
|
+
if (!this.isActiveRun(generation, pc) || event.track.kind !== "audio") return;
|
|
351
|
+
this.opts.callbacks?.onSpeechTrack?.(event.track, event.streams);
|
|
352
|
+
};
|
|
353
|
+
pc.onicecandidate = (ev) => {
|
|
354
|
+
if (!this.isActiveRun(generation, pc) || !ev.candidate) return;
|
|
355
|
+
this.handleLocalICECandidate(ev.candidate);
|
|
356
|
+
};
|
|
357
|
+
pc.onconnectionstatechange = () => {
|
|
358
|
+
if (!this.isActiveRun(generation, pc)) return;
|
|
359
|
+
const state = pc.connectionState;
|
|
360
|
+
if (state) this.opts.callbacks?.onConnectionStateChange?.(state);
|
|
361
|
+
if (state === "connected") {
|
|
362
|
+
this.clearPeerConnectionTimeout();
|
|
363
|
+
void this.reportSelectedICEPath(pc);
|
|
364
|
+
this.opts.callbacks?.onConnected?.();
|
|
365
|
+
} else if (state === "failed") {
|
|
366
|
+
this.clearPeerConnectionTimeout();
|
|
367
|
+
this.terminateWithError(new Error("WebRTC connection failed"), true, generation);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
if (initialTrack) {
|
|
371
|
+
const sender = initialTrack.type === "audio" ? this.addMicrophoneTrack(pc, initialTrack.track, initialTrack.stream) : pc.addTrack(initialTrack.track, initialTrack.stream);
|
|
372
|
+
this.typeSenders.set(
|
|
373
|
+
initialTrack.type,
|
|
374
|
+
sender
|
|
375
|
+
);
|
|
169
376
|
}
|
|
377
|
+
this.installSignaling(ws);
|
|
378
|
+
startupWS = null;
|
|
379
|
+
const offer = await pc.createOffer();
|
|
380
|
+
this.assertActiveRun(generation, pc);
|
|
381
|
+
if (initialTrack && trackKind) this.requireLiveTrack(initialTrack.track, trackKind);
|
|
382
|
+
this.beginLocalCandidateBatch();
|
|
383
|
+
await pc.setLocalDescription(offer);
|
|
384
|
+
this.assertActiveRun(generation, pc);
|
|
385
|
+
if (initialTrack && trackKind) this.requireLiveTrack(initialTrack.track, trackKind);
|
|
386
|
+
const local = pc.localDescription;
|
|
387
|
+
if (!local) throw new Error("local description missing");
|
|
388
|
+
const id = this.nextNegotiationId();
|
|
389
|
+
const { answered } = await this.sendOffer({
|
|
390
|
+
type: "offer",
|
|
391
|
+
sdp: local.sdp,
|
|
392
|
+
sdp_type: "offer",
|
|
393
|
+
negotiation_id: id,
|
|
394
|
+
tracks: this.buildTrackLabels(),
|
|
395
|
+
speech_enabled: this.speechEnabled || this.speechPending || void 0
|
|
396
|
+
});
|
|
397
|
+
this.releaseLocalCandidateBatch();
|
|
398
|
+
this.assertActiveRun(generation, pc);
|
|
399
|
+
this.armPeerConnectionTimeout(generation, pc);
|
|
400
|
+
if (initialTrack && trackKind) this.requireLiveTrack(initialTrack.track, trackKind);
|
|
401
|
+
if (initialTrack?.watchForRecovery) this.watchTrack(initialTrack.track);
|
|
402
|
+
else if (initialTrack) this.watchMicrophone(initialTrack.track);
|
|
403
|
+
const initial = answered.then(async (sdp) => {
|
|
404
|
+
this.assertActiveRun(generation, pc);
|
|
405
|
+
await pc.setRemoteDescription(
|
|
406
|
+
new RTCSessionDescription({ type: "answer", sdp })
|
|
407
|
+
);
|
|
408
|
+
this.assertActiveRun(generation, pc);
|
|
409
|
+
this.applyAnswered(pc);
|
|
410
|
+
});
|
|
411
|
+
this.negotiationChain = initial.catch((err) => {
|
|
412
|
+
if (this.isActiveRun(generation, pc)) {
|
|
413
|
+
const reported = err instanceof Error ? err : new Error(String(err));
|
|
414
|
+
const alreadyReported = err instanceof ReportedPublisherError && err.fatal;
|
|
415
|
+
this.terminateWithError(reported, !alreadyReported, generation);
|
|
416
|
+
}
|
|
417
|
+
throw err;
|
|
418
|
+
});
|
|
419
|
+
void this.negotiationChain.catch(() => {
|
|
420
|
+
});
|
|
421
|
+
} catch (err) {
|
|
422
|
+
startupWS?.close();
|
|
423
|
+
if (generation === this.lifecycleGeneration) this.stop();
|
|
424
|
+
throw err;
|
|
170
425
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Adds the single video track from `stream` to the live session under `type`,
|
|
429
|
+
* renegotiating so the media server begins ingesting them. Use this to add a
|
|
430
|
+
* track after {@link Publisher.start} — for example to begin a screen share on
|
|
431
|
+
* top of a live camera.
|
|
432
|
+
*
|
|
433
|
+
* Exactly one video track must be present in `stream`. If a track of `type` is already
|
|
434
|
+
* live it is removed and replaced (a "screen" published while another "screen"
|
|
435
|
+
* is live supersedes it).
|
|
436
|
+
*/
|
|
437
|
+
async publish(stream, type) {
|
|
438
|
+
if (!this.pc) throw new Error("publisher not started");
|
|
439
|
+
const track = this.requireSingleVideoTrack(stream);
|
|
440
|
+
await this.enqueueNegotiation(() => this.stagePublish(track, stream, type));
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Removes the live track(s) of the given type, stops their local capture, and
|
|
444
|
+
* renegotiates so the media server ends ingestion for that track. A no-op if
|
|
445
|
+
* no track of that type is published.
|
|
446
|
+
*/
|
|
447
|
+
async unpublish(type) {
|
|
448
|
+
if (!this.pc) throw new Error("publisher not started");
|
|
449
|
+
await this.enqueueNegotiation(() => {
|
|
450
|
+
if (this.tracksOfType(type).length === 0) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
return this.stageUnpublish(type);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Adds the microphone (audio) track from `stream` to the live session and
|
|
458
|
+
* renegotiates, so the media server begins transcribing it. Exactly one audio
|
|
459
|
+
* track must be present in `stream`. Publishing a microphone while one is
|
|
460
|
+
* already live replaces it.
|
|
461
|
+
*
|
|
462
|
+
* The audio track feeds server-side speech-to-text; its transcripts are
|
|
463
|
+
* delivered to the customer server over the change-notification subscription,
|
|
464
|
+
* not to the browser. Audio is not subject to the video recovery ladder — a
|
|
465
|
+
* mic that stops is simply removed.
|
|
466
|
+
*/
|
|
467
|
+
async publishMicrophone(stream) {
|
|
468
|
+
if (!this.pc) throw new Error("publisher not started");
|
|
469
|
+
const track = this.requireSingleAudioTrack(stream);
|
|
470
|
+
await this.enqueueNegotiation(() => this.stagePublishAudio(track, stream));
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Removes the live microphone track, stops its local capture, and
|
|
474
|
+
* renegotiates so the media server ends transcription. A no-op if no
|
|
475
|
+
* microphone is published.
|
|
476
|
+
*/
|
|
477
|
+
async unpublishMicrophone() {
|
|
478
|
+
if (!this.pc) throw new Error("publisher not started");
|
|
479
|
+
await this.enqueueNegotiation(() => {
|
|
480
|
+
if (this.tracksOfType("audio").length === 0) return false;
|
|
481
|
+
return this.stageUnpublish("audio");
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Replaces the published track of a single type with a new stream and
|
|
486
|
+
* renegotiates in place — e.g. to swap to a freshly reacquired screen share
|
|
487
|
+
* after {@link PublisherCallbacks.onRecoveryRequired}. Defaults to the
|
|
488
|
+
* `"camera"` type. This is a convenience over {@link Publisher.publish}, which
|
|
489
|
+
* it delegates to (publishing one track per type replaces any existing track
|
|
490
|
+
* of that type).
|
|
491
|
+
*/
|
|
492
|
+
async replaceStream(stream, type = "camera") {
|
|
493
|
+
await this.publish(stream, type);
|
|
177
494
|
}
|
|
178
495
|
/** Stops publishing and tears down the peer connection. */
|
|
179
496
|
stop() {
|
|
497
|
+
this.lifecycleGeneration++;
|
|
498
|
+
this.runAbort?.abort();
|
|
499
|
+
this.runAbort = null;
|
|
500
|
+
this.clearPeerConnectionTimeout();
|
|
180
501
|
this.stopped = true;
|
|
181
|
-
this.
|
|
502
|
+
this.cancelAllMediaRecovery();
|
|
182
503
|
this.reconnectGeneration++;
|
|
183
504
|
this.reconnecting = false;
|
|
184
505
|
this.resumeSocket?.close();
|
|
185
506
|
this.resumeSocket = null;
|
|
186
507
|
this.sig?.close();
|
|
187
508
|
this.sig = null;
|
|
509
|
+
this.rejectSignalingWaiters(new Error("publisher stopped"));
|
|
188
510
|
this.unwatchStreamTracks();
|
|
189
|
-
this.
|
|
190
|
-
this.
|
|
511
|
+
this.stopPublishedTracks();
|
|
512
|
+
this.clearIntentionalTrackEnds();
|
|
513
|
+
this.rejectPendingAnswer(new Error("publisher stopped"));
|
|
514
|
+
this.pendingOffer = null;
|
|
191
515
|
this.pc?.close();
|
|
192
516
|
this.pc = null;
|
|
517
|
+
this.textChannel = null;
|
|
518
|
+
this.speechEnabled = false;
|
|
519
|
+
this.speechPending = false;
|
|
520
|
+
this.speechTransceiver = null;
|
|
521
|
+
this.microphoneTransceiver = null;
|
|
522
|
+
this.typeSenders.clear();
|
|
193
523
|
this.hasAnswer = false;
|
|
194
524
|
this.pendingRemoteCandidates = [];
|
|
525
|
+
this.pendingLocalCandidates = [];
|
|
526
|
+
this.localCandidateOfferSent = false;
|
|
527
|
+
this.clearRetainedICECandidates();
|
|
195
528
|
this.readToken = null;
|
|
196
|
-
this.
|
|
529
|
+
this.gatewayURL = null;
|
|
530
|
+
this.lastReportedICEPath = null;
|
|
531
|
+
}
|
|
532
|
+
// rejectPendingAnswer fails any in-flight negotiation so a queued
|
|
533
|
+
// publish()/unpublish() rejects promptly instead of hanging until timeout.
|
|
534
|
+
rejectPendingAnswer(err) {
|
|
535
|
+
const pending = this.pendingAnswer;
|
|
536
|
+
if (pending) {
|
|
537
|
+
this.pendingAnswer = null;
|
|
538
|
+
pending.reject(err);
|
|
539
|
+
}
|
|
197
540
|
}
|
|
198
541
|
/** Returns the current RTCPeerConnection, or null if not started. */
|
|
199
542
|
get peerConnection() {
|
|
@@ -206,7 +549,7 @@ var Publisher = class {
|
|
|
206
549
|
// -------------------------------------------------------------------------
|
|
207
550
|
// Private helpers
|
|
208
551
|
// -------------------------------------------------------------------------
|
|
209
|
-
raceGateways() {
|
|
552
|
+
raceGateways(signal) {
|
|
210
553
|
return new Promise((resolve, reject) => {
|
|
211
554
|
const { gatewayURLs, token } = this.opts;
|
|
212
555
|
if (gatewayURLs.length === 0) {
|
|
@@ -214,9 +557,21 @@ var Publisher = class {
|
|
|
214
557
|
return;
|
|
215
558
|
}
|
|
216
559
|
const sockets = [];
|
|
560
|
+
const attemptTimers = /* @__PURE__ */ new Map();
|
|
217
561
|
let settled = false;
|
|
562
|
+
let timeoutTimer = null;
|
|
563
|
+
const clearTimeoutTimer = () => {
|
|
564
|
+
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
565
|
+
timeoutTimer = null;
|
|
566
|
+
};
|
|
567
|
+
const clearAttemptTimer = (socket) => {
|
|
568
|
+
const timer = attemptTimers.get(socket);
|
|
569
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
570
|
+
attemptTimers.delete(socket);
|
|
571
|
+
};
|
|
218
572
|
const closeAll = (except) => {
|
|
219
573
|
for (const s of sockets) {
|
|
574
|
+
clearAttemptTimer(s);
|
|
220
575
|
if (s !== except) {
|
|
221
576
|
s.onmessage = null;
|
|
222
577
|
s.onerror = null;
|
|
@@ -229,39 +584,107 @@ var Publisher = class {
|
|
|
229
584
|
if (settled) return;
|
|
230
585
|
if (sockets.every((s) => s.readyState === WebSocket.CLOSED || s.readyState === WebSocket.CLOSING)) {
|
|
231
586
|
settled = true;
|
|
587
|
+
clearTimeoutTimer();
|
|
588
|
+
signal.removeEventListener("abort", abort);
|
|
232
589
|
reject(new Error("all gateways failed to connect"));
|
|
233
590
|
}
|
|
234
591
|
};
|
|
235
|
-
|
|
592
|
+
const abort = () => {
|
|
593
|
+
if (settled) return;
|
|
594
|
+
settled = true;
|
|
595
|
+
clearTimeoutTimer();
|
|
596
|
+
closeAll();
|
|
597
|
+
reject(new PublisherStoppedError("publisher stopped"));
|
|
598
|
+
};
|
|
599
|
+
if (signal.aborted) {
|
|
600
|
+
abort();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
604
|
+
const timeoutMs = Math.max(
|
|
605
|
+
0,
|
|
606
|
+
this.opts.gatewayHandshakeTimeoutMs ?? defaultGatewayHandshakeTimeoutMs
|
|
607
|
+
);
|
|
608
|
+
timeoutTimer = setTimeout(() => {
|
|
609
|
+
if (settled) return;
|
|
610
|
+
settled = true;
|
|
611
|
+
signal.removeEventListener("abort", abort);
|
|
612
|
+
closeAll();
|
|
613
|
+
reject(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
|
|
614
|
+
}, timeoutMs);
|
|
615
|
+
const openGateway = (gatewayURL) => {
|
|
616
|
+
if (settled) return;
|
|
236
617
|
const u = new URL(gatewayURL);
|
|
237
618
|
u.searchParams.set("token", token);
|
|
238
619
|
const ws = new WebSocket(u.toString());
|
|
239
620
|
sockets.push(ws);
|
|
240
621
|
let accepted = false;
|
|
622
|
+
const attemptTimer = setTimeout(() => {
|
|
623
|
+
attemptTimers.delete(ws);
|
|
624
|
+
if (settled || accepted) return;
|
|
625
|
+
ws.onmessage = null;
|
|
626
|
+
ws.onerror = null;
|
|
627
|
+
ws.onclose = null;
|
|
628
|
+
ws.close();
|
|
629
|
+
try {
|
|
630
|
+
openGateway(gatewayURL);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
settled = true;
|
|
633
|
+
clearTimeoutTimer();
|
|
634
|
+
signal.removeEventListener("abort", abort);
|
|
635
|
+
closeAll();
|
|
636
|
+
reject(err);
|
|
637
|
+
}
|
|
638
|
+
}, initialGatewayAttemptTimeoutMs);
|
|
639
|
+
attemptTimers.set(ws, attemptTimer);
|
|
241
640
|
ws.onmessage = (ev) => {
|
|
242
641
|
if (settled) return;
|
|
243
642
|
try {
|
|
244
643
|
const msg = JSON.parse(ev.data);
|
|
245
644
|
if (!accepted && msg.type === "accepted") {
|
|
246
645
|
accepted = true;
|
|
646
|
+
clearAttemptTimer(ws);
|
|
247
647
|
ws.send(JSON.stringify({ type: "proceed" }));
|
|
248
648
|
} else if (accepted && msg.type === "ready") {
|
|
249
649
|
settled = true;
|
|
650
|
+
clearTimeoutTimer();
|
|
651
|
+
signal.removeEventListener("abort", abort);
|
|
250
652
|
closeAll(ws);
|
|
251
653
|
resolve({ ws, readyInfo: msg, gatewayURL });
|
|
252
654
|
}
|
|
253
655
|
} catch {
|
|
254
656
|
}
|
|
255
657
|
};
|
|
256
|
-
ws.onerror = () =>
|
|
257
|
-
|
|
658
|
+
ws.onerror = () => {
|
|
659
|
+
clearAttemptTimer(ws);
|
|
660
|
+
checkAllFailed();
|
|
661
|
+
};
|
|
662
|
+
ws.onclose = () => {
|
|
663
|
+
clearAttemptTimer(ws);
|
|
664
|
+
checkAllFailed();
|
|
665
|
+
};
|
|
666
|
+
};
|
|
667
|
+
try {
|
|
668
|
+
for (const gatewayURL of gatewayURLs) {
|
|
669
|
+
openGateway(gatewayURL);
|
|
670
|
+
}
|
|
671
|
+
} catch (err) {
|
|
672
|
+
settled = true;
|
|
673
|
+
clearTimeoutTimer();
|
|
674
|
+
signal.removeEventListener("abort", abort);
|
|
675
|
+
closeAll();
|
|
676
|
+
reject(err);
|
|
258
677
|
}
|
|
259
678
|
});
|
|
260
679
|
}
|
|
261
680
|
installSignaling(ws) {
|
|
262
681
|
const channel = SignalingChannel.wrap(ws);
|
|
682
|
+
const generation = this.lifecycleGeneration;
|
|
263
683
|
this.sig = channel;
|
|
264
|
-
channel.onMessage = (msg) =>
|
|
684
|
+
channel.onMessage = (msg) => {
|
|
685
|
+
if (this.sig !== channel || !this.isActiveRun(generation)) return;
|
|
686
|
+
this.handleSignal(msg);
|
|
687
|
+
};
|
|
265
688
|
channel.onClose = () => {
|
|
266
689
|
if (this.sig !== channel || this.stopped) return;
|
|
267
690
|
this.sig = null;
|
|
@@ -269,11 +692,85 @@ var Publisher = class {
|
|
|
269
692
|
};
|
|
270
693
|
channel.onError = () => {
|
|
271
694
|
};
|
|
695
|
+
this.resolveSignalingWaiters(channel);
|
|
696
|
+
const pending = this.pendingOffer;
|
|
697
|
+
if (pending?.sent) {
|
|
698
|
+
try {
|
|
699
|
+
channel.send(pending.message);
|
|
700
|
+
} catch {
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (this.localCandidateOfferSent) {
|
|
704
|
+
for (const candidate of this.retainedLocalCandidates) {
|
|
705
|
+
try {
|
|
706
|
+
channel.send(candidate);
|
|
707
|
+
} catch {
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
272
712
|
return channel;
|
|
273
713
|
}
|
|
714
|
+
awaitSignaling() {
|
|
715
|
+
if (this.sig) return Promise.resolve(this.sig);
|
|
716
|
+
if (this.stopped) return Promise.reject(new Error("publisher stopped"));
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
this.signalingWaiters.add({ resolve, reject });
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
resolveSignalingWaiters(channel) {
|
|
722
|
+
const waiters = [...this.signalingWaiters];
|
|
723
|
+
this.signalingWaiters.clear();
|
|
724
|
+
for (const waiter of waiters) waiter.resolve(channel);
|
|
725
|
+
}
|
|
726
|
+
rejectSignalingWaiters(err) {
|
|
727
|
+
const waiters = [...this.signalingWaiters];
|
|
728
|
+
this.signalingWaiters.clear();
|
|
729
|
+
for (const waiter of waiters) waiter.reject(err);
|
|
730
|
+
}
|
|
731
|
+
async sendWhenSignalingAvailable(msg) {
|
|
732
|
+
while (!this.stopped) {
|
|
733
|
+
const channel = await this.awaitSignaling();
|
|
734
|
+
try {
|
|
735
|
+
if (channel.send(msg)) return;
|
|
736
|
+
} catch {
|
|
737
|
+
}
|
|
738
|
+
if (this.sig === channel) {
|
|
739
|
+
this.sig = null;
|
|
740
|
+
void this.resumeSignaling();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
throw new Error("publisher stopped");
|
|
744
|
+
}
|
|
745
|
+
async sendOffer(message) {
|
|
746
|
+
if (this.pendingOffer) {
|
|
747
|
+
throw new Error("another negotiation offer is already pending");
|
|
748
|
+
}
|
|
749
|
+
const pending = { message, sent: false };
|
|
750
|
+
this.pendingOffer = pending;
|
|
751
|
+
try {
|
|
752
|
+
await this.sendWhenSignalingAvailable(message);
|
|
753
|
+
pending.sent = true;
|
|
754
|
+
const id = message.negotiation_id;
|
|
755
|
+
if (!id) throw new Error("negotiation offer is missing an id");
|
|
756
|
+
const answered = this.awaitAnswer(id);
|
|
757
|
+
void answered.then(
|
|
758
|
+
() => {
|
|
759
|
+
if (this.pendingOffer === pending) this.pendingOffer = null;
|
|
760
|
+
},
|
|
761
|
+
() => {
|
|
762
|
+
if (this.pendingOffer === pending) this.pendingOffer = null;
|
|
763
|
+
}
|
|
764
|
+
);
|
|
765
|
+
return { answered };
|
|
766
|
+
} catch (err) {
|
|
767
|
+
if (this.pendingOffer === pending) this.pendingOffer = null;
|
|
768
|
+
throw err;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
274
771
|
async resumeSignaling() {
|
|
275
772
|
if (this.reconnecting || this.stopped) return;
|
|
276
|
-
if (!this.
|
|
773
|
+
if (!this.gatewayURL || !this.readToken) {
|
|
277
774
|
this.terminateWithError(new Error("signaling closed and cannot be resumed"));
|
|
278
775
|
return;
|
|
279
776
|
}
|
|
@@ -312,7 +809,7 @@ var Publisher = class {
|
|
|
312
809
|
}
|
|
313
810
|
openResumeSocket(timeoutMs) {
|
|
314
811
|
return new Promise((resolve, reject) => {
|
|
315
|
-
const u = new URL(this.
|
|
812
|
+
const u = new URL(this.gatewayURL);
|
|
316
813
|
u.searchParams.set("token", this.readToken);
|
|
317
814
|
const ws = new WebSocket(u.toString());
|
|
318
815
|
this.resumeSocket = ws;
|
|
@@ -346,47 +843,88 @@ var Publisher = class {
|
|
|
346
843
|
wait(ms) {
|
|
347
844
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
845
|
}
|
|
349
|
-
|
|
846
|
+
isActiveRun(generation, pc) {
|
|
847
|
+
return generation === this.lifecycleGeneration && !this.stopped && !this.runAbort?.signal.aborted && (!pc || this.pc === pc);
|
|
848
|
+
}
|
|
849
|
+
assertActiveRun(generation, pc) {
|
|
850
|
+
if (!this.isActiveRun(generation, pc)) {
|
|
851
|
+
throw new PublisherStoppedError("publisher stopped");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
armPeerConnectionTimeout(generation, pc) {
|
|
855
|
+
this.clearPeerConnectionTimeout();
|
|
856
|
+
if (pc.connectionState === "connected") return;
|
|
857
|
+
const timeoutMs = Math.max(
|
|
858
|
+
0,
|
|
859
|
+
this.opts.peerConnectionTimeoutMs ?? defaultPeerConnectionTimeoutMs
|
|
860
|
+
);
|
|
861
|
+
const timer = setTimeout(() => {
|
|
862
|
+
if (this.peerConnectionTimer !== timer) return;
|
|
863
|
+
this.peerConnectionTimer = null;
|
|
864
|
+
if (!this.isActiveRun(generation, pc) || pc.connectionState === "connected") return;
|
|
865
|
+
this.terminateWithError(
|
|
866
|
+
new Error(`WebRTC connection timed out after ${timeoutMs}ms`),
|
|
867
|
+
true,
|
|
868
|
+
generation
|
|
869
|
+
);
|
|
870
|
+
}, timeoutMs);
|
|
871
|
+
this.peerConnectionTimer = timer;
|
|
872
|
+
}
|
|
873
|
+
clearPeerConnectionTimeout() {
|
|
874
|
+
if (this.peerConnectionTimer !== null) clearTimeout(this.peerConnectionTimer);
|
|
875
|
+
this.peerConnectionTimer = null;
|
|
876
|
+
}
|
|
877
|
+
terminateWithError(err, notify = true, generation = this.lifecycleGeneration) {
|
|
878
|
+
if (generation !== this.lifecycleGeneration) return;
|
|
879
|
+
this.lifecycleGeneration++;
|
|
880
|
+
this.runAbort?.abort();
|
|
881
|
+
this.runAbort = null;
|
|
882
|
+
this.clearPeerConnectionTimeout();
|
|
350
883
|
this.stopped = true;
|
|
351
|
-
this.
|
|
884
|
+
this.cancelAllMediaRecovery();
|
|
352
885
|
this.reconnectGeneration++;
|
|
353
886
|
this.resumeSocket?.close();
|
|
354
887
|
this.resumeSocket = null;
|
|
355
888
|
this.sig?.close();
|
|
356
889
|
this.sig = null;
|
|
890
|
+
this.rejectSignalingWaiters(err);
|
|
357
891
|
this.pc?.close();
|
|
358
892
|
this.pc = null;
|
|
893
|
+
this.textChannel = null;
|
|
894
|
+
this.speechEnabled = false;
|
|
895
|
+
this.speechPending = false;
|
|
896
|
+
this.speechTransceiver = null;
|
|
897
|
+
this.microphoneTransceiver = null;
|
|
898
|
+
this.typeSenders.clear();
|
|
359
899
|
this.unwatchStreamTracks();
|
|
360
|
-
this.
|
|
361
|
-
this.
|
|
900
|
+
this.stopPublishedTracks();
|
|
901
|
+
this.clearIntentionalTrackEnds();
|
|
902
|
+
this.rejectPendingAnswer(new Error("publisher terminated"));
|
|
903
|
+
this.pendingOffer = null;
|
|
362
904
|
this.hasAnswer = false;
|
|
363
905
|
this.pendingRemoteCandidates = [];
|
|
906
|
+
this.pendingLocalCandidates = [];
|
|
907
|
+
this.localCandidateOfferSent = false;
|
|
908
|
+
this.clearRetainedICECandidates();
|
|
364
909
|
this.readToken = null;
|
|
365
|
-
this.
|
|
366
|
-
this.
|
|
910
|
+
this.gatewayURL = null;
|
|
911
|
+
this.lastReportedICEPath = null;
|
|
912
|
+
if (notify) this.opts.callbacks?.onError?.(err);
|
|
367
913
|
}
|
|
368
914
|
handleSignal(msg) {
|
|
369
915
|
switch (msg.type) {
|
|
370
916
|
case "answer": {
|
|
371
917
|
if (!this.pc) return;
|
|
372
|
-
this.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
this.pc?.addIceCandidate(init).catch(() => {
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
this.pendingRemoteCandidates = [];
|
|
381
|
-
}).catch((err) => {
|
|
382
|
-
this.opts.callbacks?.onError?.(
|
|
383
|
-
new Error(`failed to set remote description: ${err}`)
|
|
384
|
-
);
|
|
385
|
-
});
|
|
918
|
+
const pending = this.pendingAnswer;
|
|
919
|
+
if (!pending) break;
|
|
920
|
+
if (msg.negotiation_id && msg.negotiation_id !== pending.id) break;
|
|
921
|
+
this.pendingAnswer = null;
|
|
922
|
+
pending.resolve(msg.sdp);
|
|
386
923
|
break;
|
|
387
924
|
}
|
|
388
925
|
case "ice_candidate": {
|
|
389
926
|
if (!this.pc) return;
|
|
927
|
+
if (!this.retainRemoteCandidate(msg)) return;
|
|
390
928
|
const init = {
|
|
391
929
|
candidate: msg.candidate,
|
|
392
930
|
sdpMid: msg.sdp_mid ?? null,
|
|
@@ -404,8 +942,14 @@ var Publisher = class {
|
|
|
404
942
|
case "connection_state": {
|
|
405
943
|
break;
|
|
406
944
|
}
|
|
407
|
-
case "media_stall":
|
|
408
945
|
case "media_track_ended": {
|
|
946
|
+
if (msg.track === "audio") break;
|
|
947
|
+
if (this.consumeIntentionalTrackEnd(msg.track, msg.track_id)) break;
|
|
948
|
+
void this.beginMediaRecovery(msg.track);
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case "media_stall": {
|
|
952
|
+
if (msg.track === "audio") break;
|
|
409
953
|
void this.beginMediaRecovery(msg.track);
|
|
410
954
|
break;
|
|
411
955
|
}
|
|
@@ -414,7 +958,18 @@ var Publisher = class {
|
|
|
414
958
|
break;
|
|
415
959
|
}
|
|
416
960
|
case "error": {
|
|
417
|
-
|
|
961
|
+
const err = new ReportedPublisherError(msg.error, msg.fatal === true);
|
|
962
|
+
const pending = this.pendingAnswer;
|
|
963
|
+
let matchedPending = false;
|
|
964
|
+
if (pending && (!msg.negotiation_id || msg.negotiation_id === pending.id)) {
|
|
965
|
+
matchedPending = true;
|
|
966
|
+
this.pendingAnswer = null;
|
|
967
|
+
pending.reject(err);
|
|
968
|
+
}
|
|
969
|
+
if (err.fatal) this.opts.callbacks?.onError?.(err);
|
|
970
|
+
if (err.fatal && !matchedPending) {
|
|
971
|
+
this.terminateWithError(err, false);
|
|
972
|
+
}
|
|
418
973
|
break;
|
|
419
974
|
}
|
|
420
975
|
case "resumed":
|
|
@@ -422,86 +977,482 @@ var Publisher = class {
|
|
|
422
977
|
}
|
|
423
978
|
}
|
|
424
979
|
async beginMediaRecovery(trackType) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const liveTracks =
|
|
980
|
+
const state = this.recoveryState(trackType);
|
|
981
|
+
if (this.stopped || state.recovering || state.required) return;
|
|
982
|
+
const liveTracks = this.tracksOfType(trackType).filter(
|
|
983
|
+
(track) => track.readyState !== "ended"
|
|
984
|
+
);
|
|
428
985
|
if (liveTracks.length === 0) {
|
|
429
986
|
this.failMediaRecovery(trackType, "capture_ended");
|
|
430
987
|
return;
|
|
431
988
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
989
|
+
state.recovering = true;
|
|
990
|
+
state.generation = ++this.recoverySequence;
|
|
991
|
+
const generation = state.generation;
|
|
992
|
+
state.action = "sender_restart";
|
|
435
993
|
this.emitRecoveryTransition({ state: "recovering", track: trackType, action: "sender_restart" });
|
|
436
994
|
this.sendRecoveryDiagnostic("recovery_started", trackType, "sender_restart");
|
|
437
|
-
await this.restartSenders(liveTracks, generation);
|
|
438
|
-
if (!this.isCurrentRecovery(generation)) return;
|
|
995
|
+
await this.restartSenders(trackType, liveTracks, generation);
|
|
996
|
+
if (!this.isCurrentRecovery(trackType, generation)) return;
|
|
439
997
|
await this.wait(senderRecoveryWaitMs);
|
|
440
|
-
if (!this.isCurrentRecovery(generation)) return;
|
|
441
|
-
|
|
998
|
+
if (!this.isCurrentRecovery(trackType, generation)) return;
|
|
999
|
+
state.action = "ice_restart";
|
|
442
1000
|
this.emitRecoveryTransition({ state: "recovering", track: trackType, action: "ice_restart" });
|
|
443
1001
|
this.sendRecoveryDiagnostic("recovery_retry", trackType, "ice_restart");
|
|
444
1002
|
try {
|
|
445
|
-
await this.
|
|
1003
|
+
await this.sharedIceRestart();
|
|
446
1004
|
} catch {
|
|
447
1005
|
}
|
|
1006
|
+
if (!this.isCurrentRecovery(trackType, generation)) return;
|
|
448
1007
|
await this.wait(iceRecoveryWaitMs);
|
|
449
|
-
if (!this.isCurrentRecovery(generation)) return;
|
|
1008
|
+
if (!this.isCurrentRecovery(trackType, generation)) return;
|
|
450
1009
|
this.failMediaRecovery(trackType, "automatic_recovery_failed");
|
|
451
1010
|
}
|
|
452
|
-
async restartSenders(tracks, generation) {
|
|
453
|
-
const
|
|
454
|
-
const senders = this.pc?.getSenders().filter(
|
|
455
|
-
(sender) => sender.track && live.has(sender.track)
|
|
456
|
-
) ?? [];
|
|
457
|
-
if (senders.length === 0) return;
|
|
458
|
-
const originals = senders.map((sender) => ({ sender, track: sender.track }));
|
|
1011
|
+
async restartSenders(trackType, tracks, generation) {
|
|
1012
|
+
const lifecycleGeneration = this.lifecycleGeneration;
|
|
459
1013
|
try {
|
|
460
|
-
await
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
1014
|
+
await this.enqueueNegotiation(async () => {
|
|
1015
|
+
if (!this.isCurrentRecovery(trackType, generation)) return false;
|
|
1016
|
+
const live = new Set(tracks);
|
|
1017
|
+
const senders = this.pc?.getSenders().filter(
|
|
1018
|
+
(sender) => sender.track && live.has(sender.track)
|
|
1019
|
+
) ?? [];
|
|
1020
|
+
if (senders.length === 0) return false;
|
|
1021
|
+
const originals = senders.map((sender) => ({ sender, track: sender.track }));
|
|
1022
|
+
try {
|
|
1023
|
+
await Promise.all(originals.map(({ sender }) => sender.replaceTrack(null)));
|
|
1024
|
+
await this.wait(senderRestartPauseMs);
|
|
1025
|
+
if (!this.isCurrentRecovery(trackType, generation)) return false;
|
|
1026
|
+
await Promise.all(originals.map(({ sender, track }) => sender.replaceTrack(track)));
|
|
1027
|
+
if (!this.isCurrentRecovery(trackType, generation)) return false;
|
|
1028
|
+
return;
|
|
1029
|
+
} finally {
|
|
1030
|
+
const restored = await Promise.allSettled(originals.map(async ({ sender, track }) => {
|
|
1031
|
+
if (sender.track === null && this.published.has(track) && track.readyState !== "ended") {
|
|
1032
|
+
await sender.replaceTrack(track);
|
|
1033
|
+
}
|
|
1034
|
+
}));
|
|
1035
|
+
if (restored.some((result) => result.status === "rejected")) {
|
|
1036
|
+
throw new SenderRestoreError("failed to restore a detached media sender");
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
if (err instanceof SenderRestoreError && lifecycleGeneration === this.lifecycleGeneration && !this.stopped) {
|
|
1042
|
+
this.terminateWithError(err, true, lifecycleGeneration);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
466
1045
|
}
|
|
467
1046
|
}
|
|
468
|
-
|
|
1047
|
+
/**
|
|
1048
|
+
* Adds a recovery renegotiation to the same queue as user operations. The
|
|
1049
|
+
* recovery ladder awaits its completion before starting the stage observation
|
|
1050
|
+
* window. If recovery has completed by the time this reaches the head of the
|
|
1051
|
+
* queue, it is skipped.
|
|
1052
|
+
*/
|
|
1053
|
+
/** Returns the peer-wide ICE attempt shared by all active track recoveries. */
|
|
1054
|
+
sharedIceRestart() {
|
|
1055
|
+
if (this.iceRestartAttempt) return this.iceRestartAttempt.promise;
|
|
1056
|
+
const id = ++this.iceRestartSequence;
|
|
1057
|
+
const promise = this.enqueueNegotiation(
|
|
1058
|
+
() => {
|
|
1059
|
+
if (this.iceRestartAttempt?.id !== id || !this.hasActiveMediaRecovery()) return false;
|
|
1060
|
+
},
|
|
1061
|
+
{ iceRestart: true }
|
|
1062
|
+
);
|
|
1063
|
+
const attempt = { id, promise };
|
|
1064
|
+
this.iceRestartAttempt = attempt;
|
|
1065
|
+
void promise.finally(() => {
|
|
1066
|
+
if (this.iceRestartAttempt === attempt) this.iceRestartAttempt = null;
|
|
1067
|
+
}).catch(() => {
|
|
1068
|
+
});
|
|
1069
|
+
return promise;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Serializes a renegotiation onto the shared chain: it waits for any prior
|
|
1073
|
+
* negotiation to finish (its answer applied), sends a fresh offer, and resolves
|
|
1074
|
+
* only once this offer's answer has been applied. This prevents overlapping
|
|
1075
|
+
* offers and answers being applied to the wrong offer.
|
|
1076
|
+
*/
|
|
1077
|
+
enqueueNegotiation(mutate, opts = {}) {
|
|
1078
|
+
const generation = this.lifecycleGeneration;
|
|
1079
|
+
const run = this.negotiationChain.catch(() => {
|
|
1080
|
+
}).then(() => {
|
|
1081
|
+
this.assertActiveRun(generation);
|
|
1082
|
+
return this.negotiateOnce(mutate, opts, generation);
|
|
1083
|
+
});
|
|
1084
|
+
this.negotiationChain = run.catch(() => {
|
|
1085
|
+
});
|
|
1086
|
+
return run;
|
|
1087
|
+
}
|
|
1088
|
+
async negotiateOnce(mutate, opts, generation) {
|
|
469
1089
|
const pc = this.pc;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
1090
|
+
if (!pc || !this.isActiveRun(generation, pc)) {
|
|
1091
|
+
throw new PublisherStoppedError("publisher stopped");
|
|
1092
|
+
}
|
|
1093
|
+
if (!this.runAbort?.signal) throw new PublisherStoppedError("publisher stopped");
|
|
1094
|
+
const previousHasAnswer = this.hasAnswer;
|
|
1095
|
+
const previousRemoteCandidates = this.pendingRemoteCandidates;
|
|
1096
|
+
const previousLocalCandidateOfferSent = this.localCandidateOfferSent;
|
|
1097
|
+
const previousRetainedLocalCandidates = [...this.retainedLocalCandidates];
|
|
1098
|
+
const previousRetainedLocalCandidateKeys = new Set(this.retainedLocalCandidateKeys);
|
|
1099
|
+
const previousLocalCandidateGeneration = this.localCandidateGeneration;
|
|
1100
|
+
await this.awaitSignaling();
|
|
1101
|
+
this.assertActiveRun(generation, pc);
|
|
1102
|
+
let change;
|
|
1103
|
+
let localOfferSet = false;
|
|
1104
|
+
let answerReceived = false;
|
|
1105
|
+
try {
|
|
1106
|
+
const result = await mutate();
|
|
1107
|
+
this.assertActiveRun(generation, pc);
|
|
1108
|
+
if (result === false) return;
|
|
1109
|
+
if (result && typeof result === "object") change = result;
|
|
1110
|
+
if (opts.iceRestart) pc.restartIce?.();
|
|
1111
|
+
const offer = await pc.createOffer(opts.iceRestart ? { iceRestart: true } : void 0);
|
|
1112
|
+
this.assertActiveRun(generation, pc);
|
|
1113
|
+
this.beginLocalCandidateBatch();
|
|
1114
|
+
await pc.setLocalDescription(offer);
|
|
1115
|
+
localOfferSet = true;
|
|
1116
|
+
this.assertActiveRun(generation, pc);
|
|
1117
|
+
const local = pc.localDescription;
|
|
1118
|
+
if (!local) throw new Error("local description missing");
|
|
1119
|
+
this.hasAnswer = false;
|
|
1120
|
+
this.pendingRemoteCandidates = [];
|
|
1121
|
+
const id = this.nextNegotiationId();
|
|
1122
|
+
const { answered } = await this.sendOffer({
|
|
1123
|
+
type: "offer",
|
|
1124
|
+
sdp: local.sdp,
|
|
1125
|
+
sdp_type: "offer",
|
|
1126
|
+
negotiation_id: id,
|
|
1127
|
+
tracks: change?.labels ?? this.buildTrackLabels(),
|
|
1128
|
+
speech_enabled: this.speechEnabled || this.speechPending || void 0
|
|
1129
|
+
});
|
|
1130
|
+
this.releaseLocalCandidateBatch();
|
|
1131
|
+
const sdp = await answered;
|
|
1132
|
+
this.assertActiveRun(generation, pc);
|
|
1133
|
+
answerReceived = true;
|
|
1134
|
+
await pc.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp }));
|
|
1135
|
+
this.assertActiveRun(generation, pc);
|
|
1136
|
+
this.applyAnswered(pc);
|
|
1137
|
+
await change?.commit?.();
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
let rollbackFailed = false;
|
|
1140
|
+
if (localOfferSet && this.pc === pc && pc.signalingState === "have-local-offer") {
|
|
1141
|
+
try {
|
|
1142
|
+
await pc.setLocalDescription({ type: "rollback" });
|
|
1143
|
+
} catch {
|
|
1144
|
+
rollbackFailed = true;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
try {
|
|
1148
|
+
await change?.rollback?.();
|
|
1149
|
+
} catch {
|
|
1150
|
+
rollbackFailed = true;
|
|
1151
|
+
}
|
|
1152
|
+
const ambiguous = rollbackFailed || answerReceived || err instanceof NegotiationTimeoutError || err instanceof SenderRestoreError || err instanceof ReportedPublisherError && err.fatal;
|
|
1153
|
+
if (this.isActiveRun(generation, pc) && ambiguous) {
|
|
1154
|
+
try {
|
|
1155
|
+
await change?.discard?.();
|
|
1156
|
+
} catch {
|
|
1157
|
+
}
|
|
1158
|
+
const failure = err instanceof Error ? err : new Error(String(err));
|
|
1159
|
+
this.terminateWithError(
|
|
1160
|
+
failure,
|
|
1161
|
+
!(err instanceof ReportedPublisherError),
|
|
1162
|
+
generation
|
|
1163
|
+
);
|
|
1164
|
+
} else if (this.isActiveRun(generation, pc)) {
|
|
1165
|
+
const buffered = this.pendingRemoteCandidates;
|
|
1166
|
+
this.hasAnswer = previousHasAnswer;
|
|
1167
|
+
this.pendingRemoteCandidates = previousRemoteCandidates;
|
|
1168
|
+
this.pendingLocalCandidates = [];
|
|
1169
|
+
this.localCandidateOfferSent = previousLocalCandidateOfferSent;
|
|
1170
|
+
this.retainedLocalCandidates = previousRetainedLocalCandidates;
|
|
1171
|
+
this.retainedLocalCandidateKeys = previousRetainedLocalCandidateKeys;
|
|
1172
|
+
this.localCandidateGeneration = previousLocalCandidateGeneration;
|
|
1173
|
+
if (previousHasAnswer) {
|
|
1174
|
+
for (const init of buffered) {
|
|
1175
|
+
pc.addIceCandidate(init).catch(() => {
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
throw err;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Registers interest in the answer for the offer identified by `id` and returns
|
|
1185
|
+
* a promise for its SDP. A second pending answer is an invariant violation: all
|
|
1186
|
+
* offer creation, including recovery, must pass through negotiationChain.
|
|
1187
|
+
*/
|
|
1188
|
+
nextNegotiationId() {
|
|
1189
|
+
return `n${++this.negotiationSeq}`;
|
|
1190
|
+
}
|
|
1191
|
+
handleTextMessage(data) {
|
|
1192
|
+
if (typeof data !== "string") return;
|
|
1193
|
+
try {
|
|
1194
|
+
const message = JSON.parse(data);
|
|
1195
|
+
if (message.type === "assistant_text" && message.utterance_id && message.text) {
|
|
1196
|
+
this.opts.callbacks?.onAssistantText?.({ utteranceId: message.utterance_id, text: message.text });
|
|
1197
|
+
} else if ((message.type === "user_text_accepted" || message.type === "user_text_rejected") && message.message_id) {
|
|
1198
|
+
this.opts.callbacks?.onUserTextResult?.({
|
|
1199
|
+
messageId: message.message_id,
|
|
1200
|
+
accepted: message.type === "user_text_accepted",
|
|
1201
|
+
reason: message.reason
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
} catch {
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
awaitAnswer(id) {
|
|
1208
|
+
if (this.pendingAnswer) {
|
|
1209
|
+
return Promise.reject(new Error("another negotiation is already awaiting an answer"));
|
|
1210
|
+
}
|
|
1211
|
+
return new Promise((resolve, reject) => {
|
|
1212
|
+
const timer = setTimeout(() => {
|
|
1213
|
+
if (this.pendingAnswer?.id === id) {
|
|
1214
|
+
this.pendingAnswer = null;
|
|
1215
|
+
reject(new NegotiationTimeoutError("timed out waiting for renegotiation answer"));
|
|
1216
|
+
}
|
|
1217
|
+
}, this.negotiationAnswerTimeoutMs());
|
|
1218
|
+
this.pendingAnswer = {
|
|
1219
|
+
id,
|
|
1220
|
+
resolve: (sdp) => {
|
|
1221
|
+
clearTimeout(timer);
|
|
1222
|
+
resolve(sdp);
|
|
1223
|
+
},
|
|
1224
|
+
reject: (err) => {
|
|
1225
|
+
clearTimeout(timer);
|
|
1226
|
+
reject(err);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
negotiationAnswerTimeoutMs() {
|
|
1232
|
+
const reconnectTimeout = this.opts.signalingReconnectTimeoutMs ?? defaultSignalingReconnectTimeoutMs;
|
|
1233
|
+
return Math.max(
|
|
1234
|
+
minimumNegotiationAnswerTimeoutMs,
|
|
1235
|
+
Math.max(0, reconnectTimeout) + negotiationReconnectGraceMs
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
handleLocalICECandidate(candidate) {
|
|
1239
|
+
const message = {
|
|
1240
|
+
type: "ice_candidate",
|
|
1241
|
+
candidate: candidate.candidate,
|
|
1242
|
+
sdp_mid: candidate.sdpMid ?? void 0,
|
|
1243
|
+
sdp_mline_index: candidate.sdpMLineIndex ?? void 0,
|
|
1244
|
+
username_fragment: candidate.usernameFragment ?? void 0
|
|
1245
|
+
};
|
|
1246
|
+
if (!this.retainLocalCandidate(message)) return;
|
|
1247
|
+
if (!this.localCandidateOfferSent) {
|
|
1248
|
+
this.pendingLocalCandidates.push(message);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
void this.sendWhenSignalingAvailable(message).catch(() => {
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
candidateKey(candidate) {
|
|
1255
|
+
return JSON.stringify([
|
|
1256
|
+
candidate.candidate,
|
|
1257
|
+
candidate.sdp_mid ?? null,
|
|
1258
|
+
candidate.sdp_mline_index ?? null,
|
|
1259
|
+
candidate.username_fragment ?? null
|
|
1260
|
+
]);
|
|
1261
|
+
}
|
|
1262
|
+
retainLocalCandidate(candidate) {
|
|
1263
|
+
const generation = candidate.username_fragment;
|
|
1264
|
+
if (generation) {
|
|
1265
|
+
if (this.localCandidateGeneration && this.localCandidateGeneration !== generation) {
|
|
1266
|
+
this.retainedLocalCandidates = [];
|
|
1267
|
+
this.retainedLocalCandidateKeys.clear();
|
|
1268
|
+
}
|
|
1269
|
+
this.localCandidateGeneration = generation;
|
|
1270
|
+
}
|
|
1271
|
+
const key = this.candidateKey(candidate);
|
|
1272
|
+
if (this.retainedLocalCandidateKeys.has(key)) return false;
|
|
1273
|
+
if (this.retainedLocalCandidates.length === maxRetainedICECandidates) {
|
|
1274
|
+
const evicted = this.retainedLocalCandidates.shift();
|
|
1275
|
+
if (evicted) this.retainedLocalCandidateKeys.delete(this.candidateKey(evicted));
|
|
1276
|
+
}
|
|
1277
|
+
this.retainedLocalCandidates.push(candidate);
|
|
1278
|
+
this.retainedLocalCandidateKeys.add(key);
|
|
1279
|
+
return true;
|
|
1280
|
+
}
|
|
1281
|
+
retainRemoteCandidate(candidate) {
|
|
1282
|
+
const generation = candidate.username_fragment;
|
|
1283
|
+
if (generation) {
|
|
1284
|
+
if (this.remoteCandidateGeneration && this.remoteCandidateGeneration !== generation) {
|
|
1285
|
+
this.remoteCandidateKeys.clear();
|
|
1286
|
+
this.remoteCandidateOrder = [];
|
|
1287
|
+
}
|
|
1288
|
+
this.remoteCandidateGeneration = generation;
|
|
1289
|
+
}
|
|
1290
|
+
const key = this.candidateKey(candidate);
|
|
1291
|
+
if (this.remoteCandidateKeys.has(key)) return false;
|
|
1292
|
+
if (this.remoteCandidateOrder.length === maxRetainedICECandidates) {
|
|
1293
|
+
const evicted = this.remoteCandidateOrder.shift();
|
|
1294
|
+
if (evicted) this.remoteCandidateKeys.delete(evicted);
|
|
1295
|
+
}
|
|
1296
|
+
this.remoteCandidateOrder.push(key);
|
|
1297
|
+
this.remoteCandidateKeys.add(key);
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
clearRetainedICECandidates() {
|
|
1301
|
+
this.retainedLocalCandidates = [];
|
|
1302
|
+
this.retainedLocalCandidateKeys.clear();
|
|
1303
|
+
this.localCandidateGeneration = null;
|
|
1304
|
+
this.remoteCandidateKeys.clear();
|
|
1305
|
+
this.remoteCandidateOrder = [];
|
|
1306
|
+
this.remoteCandidateGeneration = null;
|
|
1307
|
+
}
|
|
1308
|
+
beginLocalCandidateBatch() {
|
|
1309
|
+
this.localCandidateOfferSent = false;
|
|
1310
|
+
this.pendingLocalCandidates = [];
|
|
1311
|
+
}
|
|
1312
|
+
releaseLocalCandidateBatch() {
|
|
1313
|
+
this.localCandidateOfferSent = true;
|
|
1314
|
+
const candidates = this.pendingLocalCandidates;
|
|
1315
|
+
this.pendingLocalCandidates = [];
|
|
1316
|
+
for (const candidate of candidates) {
|
|
1317
|
+
void this.sendWhenSignalingAvailable(candidate).catch(() => {
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
// applyAnswered flushes ICE candidates buffered before the answer landed.
|
|
1322
|
+
applyAnswered(pc) {
|
|
1323
|
+
if (this.pc !== pc) return;
|
|
1324
|
+
this.hasAnswer = true;
|
|
1325
|
+
for (const init of this.pendingRemoteCandidates) {
|
|
1326
|
+
pc.addIceCandidate(init).catch(() => {
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
479
1329
|
this.pendingRemoteCandidates = [];
|
|
480
|
-
|
|
1330
|
+
this.watchSelectedICEPairChanges(pc);
|
|
1331
|
+
void this.reportSelectedICEPath(pc);
|
|
1332
|
+
}
|
|
1333
|
+
watchSelectedICEPairChanges(pc) {
|
|
1334
|
+
try {
|
|
1335
|
+
const dtlsTransports = [
|
|
1336
|
+
pc.sctp?.transport,
|
|
1337
|
+
...pc.getSenders().map((sender) => sender.transport),
|
|
1338
|
+
...pc.getReceivers().map((receiver) => receiver.transport)
|
|
1339
|
+
];
|
|
1340
|
+
for (const dtls of dtlsTransports) {
|
|
1341
|
+
const ice = dtls?.iceTransport;
|
|
1342
|
+
if (!ice || this.watchedICETransports.has(ice)) continue;
|
|
1343
|
+
this.watchedICETransports.add(ice);
|
|
1344
|
+
ice.addEventListener("selectedcandidatepairchange", () => {
|
|
1345
|
+
void this.reportSelectedICEPath(pc);
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
} catch {
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
async reportSelectedICEPath(pc) {
|
|
1352
|
+
if (this.pc !== pc || this.stopped) return;
|
|
1353
|
+
let stats;
|
|
1354
|
+
try {
|
|
1355
|
+
stats = await pc.getStats();
|
|
1356
|
+
} catch {
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (this.pc !== pc || this.stopped) return;
|
|
1360
|
+
let selectedPairID;
|
|
1361
|
+
let selectedPair;
|
|
1362
|
+
stats.forEach((report) => {
|
|
1363
|
+
const value = report;
|
|
1364
|
+
if (value.type === "transport" && typeof value.selectedCandidatePairId === "string") {
|
|
1365
|
+
selectedPairID = value.selectedCandidatePairId;
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
if (selectedPairID) {
|
|
1369
|
+
selectedPair = stats.get(selectedPairID);
|
|
1370
|
+
}
|
|
1371
|
+
if (!selectedPair) {
|
|
1372
|
+
stats.forEach((report) => {
|
|
1373
|
+
const value = report;
|
|
1374
|
+
if (!selectedPair && value.type === "candidate-pair" && value.state === "succeeded" && value.nominated === true) {
|
|
1375
|
+
selectedPair = value;
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
if (!selectedPair) return;
|
|
1380
|
+
const localID = selectedPair.localCandidateId;
|
|
1381
|
+
const remoteID = selectedPair.remoteCandidateId;
|
|
1382
|
+
if (typeof localID !== "string") return;
|
|
1383
|
+
const local = stats.get(localID);
|
|
1384
|
+
const remote = typeof remoteID === "string" ? stats.get(remoteID) : void 0;
|
|
1385
|
+
if (!local || typeof local.candidateType !== "string") return;
|
|
1386
|
+
const message = {
|
|
1387
|
+
type: "ice_path",
|
|
1388
|
+
local_candidate_type: local.candidateType,
|
|
1389
|
+
local_protocol: typeof local.protocol === "string" ? local.protocol : void 0,
|
|
1390
|
+
remote_candidate_type: typeof remote?.candidateType === "string" ? remote.candidateType : void 0,
|
|
1391
|
+
remote_protocol: typeof remote?.protocol === "string" ? remote.protocol : void 0,
|
|
1392
|
+
relay_protocol: typeof local.relayProtocol === "string" ? local.relayProtocol : void 0,
|
|
1393
|
+
turn_url: typeof local.url === "string" ? local.url : void 0
|
|
1394
|
+
};
|
|
1395
|
+
const fingerprint = JSON.stringify(message);
|
|
1396
|
+
if (fingerprint === this.lastReportedICEPath) return;
|
|
1397
|
+
this.lastReportedICEPath = fingerprint;
|
|
1398
|
+
try {
|
|
1399
|
+
await this.sendWhenSignalingAvailable(message);
|
|
1400
|
+
} catch {
|
|
1401
|
+
if (this.lastReportedICEPath === fingerprint) {
|
|
1402
|
+
this.lastReportedICEPath = null;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
481
1405
|
}
|
|
482
1406
|
completeMediaRecovery(trackType) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1407
|
+
const state = this.recoveryStates.get(trackType);
|
|
1408
|
+
if (!state?.recovering) return;
|
|
1409
|
+
const action = state.action ?? void 0;
|
|
1410
|
+
this.cancelMediaRecovery(trackType);
|
|
486
1411
|
this.emitRecoveryTransition({ state: "recovered", track: trackType, action });
|
|
487
1412
|
}
|
|
488
1413
|
failMediaRecovery(trackType, reason) {
|
|
489
|
-
|
|
490
|
-
this.
|
|
491
|
-
|
|
492
|
-
|
|
1414
|
+
const state = this.recoveryState(trackType);
|
|
1415
|
+
if (this.stopped || state.required) return;
|
|
1416
|
+
state.required = true;
|
|
1417
|
+
const action = state.action ?? void 0;
|
|
1418
|
+
this.cancelMediaRecovery(trackType);
|
|
493
1419
|
const event = { state: "failed", track: trackType, action, reason };
|
|
494
1420
|
this.emitRecoveryTransition(event);
|
|
495
1421
|
this.opts.callbacks?.onRecoveryRequired?.(event);
|
|
496
1422
|
this.sendRecoveryDiagnostic("recovery_failed", trackType, action, reason);
|
|
497
1423
|
}
|
|
498
|
-
|
|
499
|
-
this.
|
|
500
|
-
|
|
501
|
-
|
|
1424
|
+
recoveryState(trackType) {
|
|
1425
|
+
let state = this.recoveryStates.get(trackType);
|
|
1426
|
+
if (!state) {
|
|
1427
|
+
state = { generation: 0, recovering: false, required: false, action: null };
|
|
1428
|
+
this.recoveryStates.set(trackType, state);
|
|
1429
|
+
}
|
|
1430
|
+
return state;
|
|
1431
|
+
}
|
|
1432
|
+
cancelMediaRecovery(trackType) {
|
|
1433
|
+
const state = this.recoveryState(trackType);
|
|
1434
|
+
state.generation = ++this.recoverySequence;
|
|
1435
|
+
state.recovering = false;
|
|
1436
|
+
state.action = null;
|
|
1437
|
+
this.clearSharedIceRestartIfIdle();
|
|
1438
|
+
}
|
|
1439
|
+
cancelAllMediaRecovery() {
|
|
1440
|
+
for (const trackType of this.recoveryStates.keys()) {
|
|
1441
|
+
this.cancelMediaRecovery(trackType);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
isCurrentRecovery(trackType, generation) {
|
|
1445
|
+
const state = this.recoveryStates.get(trackType);
|
|
1446
|
+
return !this.stopped && !!state?.recovering && state.generation === generation;
|
|
502
1447
|
}
|
|
503
|
-
|
|
504
|
-
|
|
1448
|
+
hasActiveMediaRecovery() {
|
|
1449
|
+
for (const state of this.recoveryStates.values()) {
|
|
1450
|
+
if (state.recovering) return true;
|
|
1451
|
+
}
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
clearSharedIceRestartIfIdle() {
|
|
1455
|
+
if (!this.hasActiveMediaRecovery()) this.iceRestartAttempt = null;
|
|
505
1456
|
}
|
|
506
1457
|
emitRecoveryTransition(event) {
|
|
507
1458
|
this.opts.callbacks?.onRecoveryStateChange?.(event);
|
|
@@ -509,39 +1460,354 @@ var Publisher = class {
|
|
|
509
1460
|
sendRecoveryDiagnostic(event, track, action, reason) {
|
|
510
1461
|
this.sig?.send({ type: "recovery_event", event, track, action, reason });
|
|
511
1462
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
1463
|
+
// -------------------------------------------------------------------------
|
|
1464
|
+
// Published-track bookkeeping
|
|
1465
|
+
// -------------------------------------------------------------------------
|
|
1466
|
+
requireSingleVideoTrack(stream) {
|
|
1467
|
+
const tracks = stream.getVideoTracks();
|
|
1468
|
+
if (tracks.length !== 1) {
|
|
1469
|
+
throw new Error(
|
|
1470
|
+
`expected exactly one video track, received ${tracks.length}`
|
|
1471
|
+
);
|
|
517
1472
|
}
|
|
1473
|
+
this.requireLiveVideoTrack(tracks[0]);
|
|
1474
|
+
return tracks[0];
|
|
518
1475
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
1476
|
+
requireLiveVideoTrack(track) {
|
|
1477
|
+
if (track.readyState === "ended") {
|
|
1478
|
+
throw new Error("video track has already ended");
|
|
522
1479
|
}
|
|
523
|
-
this.trackEndHandlers.clear();
|
|
524
1480
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
1481
|
+
requireLiveTrack(track, kind) {
|
|
1482
|
+
if (track.readyState === "ended") {
|
|
1483
|
+
throw new Error(`${kind} track has already ended`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
requireSingleAudioTrack(stream) {
|
|
1487
|
+
const tracks = stream.getAudioTracks();
|
|
1488
|
+
if (tracks.length !== 1) {
|
|
1489
|
+
throw new Error(
|
|
1490
|
+
`expected exactly one audio track, received ${tracks.length}`
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
if (tracks[0].readyState === "ended") {
|
|
1494
|
+
throw new Error("audio track has already ended");
|
|
1495
|
+
}
|
|
1496
|
+
return tracks[0];
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Stages the microphone track onto the peer connection. Unlike video, audio
|
|
1500
|
+
* has no recovery ladder and no SSIM/frame semantics, so this simply adds (or
|
|
1501
|
+
* replaces) the single audio sender and declares the updated labels.
|
|
1502
|
+
*/
|
|
1503
|
+
async stagePublishAudio(track, stream) {
|
|
1504
|
+
const pc = this.pc;
|
|
1505
|
+
if (!pc) throw new Error("publisher not started");
|
|
1506
|
+
if (track.readyState === "ended") throw new Error("audio track has already ended");
|
|
1507
|
+
const type = "audio";
|
|
1508
|
+
if (this.published.get(track) === type) return false;
|
|
1509
|
+
const previous = this.tracksOfType(type).map((oldTrack) => ({
|
|
1510
|
+
track: oldTrack,
|
|
1511
|
+
stream: this.publishedStreams.get(oldTrack)
|
|
1512
|
+
}));
|
|
1513
|
+
const typeSender = this.typeSenders.get(type) ?? null;
|
|
1514
|
+
let addedSender = null;
|
|
1515
|
+
let replacedSender = null;
|
|
1516
|
+
if (previous.length > 0) {
|
|
1517
|
+
if (previous.length !== 1 || !typeSender || typeSender.track !== previous[0].track) {
|
|
1518
|
+
throw new SenderRestoreError("published audio sender is unavailable");
|
|
532
1519
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1520
|
+
replacedSender = typeSender;
|
|
1521
|
+
await replacedSender.replaceTrack(track);
|
|
1522
|
+
} else {
|
|
1523
|
+
if (typeSender?.track) {
|
|
1524
|
+
throw new SenderRestoreError("inactive audio sender still has a track");
|
|
536
1525
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1526
|
+
if (this.microphoneTransceiver && this.microphoneTransceiver.sender.track === null) {
|
|
1527
|
+
addedSender = this.microphoneTransceiver.sender;
|
|
1528
|
+
await addedSender.replaceTrack(track);
|
|
1529
|
+
this.microphoneTransceiver.direction = "sendonly";
|
|
1530
|
+
} else {
|
|
1531
|
+
addedSender = this.addMicrophoneTrack(pc, track, stream);
|
|
1532
|
+
}
|
|
1533
|
+
this.typeSenders.set(type, addedSender);
|
|
1534
|
+
}
|
|
1535
|
+
return {
|
|
1536
|
+
labels: this.labelsReplacingType(type, track),
|
|
1537
|
+
commit: () => {
|
|
1538
|
+
for (const { track: oldTrack } of previous) {
|
|
1539
|
+
this.unwatchTrack(oldTrack);
|
|
1540
|
+
this.published.delete(oldTrack);
|
|
1541
|
+
this.publishedStreams.delete(oldTrack);
|
|
1542
|
+
oldTrack.stop();
|
|
541
1543
|
}
|
|
542
|
-
|
|
543
|
-
|
|
1544
|
+
this.published.set(track, type);
|
|
1545
|
+
this.publishedStreams.set(track, stream);
|
|
1546
|
+
this.watchMicrophone(track);
|
|
1547
|
+
},
|
|
1548
|
+
rollback: async () => {
|
|
1549
|
+
if (this.pc !== pc) return;
|
|
1550
|
+
if (addedSender && pc.getSenders().includes(addedSender)) {
|
|
1551
|
+
pc.removeTrack(addedSender);
|
|
1552
|
+
if (typeSender) this.typeSenders.set(type, typeSender);
|
|
1553
|
+
else this.typeSenders.delete(type);
|
|
1554
|
+
}
|
|
1555
|
+
if (replacedSender && previous.length > 0) {
|
|
1556
|
+
const oldTrack = previous[0].track;
|
|
1557
|
+
if (oldTrack.readyState !== "ended") {
|
|
1558
|
+
await replacedSender.replaceTrack(oldTrack);
|
|
1559
|
+
}
|
|
1560
|
+
} else if (replacedSender) {
|
|
1561
|
+
pc.removeTrack(replacedSender);
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
discard: () => track.stop()
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Gives the microphone its own sendonly transceiver. addTrack() may reuse an
|
|
1569
|
+
* existing compatible recvonly transceiver, which would collapse the
|
|
1570
|
+
* microphone and assistant speech roles when speech was enabled first.
|
|
1571
|
+
* addTransceiver() always creates a distinct m-line and its direction keeps
|
|
1572
|
+
* the server's outbound speech sender on the dedicated speech transceiver.
|
|
1573
|
+
*/
|
|
1574
|
+
addMicrophoneTrack(pc, track, stream) {
|
|
1575
|
+
const transceiver = pc.addTransceiver(track, {
|
|
1576
|
+
direction: "sendonly",
|
|
1577
|
+
streams: [stream]
|
|
544
1578
|
});
|
|
1579
|
+
this.microphoneTransceiver = transceiver;
|
|
1580
|
+
return transceiver.sender;
|
|
1581
|
+
}
|
|
1582
|
+
/** Registers one physical video track under its logical type and source stream. */
|
|
1583
|
+
registerTrack(track, stream, type) {
|
|
1584
|
+
this.published.set(track, type);
|
|
1585
|
+
this.publishedStreams.set(track, stream);
|
|
1586
|
+
this.watchTrack(track);
|
|
1587
|
+
}
|
|
1588
|
+
/** The live video tracks currently published under the given type. */
|
|
1589
|
+
tracksOfType(type) {
|
|
1590
|
+
const out = [];
|
|
1591
|
+
for (const [track, tt] of this.published) {
|
|
1592
|
+
if (tt === type) out.push(track);
|
|
1593
|
+
}
|
|
1594
|
+
return out;
|
|
1595
|
+
}
|
|
1596
|
+
/** Builds the id → type label array declared to the server on every offer. */
|
|
1597
|
+
buildTrackLabels() {
|
|
1598
|
+
const labels = [];
|
|
1599
|
+
for (const [track, type] of this.published) {
|
|
1600
|
+
labels.push({ id: track.id, type });
|
|
1601
|
+
}
|
|
1602
|
+
return labels;
|
|
1603
|
+
}
|
|
1604
|
+
labelsReplacingType(type, replacement) {
|
|
1605
|
+
const labels = [];
|
|
1606
|
+
for (const [track, publishedType] of this.published) {
|
|
1607
|
+
if (publishedType !== type) labels.push({ id: track.id, type: publishedType });
|
|
1608
|
+
}
|
|
1609
|
+
if (replacement) labels.push({ id: replacement.id, type });
|
|
1610
|
+
return labels;
|
|
1611
|
+
}
|
|
1612
|
+
async stagePublish(track, stream, type) {
|
|
1613
|
+
const pc = this.pc;
|
|
1614
|
+
if (!pc) throw new Error("publisher not started");
|
|
1615
|
+
this.requireLiveVideoTrack(track);
|
|
1616
|
+
const alreadyPublishedAs = this.published.get(track);
|
|
1617
|
+
if (alreadyPublishedAs === type) return false;
|
|
1618
|
+
if (alreadyPublishedAs) {
|
|
1619
|
+
throw new Error(`video track is already published as ${alreadyPublishedAs}`);
|
|
1620
|
+
}
|
|
1621
|
+
const previous = this.tracksOfType(type).map((oldTrack) => ({
|
|
1622
|
+
track: oldTrack,
|
|
1623
|
+
stream: this.publishedStreams.get(oldTrack)
|
|
1624
|
+
}));
|
|
1625
|
+
const typeSender = this.typeSenders.get(type) ?? null;
|
|
1626
|
+
let addedSender = null;
|
|
1627
|
+
let replacedSender = null;
|
|
1628
|
+
if (previous.length > 0) {
|
|
1629
|
+
if (previous.length !== 1 || !typeSender || typeSender.track !== previous[0].track) {
|
|
1630
|
+
throw new SenderRestoreError(`published ${type} sender is unavailable`);
|
|
1631
|
+
}
|
|
1632
|
+
replacedSender = typeSender;
|
|
1633
|
+
await replacedSender.replaceTrack(track);
|
|
1634
|
+
} else {
|
|
1635
|
+
if (typeSender?.track) {
|
|
1636
|
+
throw new SenderRestoreError(`inactive ${type} sender still has a track`);
|
|
1637
|
+
}
|
|
1638
|
+
addedSender = pc.addTrack(track, stream);
|
|
1639
|
+
this.typeSenders.set(type, addedSender);
|
|
1640
|
+
}
|
|
1641
|
+
return {
|
|
1642
|
+
labels: this.labelsReplacingType(type, track),
|
|
1643
|
+
commit: () => {
|
|
1644
|
+
this.cancelMediaRecovery(type);
|
|
1645
|
+
this.recoveryState(type).required = false;
|
|
1646
|
+
for (const { track: oldTrack } of previous) {
|
|
1647
|
+
this.unwatchTrack(oldTrack);
|
|
1648
|
+
this.published.delete(oldTrack);
|
|
1649
|
+
this.publishedStreams.delete(oldTrack);
|
|
1650
|
+
oldTrack.stop();
|
|
1651
|
+
}
|
|
1652
|
+
this.registerTrack(track, stream, type);
|
|
1653
|
+
if (track.readyState === "ended") {
|
|
1654
|
+
this.failMediaRecovery(type, "capture_ended");
|
|
1655
|
+
}
|
|
1656
|
+
},
|
|
1657
|
+
rollback: async () => {
|
|
1658
|
+
if (this.pc !== pc) return;
|
|
1659
|
+
if (addedSender && pc.getSenders().includes(addedSender)) {
|
|
1660
|
+
pc.removeTrack(addedSender);
|
|
1661
|
+
if (typeSender) this.typeSenders.set(type, typeSender);
|
|
1662
|
+
else this.typeSenders.delete(type);
|
|
1663
|
+
}
|
|
1664
|
+
if (replacedSender && previous.length > 0) {
|
|
1665
|
+
const oldTrack = previous[0].track;
|
|
1666
|
+
if (oldTrack.readyState !== "ended") {
|
|
1667
|
+
await replacedSender.replaceTrack(oldTrack);
|
|
1668
|
+
}
|
|
1669
|
+
} else if (replacedSender) {
|
|
1670
|
+
pc.removeTrack(replacedSender);
|
|
1671
|
+
}
|
|
1672
|
+
},
|
|
1673
|
+
discard: () => track.stop()
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
stageUnpublish(type) {
|
|
1677
|
+
const pc = this.pc;
|
|
1678
|
+
if (!pc) throw new Error("publisher not started");
|
|
1679
|
+
const previous = this.tracksOfType(type).map((track) => ({
|
|
1680
|
+
track,
|
|
1681
|
+
stream: this.publishedStreams.get(track)
|
|
1682
|
+
}));
|
|
1683
|
+
const typeSender = this.typeSenders.get(type);
|
|
1684
|
+
if (previous.length !== 1 || !typeSender || typeSender.track !== previous[0].track) {
|
|
1685
|
+
throw new SenderRestoreError(`published ${type} sender is unavailable`);
|
|
1686
|
+
}
|
|
1687
|
+
for (const { track } of previous) this.expectIntentionalTrackEnd(track.id, type);
|
|
1688
|
+
pc.removeTrack(typeSender);
|
|
1689
|
+
return {
|
|
1690
|
+
labels: this.labelsReplacingType(type),
|
|
1691
|
+
commit: () => {
|
|
1692
|
+
this.cancelMediaRecovery(type);
|
|
1693
|
+
this.recoveryState(type).required = false;
|
|
1694
|
+
this.typeSenders.delete(type);
|
|
1695
|
+
for (const { track } of previous) {
|
|
1696
|
+
this.unwatchTrack(track);
|
|
1697
|
+
this.published.delete(track);
|
|
1698
|
+
this.publishedStreams.delete(track);
|
|
1699
|
+
track.stop();
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
rollback: () => {
|
|
1703
|
+
if (this.pc !== pc) return;
|
|
1704
|
+
this.forgetIntentionalTrackEnds(previous.map(({ track }) => track.id));
|
|
1705
|
+
return Promise.all(previous.map(async ({ track }) => {
|
|
1706
|
+
if (track.readyState === "ended") return;
|
|
1707
|
+
await typeSender.replaceTrack(track);
|
|
1708
|
+
})).then(() => void 0);
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
/** Stops every published local track and clears the published map. */
|
|
1713
|
+
stopPublishedTracks() {
|
|
1714
|
+
for (const track of this.published.keys()) {
|
|
1715
|
+
track.stop();
|
|
1716
|
+
}
|
|
1717
|
+
this.published.clear();
|
|
1718
|
+
this.publishedStreams.clear();
|
|
1719
|
+
}
|
|
1720
|
+
expectIntentionalTrackEnd(trackID, type) {
|
|
1721
|
+
const prior = this.intentionalTrackEnds.get(trackID);
|
|
1722
|
+
if (prior) clearTimeout(prior.timer);
|
|
1723
|
+
const retentionMs = Math.max(
|
|
1724
|
+
minimumIntentionalTrackEndRetentionMs,
|
|
1725
|
+
this.negotiationAnswerTimeoutMs()
|
|
1726
|
+
);
|
|
1727
|
+
const timer = setTimeout(() => {
|
|
1728
|
+
const current = this.intentionalTrackEnds.get(trackID);
|
|
1729
|
+
if (current?.timer === timer) this.intentionalTrackEnds.delete(trackID);
|
|
1730
|
+
}, retentionMs);
|
|
1731
|
+
this.intentionalTrackEnds.set(trackID, { type, timer });
|
|
1732
|
+
}
|
|
1733
|
+
forgetIntentionalTrackEnds(trackIDs) {
|
|
1734
|
+
for (const trackID of trackIDs) {
|
|
1735
|
+
const expected = this.intentionalTrackEnds.get(trackID);
|
|
1736
|
+
if (!expected) continue;
|
|
1737
|
+
clearTimeout(expected.timer);
|
|
1738
|
+
this.intentionalTrackEnds.delete(trackID);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
consumeIntentionalTrackEnd(type, trackID) {
|
|
1742
|
+
if (trackID) {
|
|
1743
|
+
const expected = this.intentionalTrackEnds.get(trackID);
|
|
1744
|
+
if (!expected || expected.type !== type) return false;
|
|
1745
|
+
clearTimeout(expected.timer);
|
|
1746
|
+
this.intentionalTrackEnds.delete(trackID);
|
|
1747
|
+
return true;
|
|
1748
|
+
}
|
|
1749
|
+
for (const [id, expected] of this.intentionalTrackEnds) {
|
|
1750
|
+
if (expected.type !== type) continue;
|
|
1751
|
+
clearTimeout(expected.timer);
|
|
1752
|
+
this.intentionalTrackEnds.delete(id);
|
|
1753
|
+
return true;
|
|
1754
|
+
}
|
|
1755
|
+
return false;
|
|
1756
|
+
}
|
|
1757
|
+
clearIntentionalTrackEnds() {
|
|
1758
|
+
for (const expected of this.intentionalTrackEnds.values()) {
|
|
1759
|
+
clearTimeout(expected.timer);
|
|
1760
|
+
}
|
|
1761
|
+
this.intentionalTrackEnds.clear();
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Watches a track's "ended" event so an involuntary capture stop (the user
|
|
1765
|
+
* revokes a screen share, a device unplugs) reports as a recovery failure for
|
|
1766
|
+
* that track's actual type. Intentional removals unwatch first.
|
|
1767
|
+
*/
|
|
1768
|
+
watchTrack(track) {
|
|
1769
|
+
if (this.trackEndHandlers.has(track)) return;
|
|
1770
|
+
const handler = () => {
|
|
1771
|
+
const type = this.published.get(track) ?? "camera";
|
|
1772
|
+
this.failMediaRecovery(type, "capture_ended");
|
|
1773
|
+
};
|
|
1774
|
+
track.addEventListener("ended", handler);
|
|
1775
|
+
this.trackEndHandlers.set(track, handler);
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Watches a microphone only for lifecycle removal. An ended microphone is not
|
|
1779
|
+
* recovered like video; it is negotiated away so the media server can flush
|
|
1780
|
+
* the utterance and release transcription resources.
|
|
1781
|
+
*/
|
|
1782
|
+
watchMicrophone(track) {
|
|
1783
|
+
if (this.trackEndHandlers.has(track)) return;
|
|
1784
|
+
const generation = this.lifecycleGeneration;
|
|
1785
|
+
const handler = () => {
|
|
1786
|
+
if (this.published.get(track) !== "audio") return;
|
|
1787
|
+
void this.enqueueNegotiation(() => {
|
|
1788
|
+
if (this.published.get(track) !== "audio") return false;
|
|
1789
|
+
return this.stageUnpublish("audio");
|
|
1790
|
+
}).catch((err) => {
|
|
1791
|
+
if (!this.isActiveRun(generation)) return;
|
|
1792
|
+
const failure = err instanceof Error ? err : new Error(String(err));
|
|
1793
|
+
this.terminateWithError(failure, true, generation);
|
|
1794
|
+
});
|
|
1795
|
+
};
|
|
1796
|
+
track.addEventListener("ended", handler);
|
|
1797
|
+
this.trackEndHandlers.set(track, handler);
|
|
1798
|
+
if (track.readyState === "ended") handler(new Event("ended"));
|
|
1799
|
+
}
|
|
1800
|
+
unwatchTrack(track) {
|
|
1801
|
+
const handler = this.trackEndHandlers.get(track);
|
|
1802
|
+
if (!handler) return;
|
|
1803
|
+
track.removeEventListener("ended", handler);
|
|
1804
|
+
this.trackEndHandlers.delete(track);
|
|
1805
|
+
}
|
|
1806
|
+
unwatchStreamTracks() {
|
|
1807
|
+
for (const [track, handler] of this.trackEndHandlers) {
|
|
1808
|
+
track.removeEventListener("ended", handler);
|
|
1809
|
+
}
|
|
1810
|
+
this.trackEndHandlers.clear();
|
|
545
1811
|
}
|
|
546
1812
|
};
|
|
547
1813
|
|
|
@@ -569,10 +1835,23 @@ async function captureScreen(opts = {}) {
|
|
|
569
1835
|
const mediaDevices = opts.mediaDevices ?? navigator.mediaDevices;
|
|
570
1836
|
return mediaDevices.getDisplayMedia(constraints);
|
|
571
1837
|
}
|
|
1838
|
+
async function captureMicrophone(opts = {}) {
|
|
1839
|
+
const constraints = {
|
|
1840
|
+
audio: opts.audio ?? {
|
|
1841
|
+
echoCancellation: true,
|
|
1842
|
+
noiseSuppression: true,
|
|
1843
|
+
autoGainControl: true
|
|
1844
|
+
},
|
|
1845
|
+
video: false
|
|
1846
|
+
};
|
|
1847
|
+
const mediaDevices = opts.mediaDevices ?? navigator.mediaDevices;
|
|
1848
|
+
return mediaDevices.getUserMedia(constraints);
|
|
1849
|
+
}
|
|
572
1850
|
// Annotate the CommonJS export names for ESM import in node:
|
|
573
1851
|
0 && (module.exports = {
|
|
574
1852
|
Publisher,
|
|
575
1853
|
captureCamera,
|
|
1854
|
+
captureMicrophone,
|
|
576
1855
|
captureScreen
|
|
577
1856
|
});
|
|
578
1857
|
//# sourceMappingURL=index.cjs.map
|