@furious.luke/argus-js 0.4.0 → 0.5.1

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