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