@sphoro/bugcatch-web-sdk 0.13.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.
@@ -0,0 +1,870 @@
1
+ // Two people, talking directly.
2
+ //
3
+ // The agent call next door is a microphone, a socket and a codec: the browser
4
+ // sends µ-law up to sphoro.voice, a model answers, and every sample passes
5
+ // through a server. This is the opposite arrangement. The audio here goes
6
+ // straight from one browser to the other over WebRTC, and sphoro.voice's part is
7
+ // only to introduce them — it passes an offer, an answer and a handful of ICE
8
+ // candidates between the two sockets and then has nothing left to do.
9
+ //
10
+ // Which means there is no codec in this file, no resampler, and no audio path
11
+ // at all. The browser's own WebRTC stack owns those, and it is better at them
12
+ // than anything hand-written: Opus at whatever rate the two ends agree on,
13
+ // echo cancellation, jitter buffering and packet loss concealment, none of
14
+ // which the µ-law path can have because a telephone carrier cannot receive
15
+ // them. A live call sounds better than the agent call, and costs nothing per
16
+ // minute, because nobody is in the middle of it.
17
+ //
18
+ // # Shared with the dashboard
19
+ //
20
+ // Both ends of a live call run this file: the reporter's widget and the member
21
+ // who answers in the portal, which imports it from here rather than keeping a
22
+ // second copy. The two sides differ by one flag — who makes the offer — and it
23
+ // arrives from the server rather than being decided here, so the two ends
24
+ // cannot both sit waiting for the other to go first.
25
+ //
26
+ // # The one exception: the copy that gets kept
27
+ //
28
+ // An agent that is set to record or transcribe cannot do either from here —
29
+ // the audio it would need never reaches it. So sphoro.voice asks instead: it
30
+ // names, in `session.created`, what this call is keeping and in what format,
31
+ // and each browser uplinks a copy of *its own* microphone over the signaling
32
+ // socket it already has open. That copy is the only way a recording or a
33
+ // transcript of a peer-to-peer call can exist at all, and it is why the codec
34
+ // this file said it did not have is imported below.
35
+ //
36
+ // It is a copy and not the call. The conversation still runs browser to
37
+ // browser; an uplink that stalls or is refused costs a recording, never a call.
38
+ //
39
+ // Nothing outside the package is imported to do any of it. The SDK runs on
40
+ // someone else's page and has no dependencies, and that rule does not bend for
41
+ // a peer connection.
42
+
43
+ import { createContext, resumeContext, tapMicrophone } from './audio.js';
44
+
45
+ /**
46
+ * sphoro.voice's realtime protocol, sent as the WebSocket subprotocol. The same
47
+ * one the agent call speaks — a bridged call is a different conversation over
48
+ * the same envelope, not a different protocol.
49
+ */
50
+ const SUBPROTOCOL = 'voiceai.realtime.v1';
51
+
52
+ /**
53
+ * Where a browser looks to find its way to the other one, when the server names
54
+ * nowhere.
55
+ *
56
+ * The server almost always does name somewhere — it is configured per
57
+ * deployment, because a network behind symmetric NAT needs a TURN relay and
58
+ * that costs money. This is the floor: a public STUN server, which is enough
59
+ * for most home and office networks and free.
60
+ */
61
+ const FALLBACK_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
62
+
63
+ /**
64
+ * Ask for the microphone.
65
+ *
66
+ * Separate from joining, and deliberately. The permission prompt takes as long
67
+ * as the person takes to read it, and both ends have something they should not
68
+ * do until it is answered: the reporter should not make a room full of
69
+ * dashboards ring before knowing they can speak, and whoever answers should not
70
+ * claim a call they then cannot take. So both ask first and act second.
71
+ *
72
+ * @returns {Promise<MediaStream>}
73
+ */
74
+ export function openMicrophone() {
75
+ return navigator.mediaDevices.getUserMedia({
76
+ audio: {
77
+ // Both ends are on speakers as often as not. Without cancellation each
78
+ // hears themselves back through the other, half a second late, which is
79
+ // the single most effective way to make a conversation impossible.
80
+ echoCancellation: true,
81
+ noiseSuppression: true,
82
+ autoGainControl: true,
83
+ channelCount: 1,
84
+ },
85
+ });
86
+ }
87
+
88
+ /** Whether this browser can be on a live call at all. */
89
+ export function liveCallSupported() {
90
+ return typeof window !== 'undefined'
91
+ && typeof window.RTCPeerConnection === 'function'
92
+ && Boolean(navigator.mediaDevices?.getUserMedia);
93
+ }
94
+
95
+ /**
96
+ * Join a live call and stay on it until somebody hangs up.
97
+ *
98
+ * @param {object} opts
99
+ * @param {string} opts.realtimeUrl From the API, carrying its own credential
100
+ * and its own seat. Never log it and never build one by hand.
101
+ * @param {MediaStream} [opts.stream] A microphone already opened by
102
+ * `openMicrophone`. One is opened here if not, which is the wrong order for
103
+ * anything that had a decision to make first.
104
+ * @param {string} [opts.ringsUntil] ISO-8601, on the caller's side only: when
105
+ * the ringing stops. The browser is the thing that decides it has, because it
106
+ * is the one client guaranteed to still be listening at that moment.
107
+ * @param {(state: string, detail?: object) => void} [opts.onState]
108
+ * 'ringing' | 'connecting' | 'connected' | 'reconnecting' | 'ended' | 'missed'
109
+ * | 'failed'. 'reconnecting' is not a failure and never ends a call — see
110
+ * `routeChanged`.
111
+ * @param {(blocked: boolean) => void} [opts.onAudioBlocked] The browser refused
112
+ * to play the other person, or has since started. Its own callback rather
113
+ * than a state, because it is orthogonal to every one of them: the call is
114
+ * connected and working, and one end cannot hear it. See `playRemote`.
115
+ * @param {(live: boolean) => void} [opts.onMicrophone] Whether this person's
116
+ * microphone is still reaching the call. Also orthogonal, and for the same
117
+ * reason: the call is up, the other end is audible, and nothing this person
118
+ * says is going anywhere. See `watchMicrophone`.
119
+ * @returns {Promise<{hangUp: () => void, setMuted: (muted: boolean) => boolean,
120
+ * isMuted: () => boolean}>}
121
+ */
122
+ export async function joinLiveCall({
123
+ realtimeUrl, stream, ringsUntil, onState, onAudioBlocked, onMicrophone,
124
+ }) {
125
+ const emit = (state, detail) => { try { onState?.(state, detail); } catch { /* theirs */ } };
126
+ const sayBlocked = (blocked) => {
127
+ try { onAudioBlocked?.(blocked); } catch { /* theirs */ }
128
+ };
129
+ const sayMicrophone = (live) => {
130
+ try { onMicrophone?.(live); } catch { /* theirs */ }
131
+ };
132
+
133
+ if (!liveCallSupported()) {
134
+ const err = new Error('[BugCatch] this browser cannot make live calls.');
135
+ emit('failed', { reason: 'unsupported' });
136
+ throw err;
137
+ }
138
+
139
+ let microphone = stream;
140
+ if (!microphone) {
141
+ try {
142
+ microphone = await openMicrophone();
143
+ } catch (err) {
144
+ emit('failed', { reason: 'microphone', error: String(err?.name || err) });
145
+ throw err;
146
+ }
147
+ }
148
+
149
+ let socket;
150
+ try {
151
+ socket = await openSocket(realtimeUrl);
152
+ } catch (err) {
153
+ stopStream(microphone);
154
+ emit('failed', { reason: 'connect' });
155
+ throw err;
156
+ }
157
+
158
+ let peer = null;
159
+ let remoteAudio = null;
160
+ let finished = false;
161
+ let ringTimer = null;
162
+ // What sphoro.voice said this call is keeping, and the tap feeding it. Both
163
+ // stay null on the ordinary call, which keeps nothing and uplinks nothing.
164
+ let capture = null;
165
+ let uplink = null;
166
+ // Whether the browser is currently refusing to play the other person, and the
167
+ // teardown for the gesture listeners armed to try again. See playRemote.
168
+ let blocked = false;
169
+ let disarmGesture = null;
170
+ /** Teardown for the microphone watch, armed once the peer connection is built. */
171
+ let unwatchMicrophone = null;
172
+ // Claimed synchronously, before the first await below, because the seat that
173
+ // answers is told the call has started twice — once in its own
174
+ // `session.created`, which says somebody is already waiting, and again in the
175
+ // `peer.joined` that goes to both ends a moment later. Two starts that both
176
+ // check `uplink` before either has set it are two taps on one microphone, and
177
+ // two copies of every frame interleaved into one recording.
178
+ let starting = false;
179
+ // Candidates that arrived before there was anywhere to put them.
180
+ //
181
+ // The offer and the first candidates are sent within a millisecond of each
182
+ // other and arrive in that order, but `setRemoteDescription` is a promise —
183
+ // so a candidate can be in hand while the description it belongs to is still
184
+ // being applied. Adding one then throws, and the call comes up with a worse
185
+ // route than it could have had, or none.
186
+ const pending = [];
187
+
188
+ // --- surviving a change of network ------------------------------------------------
189
+ //
190
+ // A phone call moves. Somebody walks out of the office and the handset hands the
191
+ // connection from Wi-Fi to cellular; a lift takes the signal for four seconds; a laptop
192
+ // wakes on a different access point. Every one of those changes the local address the
193
+ // call was negotiated against, and WebRTC's answer to all of them is the same: the ICE
194
+ // agent reports `disconnected`, and unless somebody restarts it, `failed` a moment later.
195
+ //
196
+ // Before this, `failed` ended the call and `disconnected` was not handled at all — so a
197
+ // call that would have recovered on its own in two seconds was hung up on, and one that
198
+ // needed a restart was hung up on with no attempt made. On a desk that is rare. On the
199
+ // phone this app also ships as, it is what happens when you stand up.
200
+ //
201
+ // What replaces it is a grace period, then an ICE restart, then a deadline. Each is
202
+ // there for a distinct reason:
203
+ //
204
+ // the grace `disconnected` is frequently transient — a couple of lost packets on a
205
+ // route that is still fine. Restarting immediately would tear down a
206
+ // working connection to rebuild the same one, and take audio with it.
207
+ //
208
+ // the restart Renegotiation from scratch over the signaling socket, which is still
209
+ // open: it never depended on the media route. This is the part that
210
+ // actually crosses a network change.
211
+ //
212
+ // the deadline Reconnecting forever is its own failure. Somebody staring at a card
213
+ // that says "reconnecting" for five minutes has been abandoned more
214
+ // thoroughly than somebody told the call dropped.
215
+ //
216
+ // Only one side offers, and it is the side the server nominated at the start. Both ends
217
+ // see the drop, so if the two sides both restarted there would be two offers crossing —
218
+ // a glare condition WebRTC resolves badly. The other side asks instead, over the
219
+ // signaling socket, and its request is best-effort: the offerer's own detection is what
220
+ // this actually rests on.
221
+
222
+ /** Which side was told to make the offer. Set from `peer.joined`, for both ends. */
223
+ let shouldOffer = false;
224
+ /** Whether the two ends have ever been connected. A drop before that is a failed setup. */
225
+ let connectedOnce = false;
226
+ let reconnecting = false;
227
+ let recoverTimer = null;
228
+ let giveUpTimer = null;
229
+ let restarts = 0;
230
+
231
+ /** How long `disconnected` is allowed to resolve itself before a restart is attempted. */
232
+ const RECOVER_GRACE_MS = 2000;
233
+ /** How long between one restart and the next, when the first did not take. */
234
+ const RECOVER_RETRY_MS = 5000;
235
+ /** How long a call may spend off the air in total before it is called dropped. */
236
+ const RECOVER_DEADLINE_MS = 30000;
237
+ const MAX_RESTARTS = 4;
238
+
239
+ /** Everything ends here, whatever ended it, exactly once. */
240
+ const finish = (state, detail) => {
241
+ if (finished) return;
242
+ finished = true;
243
+ if (ringTimer) { clearTimeout(ringTimer); ringTimer = null; }
244
+ // Before the peer is closed, and before the stream is stopped. Closing a connection
245
+ // moves it to `closed` and stopping a track fires `ended`, and both would otherwise
246
+ // reach handlers that would report a dropped call and a dead microphone to somebody
247
+ // who has just hung up.
248
+ clearRecovery();
249
+ unwatchMicrophone?.();
250
+ unwatchMicrophone = null;
251
+ stopUplink();
252
+ // Before anything else is torn down: these are listeners on `window`, and a
253
+ // call that left them behind would keep retrying playback on an element
254
+ // that is about to be removed — once per click, for the rest of the session.
255
+ disarmGesture?.();
256
+ disarmGesture = null;
257
+ try { peer?.close(); } catch { /* already gone */ }
258
+ stopStream(microphone);
259
+ if (remoteAudio) {
260
+ remoteAudio.srcObject = null;
261
+ remoteAudio.remove();
262
+ remoteAudio = null;
263
+ }
264
+ try {
265
+ if (socket.readyState === WebSocket.OPEN) socket.close(1000, 'call over');
266
+ } catch { /* already closing */ }
267
+ emit(state, detail);
268
+ };
269
+
270
+ const send = (message) => {
271
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
272
+ };
273
+
274
+ /**
275
+ * Start sending sphoro.voice a copy of this person's microphone.
276
+ *
277
+ * Only when it asked, and only once both people are on the call. The server
278
+ * throws away anything that arrives while the phone is still ringing — a
279
+ * recording is timed from the moment the second person joined, and a minute
280
+ * of somebody waiting alone at the head of the file is a timeline that
281
+ * disagrees with the call's own duration. Sending it anyway would just be
282
+ * bandwidth spent on audio that gets dropped, on the same network carrying
283
+ * the call.
284
+ *
285
+ * Every failure here is survivable and none of them ends the call. A browser
286
+ * with no Web Audio, a context the autoplay policy will not start, a format
287
+ * this version does not know how to produce — each costs the recording and
288
+ * leaves two people talking, which is the right way round. It is said out
289
+ * loud, though: an agent set to record whose calls silently arrive with no
290
+ * audio is the failure that is impossible to diagnose from the outside.
291
+ */
292
+ const startUplink = async () => {
293
+ if (starting || uplink || finished || !capture) return;
294
+ if (!capture.audio && !capture.transcript) return;
295
+ starting = true;
296
+
297
+ // The server names the format rather than assuming one, so a client that
298
+ // does not recognise it sends nothing rather than sending µ-law into a
299
+ // recording that expected something else — which would store a file of
300
+ // noise and read as a working recording to everything downstream.
301
+ const encoding = capture.encoding || 'mulaw';
302
+ if (encoding !== 'mulaw') {
303
+ warn(`this call is kept as ${encoding}, which this SDK cannot produce.`);
304
+ return;
305
+ }
306
+
307
+
308
+ const rate = Number(capture.sample_rate) || 8000;
309
+ try {
310
+ const context = createContext(rate);
311
+ // A context built without a user gesture starts suspended, and a
312
+ // suspended context's ScriptProcessorNode never fires — so nothing would
313
+ // be uplinked and nothing would say why. Both ends granted microphone
314
+ // access a moment ago, which is the activation this spends.
315
+ await resumeContext(context);
316
+ if (finished) {
317
+ try { context.close(); } catch { /* already closed */ }
318
+ return;
319
+ }
320
+
321
+ const tap = tapMicrophone(context, microphone, (bytes) => {
322
+ // Binary, not the base64 envelope the protocol also accepts: this is a
323
+ // second copy of audio going up a network that is already carrying the
324
+ // call, and base64 would make it a third larger for nothing.
325
+ if (socket.readyState === WebSocket.OPEN) socket.send(bytes);
326
+ }, rate);
327
+
328
+ uplink = { context, tap };
329
+ } catch (err) {
330
+ warn(`could not send the copy this call is keeping: ${String(err?.message || err)}`);
331
+ }
332
+ };
333
+
334
+ /**
335
+ * Stop sending it.
336
+ *
337
+ * The microphone track is deliberately left alone — `finish` stops the whole
338
+ * stream a moment later, and stopping it here would cut the conversation off
339
+ * to end a recording.
340
+ */
341
+ function stopUplink() {
342
+ starting = false;
343
+ if (!uplink) return;
344
+ const { context, tap } = uplink;
345
+ uplink = null;
346
+ try { tap.stop(); } catch { /* already gone */ }
347
+ try { context.close(); } catch { /* already closed */ }
348
+ }
349
+
350
+ /**
351
+ * Play the other person, and do something about it when the browser says no.
352
+ *
353
+ * The refusal used to be swallowed: `play().catch(() => {})`, on the reasoning
354
+ * that both ends had granted microphone access a moment earlier and that
355
+ * satisfies every autoplay policy. It does not always. A call answered from a
356
+ * notification arrives with no user gesture on the page at all — the tap
357
+ * happened on a system notification, which is not this document — and inside
358
+ * an Android WebView that is exactly the case that fails.
359
+ *
360
+ * What that produced is the worst shape a bug can have: the call connects,
361
+ * every state says `connected`, both ends are billed for it, and one person
362
+ * hears nothing. Nothing is logged, because the rejection was caught and
363
+ * dropped, so there is no evidence anywhere that it happened.
364
+ *
365
+ * So now it is reported and it is retried. The retry is armed on the next
366
+ * gesture anywhere on the page, which is the one thing that reliably lifts an
367
+ * autoplay block, and it is armed in capture so a handler calling
368
+ * `stopPropagation` cannot hide the gesture from it. Whoever is on the call is
369
+ * about to touch something — the mute button, the hang-up button, the report
370
+ * they are talking about — and the sound comes up when they do, with the
371
+ * caller free to say so on screen in the meantime.
372
+ */
373
+ function playRemote() {
374
+ if (!remoteAudio || finished) return;
375
+ let attempt;
376
+ try {
377
+ attempt = remoteAudio.play?.();
378
+ } catch {
379
+ // A synchronous throw, which some engines do instead of rejecting.
380
+ markBlocked();
381
+ return;
382
+ }
383
+ if (!attempt || typeof attempt.then !== 'function') {
384
+ // No promise: an older engine, or a stub. Nothing to wait on and nothing
385
+ // to report — it either made a sound or it did not.
386
+ markPlaying();
387
+ return;
388
+ }
389
+ attempt.then(markPlaying, markBlocked);
390
+ }
391
+
392
+ function markPlaying() {
393
+ disarmGesture?.();
394
+ disarmGesture = null;
395
+ if (!blocked) return;
396
+ blocked = false;
397
+ sayBlocked(false);
398
+ }
399
+
400
+ function markBlocked() {
401
+ if (finished) return;
402
+ if (!blocked) {
403
+ blocked = true;
404
+ sayBlocked(true);
405
+ }
406
+ armGesture();
407
+ }
408
+
409
+ /** Try again on the next thing the person touches. Armed once. */
410
+ function armGesture() {
411
+ if (disarmGesture) return;
412
+ // Guarded rather than assumed: this file is also loaded by a host page's
413
+ // bundler into environments that define a partial `window`, and a retry
414
+ // that cannot be armed is a quieter failure than one that throws inside a
415
+ // promise nobody is awaiting.
416
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
417
+ const retry = () => playRemote();
418
+ const events = ['pointerdown', 'keydown', 'touchstart'];
419
+ for (const name of events) {
420
+ window.addEventListener(name, retry, { capture: true, passive: true });
421
+ }
422
+ disarmGesture = () => {
423
+ for (const name of events) {
424
+ try { window.removeEventListener(name, retry, { capture: true }); } catch { /* gone */ }
425
+ }
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Build the peer connection.
431
+ *
432
+ * Made on `session.created` rather than lazily on the first signal, because
433
+ * the ICE gathering it starts is the slowest part of connecting and the whole
434
+ * of it can happen while the phone is still ringing. Waiting until somebody
435
+ * answers would add that delay to the exact moment a person says hello, which
436
+ * is the one moment on this call where it is audible.
437
+ */
438
+ const build = (iceServers) => {
439
+ peer = new RTCPeerConnection({ iceServers: iceServers?.length ? iceServers : FALLBACK_ICE });
440
+
441
+ for (const track of microphone.getAudioTracks()) peer.addTrack(track, microphone);
442
+
443
+ // Armed here rather than at the top, so a call that never got a peer connection has
444
+ // nothing to tear down — and so the watch covers exactly the span in which being
445
+ // unheard is a problem worth telling somebody about.
446
+ unwatchMicrophone = watchMicrophone();
447
+
448
+ peer.onicecandidate = (event) => {
449
+ if (!event.candidate) return;
450
+ send({ type: 'signal', signal: { kind: 'candidate', payload: event.candidate.toJSON() } });
451
+ };
452
+
453
+ peer.ontrack = (event) => {
454
+ const [incoming] = event.streams;
455
+ if (!incoming) return;
456
+ // An element rather than the Web Audio API. Audio routed through a
457
+ // MediaStreamAudioSourceNode loses the browser's own echo cancellation on
458
+ // several engines, and this call has a live microphone next to it.
459
+ if (!remoteAudio) {
460
+ remoteAudio = document.createElement('audio');
461
+ remoteAudio.autoplay = true;
462
+ // Inline, for a WebView that would otherwise hand a media element to a
463
+ // full-screen player — which on a call is a black rectangle over the
464
+ // dashboard.
465
+ remoteAudio.playsInline = true;
466
+ remoteAudio.setAttribute?.('playsinline', '');
467
+ // Off-screen rather than display:none — a hidden media element is
468
+ // treated as invisible by some autoplay heuristics and refused.
469
+ remoteAudio.style.cssText = 'position:fixed;width:0;height:0;opacity:0;pointer-events:none';
470
+ document.body.appendChild(remoteAudio);
471
+ }
472
+ remoteAudio.srcObject = incoming;
473
+ playRemote();
474
+ };
475
+
476
+ peer.onconnectionstatechange = routeChanged;
477
+ // The same signal, read the other way, for the engines that do not give a reliable
478
+ // `connectionState`. WebKit — which is every browser on iOS and the WebView this app
479
+ // ships inside there — has historically fired `iceconnectionstatechange` and not the
480
+ // aggregate one. Both are wired to the same handler, which reads whichever of the two
481
+ // the engine actually maintains, so a duplicate event costs one comparison.
482
+ peer.oniceconnectionstatechange = routeChanged;
483
+ };
484
+
485
+ /**
486
+ * What the media route is doing, and what to do about it.
487
+ *
488
+ * Reads whichever state the engine keeps up to date, and treats `completed` — which only
489
+ * the ICE-level state has — as connected, because it is: it means a route was found and
490
+ * checking has stopped.
491
+ */
492
+ function routeChanged() {
493
+ if (!peer || finished) return;
494
+ const state = peer.connectionState || peer.iceConnectionState;
495
+
496
+ if (state === 'connected' || state === 'completed') {
497
+ connectedOnce = true;
498
+ restarts = 0;
499
+ clearRecovery();
500
+ // Emitted whether or not this is a reconnection. The caller renders one state, and a
501
+ // card that came back from "reconnecting" has to be told the call is up again.
502
+ reconnecting = false;
503
+ emit('connected');
504
+ return;
505
+ }
506
+
507
+ if (state === 'disconnected') {
508
+ // Before the two ends ever met, a `disconnected` is ICE still working — it goes
509
+ // there between checking one candidate pair and the next. Only `failed` is final at
510
+ // that stage, and it is handled below.
511
+ if (!connectedOnce) return;
512
+ enterRecovery(RECOVER_GRACE_MS);
513
+ return;
514
+ }
515
+
516
+ if (state === 'failed') {
517
+ if (!connectedOnce) {
518
+ // The two browsers never found a route to each other at all. Almost always a
519
+ // network that needs a TURN relay the deployment has not configured; reported as
520
+ // its own reason so that is diagnosable from a support ticket rather than from a
521
+ // packet capture. A restart would try the same candidates again.
522
+ finish('failed', { reason: 'ice' });
523
+ return;
524
+ }
525
+ // A route that worked and then stopped. That is a network change, and a restart is
526
+ // exactly the remedy — no grace, because `failed` is not going to resolve itself.
527
+ enterRecovery(0);
528
+ }
529
+ }
530
+
531
+ /** Say the call is off the air, and line up an attempt to bring it back. */
532
+ function enterRecovery(delay) {
533
+ if (finished) return;
534
+ if (!reconnecting) {
535
+ reconnecting = true;
536
+ emit('reconnecting');
537
+ giveUpTimer = setTimeout(() => finish('failed', { reason: 'network' }), RECOVER_DEADLINE_MS);
538
+ }
539
+ // One attempt in flight at a time. `disconnected` and `failed` both arrive on the way
540
+ // down, and two timers would mean two offers crossing on one connection.
541
+ if (recoverTimer) return;
542
+ recoverTimer = setTimeout(recover, delay);
543
+ }
544
+
545
+ function clearRecovery() {
546
+ if (recoverTimer) { clearTimeout(recoverTimer); recoverTimer = null; }
547
+ if (giveUpTimer) { clearTimeout(giveUpTimer); giveUpTimer = null; }
548
+ }
549
+
550
+ /**
551
+ * Renegotiate the media route over the signaling socket, which never left.
552
+ *
553
+ * `createOffer({ iceRestart: true })` rather than `restartIce()`: the latter is the
554
+ * modern spelling and does the same thing by firing `negotiationneeded`, but it is
555
+ * missing from engines this SDK is expected to run in and the explicit form works
556
+ * everywhere. It also keeps the offer on the same path as the original one, so there is a
557
+ * single place where an offer is made.
558
+ */
559
+ async function recover() {
560
+ recoverTimer = null;
561
+ if (finished || !peer) return;
562
+ // It came back on its own while the grace period was running, which is the common case
563
+ // and the reason the grace period exists.
564
+ const state = peer.connectionState || peer.iceConnectionState;
565
+ if (state === 'connected' || state === 'completed') return;
566
+
567
+ if (!shouldOffer) {
568
+ // Not this side's to restart. Ask, and keep asking — the offerer sees the same drop
569
+ // and is very likely already doing it, so this is the belt to that braces.
570
+ send({ type: 'signal', signal: { kind: 'restart', payload: {} } });
571
+ recoverTimer = setTimeout(recover, RECOVER_RETRY_MS);
572
+ return;
573
+ }
574
+
575
+ if (restarts >= MAX_RESTARTS) return; // the deadline is what ends it
576
+ restarts += 1;
577
+ await restartIce();
578
+ recoverTimer = setTimeout(recover, RECOVER_RETRY_MS);
579
+ }
580
+
581
+ /** The restart itself, also reachable from the other side's request. */
582
+ async function restartIce() {
583
+ if (finished || !peer) return;
584
+ try {
585
+ const description = await peer.createOffer({ iceRestart: true });
586
+ await peer.setLocalDescription(description);
587
+ send({ type: 'signal', signal: { kind: 'offer', payload: peer.localDescription.toJSON() } });
588
+ } catch {
589
+ // Nothing to do here but let the next attempt or the deadline decide. Ending the
590
+ // call on one refused renegotiation would throw away the retries that follow.
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Whether this person is still being heard.
596
+ *
597
+ * A track's `muted` is not the mute button — that is `enabled`, which this file sets and
598
+ * therefore already knows about. `muted` is the *source* stopping: the OS handed the
599
+ * microphone to an incoming phone call, a headset was unplugged, or — the case this app
600
+ * meets daily — Android revoked microphone access because the app went to the background.
601
+ *
602
+ * Which produces the same shape as every other bug on this path: the call is up, the
603
+ * other person is audible, both ends are billed, and nothing this person says goes
604
+ * anywhere. They find out when the other person says "hello? are you there?".
605
+ *
606
+ * The loss is reported on a short delay and the recovery immediately. Several engines
607
+ * flick `muted` on for a few frames while a track settles, and a card that said "your
608
+ * microphone was cut off" for 200ms at the start of every call would be noise — while a
609
+ * microphone that came back and was still reported lost is the error saying the wrong
610
+ * thing.
611
+ */
612
+ function watchMicrophone() {
613
+ const SETTLE_MS = 600;
614
+ let settle = null;
615
+ let lost = false;
616
+
617
+ const say = (live) => {
618
+ if (settle) { clearTimeout(settle); settle = null; }
619
+ if (live) {
620
+ if (!lost) return;
621
+ lost = false;
622
+ sayMicrophone(true);
623
+ return;
624
+ }
625
+ if (lost) return;
626
+ settle = setTimeout(() => {
627
+ settle = null;
628
+ if (finished) return;
629
+ lost = true;
630
+ sayMicrophone(false);
631
+ }, SETTLE_MS);
632
+ };
633
+
634
+ for (const track of microphone.getAudioTracks()) {
635
+ track.onmute = () => say(false);
636
+ track.onunmute = () => say(true);
637
+ // An ended track never comes back — the device is gone. Said at once rather than
638
+ // after the settle, because there is nothing to wait for.
639
+ track.onended = () => {
640
+ if (settle) { clearTimeout(settle); settle = null; }
641
+ if (lost) return;
642
+ lost = true;
643
+ sayMicrophone(false);
644
+ };
645
+ }
646
+ return () => {
647
+ if (settle) { clearTimeout(settle); settle = null; }
648
+ for (const track of microphone.getAudioTracks()) {
649
+ track.onmute = null;
650
+ track.onunmute = null;
651
+ track.onended = null;
652
+ }
653
+ };
654
+ }
655
+
656
+ const offer = async () => {
657
+ try {
658
+ const description = await peer.createOffer();
659
+ await peer.setLocalDescription(description);
660
+ send({ type: 'signal', signal: { kind: 'offer', payload: peer.localDescription.toJSON() } });
661
+ } catch {
662
+ finish('failed', { reason: 'negotiation' });
663
+ }
664
+ };
665
+
666
+ const accept = async (payload) => {
667
+ try {
668
+ await peer.setRemoteDescription(new RTCSessionDescription(payload));
669
+ const answer = await peer.createAnswer();
670
+ await peer.setLocalDescription(answer);
671
+ send({ type: 'signal', signal: { kind: 'answer', payload: peer.localDescription.toJSON() } });
672
+ await drainCandidates();
673
+ } catch {
674
+ finish('failed', { reason: 'negotiation' });
675
+ }
676
+ };
677
+
678
+ const drainCandidates = async () => {
679
+ while (pending.length) {
680
+ const candidate = pending.shift();
681
+ try {
682
+ await peer.addIceCandidate(new RTCIceCandidate(candidate));
683
+ } catch {
684
+ // One unusable candidate is not a failed call — there are usually
685
+ // several, and the connection needs only one of them to work.
686
+ }
687
+ }
688
+ };
689
+
690
+ /**
691
+ * One message at a time, in the order they arrived.
692
+ *
693
+ * Handling is asynchronous — `setRemoteDescription` and `createAnswer` are
694
+ * promises — and the socket does not wait. Without this chain a candidate
695
+ * arriving mid-answer is examined while the description it belongs to is
696
+ * still being applied, so it goes on the pending list a moment after the
697
+ * pending list was drained, and stays there for the rest of the call.
698
+ */
699
+ let handling = Promise.resolve();
700
+ socket.onmessage = (event) => {
701
+ handling = handling.then(() => handle(event)).catch(() => { /* one bad frame */ });
702
+ };
703
+
704
+ const handle = async (event) => {
705
+ if (typeof event.data !== 'string') return;
706
+ let message;
707
+ try { message = JSON.parse(event.data); } catch { return; }
708
+
709
+ switch (message.type) {
710
+ case 'session.created': {
711
+ build(message.data?.ice_servers);
712
+ // What this call keeps, and in what format. Held rather than acted on:
713
+ // nothing is uplinked until somebody is actually on the other end.
714
+ capture = message.data?.capture || null;
715
+ // Ringing, until somebody is on the other side. Whoever answers finds
716
+ // the caller already waiting and skips straight past it — the
717
+ // `peer.joined` that follows a moment later says the same thing, and
718
+ // this is what keeps the answerer's first frame from being silence.
719
+ if (message.data?.peer_present) {
720
+ emit('connecting');
721
+ void startUplink();
722
+ break;
723
+ }
724
+ emit('ringing');
725
+ armRingTimer();
726
+ break;
727
+ }
728
+
729
+ case 'peer.joined': {
730
+ if (ringTimer) { clearTimeout(ringTimer); ringTimer = null; }
731
+ emit('connecting');
732
+ // Told, not inferred. Both ends learn somebody arrived in the same
733
+ // frame, and only one of them is asked to go first.
734
+ //
735
+ // Remembered as well as acted on: it is also the answer to "whose job is it to
736
+ // renegotiate when the network changes", and that question is asked later, when
737
+ // the frame that answered it is long gone. See `recover`.
738
+ shouldOffer = Boolean(message.data?.should_offer);
739
+ if (shouldOffer) await offer();
740
+ // After the offer, and not awaited: opening an audio context is
741
+ // milliseconds, but they would be milliseconds added to the moment
742
+ // somebody says hello, which is the one place on this call where they
743
+ // are audible.
744
+ void startUplink();
745
+ break;
746
+ }
747
+
748
+ case 'signal': {
749
+ if (!peer) break;
750
+ const { kind, payload } = message.data || {};
751
+ if (kind === 'offer') {
752
+ await accept(payload);
753
+ } else if (kind === 'answer') {
754
+ try {
755
+ await peer.setRemoteDescription(new RTCSessionDescription(payload));
756
+ await drainCandidates();
757
+ } catch {
758
+ finish('failed', { reason: 'negotiation' });
759
+ }
760
+ } else if (kind === 'candidate') {
761
+ if (peer.remoteDescription) {
762
+ try { await peer.addIceCandidate(new RTCIceCandidate(payload)); } catch { /* see above */ }
763
+ } else {
764
+ pending.push(payload);
765
+ }
766
+ } else if (kind === 'restart') {
767
+ // The other side lost the route and cannot renegotiate itself. Honoured only by
768
+ // the side that was nominated to offer, so a request arriving at both ends of a
769
+ // mis-signalled call cannot produce two offers crossing.
770
+ if (shouldOffer) await restartIce();
771
+ }
772
+ break;
773
+ }
774
+
775
+ case 'peer.left':
776
+ finish('ended', { reason: 'peer_left' });
777
+ break;
778
+
779
+ case 'error':
780
+ finish('failed', { reason: 'server', detail: message.error?.detail });
781
+ break;
782
+
783
+ case 'session.ended':
784
+ finish('ended', { reason: 'server' });
785
+ break;
786
+
787
+ default:
788
+ // Every message the agent conversation has — audio deltas, transcripts,
789
+ // state changes. None of them happen on a bridged call, and one
790
+ // arriving is not worth ending a working call over.
791
+ break;
792
+ }
793
+ };
794
+
795
+ socket.onclose = () => finish('ended', { reason: 'disconnected' });
796
+ socket.onerror = () => finish('failed', { reason: 'socket' });
797
+
798
+ /**
799
+ * Stop waiting.
800
+ *
801
+ * The deadline comes from the server, which wrote it down when the call was
802
+ * created — so a browser with a slow clock waits a little longer rather than
803
+ * hanging up on somebody mid-answer, and the server refuses a give-up that
804
+ * arrives early in any case.
805
+ */
806
+ function armRingTimer() {
807
+ if (!ringsUntil) return;
808
+ const remaining = new Date(ringsUntil).getTime() - Date.now();
809
+ if (!Number.isFinite(remaining)) return;
810
+ ringTimer = setTimeout(() => finish('missed'), Math.max(0, remaining));
811
+ }
812
+
813
+ return {
814
+ hangUp() {
815
+ // Said out loud before the socket closes. A close on its own would end
816
+ // the call too — sphoro.voice ends a bridge the moment one side leaves —
817
+ // but saying so first is what distinguishes hanging up from a tunnel.
818
+ send({ type: 'session.end' });
819
+ finish('ended', { reason: 'hung_up' });
820
+ },
821
+ setMuted(muted) {
822
+ for (const track of microphone.getAudioTracks()) track.enabled = !muted;
823
+ return muted;
824
+ },
825
+ isMuted() {
826
+ return microphone.getAudioTracks().every((track) => !track.enabled);
827
+ },
828
+ };
829
+ }
830
+
831
+ /** Open the socket, resolving only once it is actually open. */
832
+ function openSocket(url) {
833
+ return new Promise((resolve, reject) => {
834
+ let socket;
835
+ try {
836
+ socket = new WebSocket(url, SUBPROTOCOL);
837
+ } catch (err) {
838
+ reject(err);
839
+ return;
840
+ }
841
+ socket.onopen = () => {
842
+ socket.onopen = null;
843
+ socket.onerror = null;
844
+ resolve(socket);
845
+ };
846
+ socket.onerror = () => {
847
+ socket.onopen = null;
848
+ socket.onerror = null;
849
+ reject(new Error('[BugCatch] could not connect the call.'));
850
+ };
851
+ });
852
+ }
853
+
854
+ /**
855
+ * Say something went wrong with the copy, without ending anything.
856
+ *
857
+ * `console.warn` and not a state: every caller of this file renders the states
858
+ * to somebody who is mid-conversation, and "the recording did not start" is not
859
+ * news to the two people talking. It is news to whoever turned recording on,
860
+ * and the console is where they will look.
861
+ */
862
+ function warn(message) {
863
+ try { console.warn(`[BugCatch] ${message}`); } catch { /* no console */ }
864
+ }
865
+
866
+ function stopStream(stream) {
867
+ for (const track of stream?.getTracks?.() ?? []) {
868
+ try { track.stop(); } catch { /* already stopped */ }
869
+ }
870
+ }