@sphoro/bugcatch-web-sdk 0.13.1 → 0.14.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/src/voice.js DELETED
@@ -1,372 +0,0 @@
1
- // Talking a report through, out loud.
2
- //
3
- // When somebody on the team picks up a report, the reporter is offered a call
4
- // with a triage agent: it asks the three questions the form did not, and what
5
- // it learns lands in the bug's thread. This is the browser end of that — the
6
- // microphone, the socket, and the codec between them.
7
- //
8
- // Nobody from the team is ever on the line. The agent is software, the copy the
9
- // reporter sees says so, and the team reads the transcript afterwards.
10
- //
11
- // # Why this file is bigger than the rest of the SDK
12
- //
13
- // sphoro.voice speaks µ-law at 8 kHz in both directions — the format a
14
- // telephone carrier hands it, which is what makes a browser call and a phone
15
- // call indistinguishable to everything downstream. A browser has neither: it
16
- // captures 32-bit floats at whatever rate the sound card runs at. The gap
17
- // between those two is G.711 and a resampler, and neither is available from the
18
- // platform, so both are here.
19
- //
20
- // Nothing external is imported to do it. The SDK runs on someone else's page
21
- // and has no dependencies, and that rule does not bend for audio — the codec
22
- // and the resampler are a sibling file, shared with the live call, which needs
23
- // the same µ-law for the copy of the microphone it uplinks.
24
-
25
- import {
26
- WIRE_RATE, CAPTURE_BLOCK, createContext, resumeContext,
27
- downsample, encodeMuLaw, decodeMuLaw,
28
- } from './audio.js';
29
-
30
- // Re-exported because this is where they lived and where the codec test still
31
- // reaches for them. Moving a hand-rolled G.711 out from under its own test is
32
- // how a silent call ships.
33
- export { downsample, encodeMuLaw, decodeMuLaw };
34
-
35
- /**
36
- * What sphoro.voice's realtime protocol is called. Sent as the WebSocket
37
- * subprotocol so a future v2 can share the endpoint instead of breaking every
38
- * deployed client on one day.
39
- */
40
- const SUBPROTOCOL = 'voiceai.realtime.v1';
41
-
42
- /**
43
- * Start a call.
44
- *
45
- * Resolves once the microphone is live and the socket is open, so a caller can
46
- * show "connected" without guessing. Everything after that arrives through the
47
- * callbacks.
48
- *
49
- * @param {object} opts
50
- * @param {string} opts.realtimeUrl From the API. Carries its own credential —
51
- * never log it, and never build one by hand.
52
- * @param {(state: string, detail?: object) => void} [opts.onState]
53
- * 'connecting' | 'listening' | 'speaking' | 'ended' | 'failed'.
54
- * @param {(line: {role: string, text: string, final: boolean}) => void} [opts.onTranscript]
55
- * @returns {Promise<{hangUp: () => void}>}
56
- */
57
- export async function startVoiceCall({ realtimeUrl, onState, onTranscript }) {
58
- const emit = (state, detail) => { try { onState?.(state, detail); } catch { /* theirs */ } };
59
- const say = (line) => { try { onTranscript?.(line); } catch { /* theirs */ } };
60
-
61
- emit('connecting');
62
-
63
- // Asked for before the socket, deliberately. A permission prompt takes as
64
- // long as the person takes to read it, and a socket opened first would have
65
- // the agent greeting an empty room — sphoro.voice starts talking on connect.
66
- let stream;
67
- try {
68
- stream = await navigator.mediaDevices.getUserMedia({
69
- audio: {
70
- // A speakerphone conversation with no echo cancellation is one where
71
- // the agent hears itself and answers its own greeting.
72
- echoCancellation: true,
73
- noiseSuppression: true,
74
- autoGainControl: true,
75
- channelCount: 1,
76
- },
77
- });
78
- } catch (err) {
79
- emit('failed', { reason: 'microphone', error: String(err?.name || err) });
80
- throw err;
81
- }
82
-
83
- const context = createContext();
84
- // Started explicitly, and before anything is wired to it.
85
- //
86
- // A context constructed while the page has no user activation starts
87
- // *suspended*, and a suspended context is silent in both directions: nothing
88
- // plays out of it, and a ScriptProcessorNode attached to it never fires — so
89
- // the reporter hears no greeting and the agent hears no reporter, with no
90
- // error anywhere to say why. The microphone prompt above is what puts this
91
- // code past the gesture that would have carried it: Safari does not treat
92
- // activation as sticky across the await, so the context that follows it is
93
- // suspended on exactly the browsers that need the resume most.
94
- //
95
- // Failure is not fatal here. `resume()` rejects when there is no activation to
96
- // spend, and the call still has to be attempted — the state listener below
97
- // picks it up if the browser starts the context later.
98
- await resumeContext(context);
99
- const source = context.createMediaStreamSource(stream);
100
-
101
- let socket;
102
- try {
103
- socket = await openSocket(realtimeUrl);
104
- } catch (err) {
105
- stopEverything(stream, context, null, null);
106
- emit('failed', { reason: 'connect' });
107
- throw err;
108
- }
109
-
110
- // --- capture ---------------------------------------------------------------
111
-
112
- // ScriptProcessorNode is deprecated in favour of AudioWorklet, and is used
113
- // anyway. A worklet's processor has to be loaded from a URL, which on this
114
- // SDK means a blob: URL — and a blob: worklet is refused outright by the
115
- // Content-Security-Policy of a great many of the sites this code runs on.
116
- // A deprecated node that works on a customer's page beats a modern one that
117
- // is blocked by their headers.
118
- const capture = context.createScriptProcessor(CAPTURE_BLOCK, 1, 1);
119
- let sending = true;
120
-
121
- capture.onaudioprocess = (event) => {
122
- if (!sending || socket.readyState !== WebSocket.OPEN) return;
123
- const input = event.inputBuffer.getChannelData(0);
124
- const frames = context.sampleRate === WIRE_RATE
125
- ? input
126
- : downsample(input, context.sampleRate, WIRE_RATE);
127
- socket.send(encodeMuLaw(frames));
128
- };
129
-
130
- source.connect(capture);
131
- // A ScriptProcessorNode does not run unless it is connected to a destination,
132
- // even when nothing is meant to come out of it. Zero gain is what keeps the
133
- // reporter from hearing their own voice played back at them.
134
- const mute = context.createGain();
135
- mute.gain.value = 0;
136
- capture.connect(mute);
137
- mute.connect(context.destination);
138
-
139
- // --- playback --------------------------------------------------------------
140
-
141
- // Chunks arrive faster than they play, so each is scheduled to start where
142
- // the last one ends. Playing them on arrival instead would overlap them into
143
- // noise; waiting for each to finish would gap them into stutter.
144
- //
145
- // The scheduled nodes are held onto because scheduling is the problem: by the
146
- // time somebody talks over the agent, most of what the agent is about to say
147
- // has already been handed to the audio clock, and dropping the queue is not
148
- // enough to stop it coming out of the speaker.
149
- let playHead = 0;
150
- const scheduled = new Set();
151
-
152
- const play = (bytes) => {
153
- // A context can be suspended long after it started — a backgrounded tab, an
154
- // OS audio-session change — and a suspended one accepts scheduled buffers
155
- // and plays none of them. Nudged on the way past rather than checked once at
156
- // the top, because the failure is a call that goes quiet mid-sentence and
157
- // never comes back.
158
- if (context.state === 'suspended') void resumeContext(context);
159
-
160
- const samples = decodeMuLaw(bytes);
161
- // Declared at the wire's rate rather than the context's: the browser
162
- // resamples an AudioBuffer on playback, and it does it better than the
163
- // twenty lines it would take to do it here.
164
- const buffer = context.createBuffer(1, samples.length, WIRE_RATE);
165
- buffer.getChannelData(0).set(samples);
166
-
167
- const node = context.createBufferSource();
168
- node.buffer = buffer;
169
- node.connect(context.destination);
170
- node.onended = () => scheduled.delete(node);
171
-
172
- const now = context.currentTime;
173
- // A play head in the past means the queue drained — the agent stopped
174
- // talking — so the next chunk starts now rather than at a stale offset.
175
- if (playHead < now) playHead = now;
176
- node.start(playHead);
177
- playHead += buffer.duration;
178
- scheduled.add(node);
179
- };
180
-
181
- /**
182
- * Stop talking, now.
183
- *
184
- * Called when the caller interrupts and when the call ends. Without it, an
185
- * agent that has been talked over keeps playing to the end of whatever was
186
- * already scheduled — which is the behaviour barge-in exists to prevent, and
187
- * which sounds like the agent ignoring the person.
188
- */
189
- const stopPlayback = () => {
190
- for (const node of scheduled) {
191
- try { node.stop(); } catch { /* already finished */ }
192
- }
193
- scheduled.clear();
194
- playHead = 0;
195
- };
196
-
197
- // --- the conversation ------------------------------------------------------
198
-
199
- socket.addEventListener('message', (event) => {
200
- // Binary frames are not part of the downstream protocol; audio arrives
201
- // base64-encoded inside a JSON envelope like everything else.
202
- if (typeof event.data !== 'string') return;
203
-
204
- let message;
205
- try {
206
- message = JSON.parse(event.data);
207
- } catch {
208
- return;
209
- }
210
-
211
- switch (message.type) {
212
- case 'session.created':
213
- emit('listening');
214
- break;
215
-
216
- case 'transcript.partial':
217
- if (isSpeech(message.text)) say({ role: 'reporter', text: message.text, final: false });
218
- break;
219
-
220
- case 'transcript.final':
221
- if (isSpeech(message.text)) say({ role: 'reporter', text: message.text, final: true });
222
- break;
223
-
224
- case 'response.started':
225
- emit('speaking');
226
- break;
227
-
228
- case 'response.audio.delta':
229
- if (message.audio) play(base64ToBytes(message.audio));
230
- break;
231
-
232
- case 'response.text.delta':
233
- // Deltas, not whole lines: the caller concatenates until `response.done`
234
- // if it is rendering captions.
235
- //
236
- // Filtered, because this field is not always text. sphoro.voice sends
237
- // the caption and the audio from the same place, and against a real
238
- // synthesiser it has been observed putting the µ-law bytes it is about
239
- // to play into both — so an unguarded client renders a RIFF header into
240
- // somebody's page.
241
- if (isSpeech(message.text)) say({ role: 'agent', text: message.text, final: false });
242
- break;
243
-
244
- case 'response.done':
245
- say({ role: 'agent', text: '', final: true });
246
- emit('listening');
247
- break;
248
-
249
- case 'state.changed':
250
- // The server's own view — it says 'listening' after a barge-in, which
251
- // is the one transition this client cannot infer for itself. Going back
252
- // to listening mid-answer *is* the barge-in, so the queued speech goes
253
- // with it.
254
- //
255
- // 'interrupted' is the same event arriving by the other route: the
256
- // server's own voice detection heard the reporter start talking and
257
- // dropped what it was synthesizing. Only 'listening' — the answer to a
258
- // barge-in this client asked for — used to be honoured here, so a
259
- // reporter who simply talked over the agent kept hearing it: most of a
260
- // reply is already handed to the audio clock by the time anybody
261
- // interrupts, and dropping the queue is the only thing that stops it.
262
- if (message.state === 'listening' || message.state === 'interrupted') stopPlayback();
263
- if (message.state) emit(message.state);
264
- break;
265
-
266
- case 'session.ended':
267
- finish('ended', { reason: message.data?.reason || null });
268
- break;
269
-
270
- case 'error':
271
- finish('failed', { reason: message.error?.detail || 'the call failed' });
272
- break;
273
-
274
- default:
275
- break;
276
- }
277
- });
278
-
279
- socket.addEventListener('close', () => finish('ended'));
280
- socket.addEventListener('error', () => finish('failed', { reason: 'transport' }));
281
-
282
- let over = false;
283
- function finish(state, detail) {
284
- if (over) return;
285
- over = true;
286
- sending = false;
287
- stopPlayback();
288
- stopEverything(stream, context, capture, socket);
289
- emit(state, detail);
290
- }
291
-
292
- return {
293
- /** End the call. Safe to call twice, and after it has already ended. */
294
- hangUp() {
295
- if (!over && socket.readyState === WebSocket.OPEN) {
296
- // Told, not just dropped: a socket that simply closes leaves
297
- // sphoro.voice waiting out its own timeout before the call is marked
298
- // finished, and the transcript does not reach the bug until it does.
299
- try { socket.send(JSON.stringify({ type: 'session.end' })); } catch { /* closing */ }
300
- }
301
- finish('ended', { reason: 'hung_up' });
302
- },
303
- };
304
- }
305
-
306
- // --- plumbing ----------------------------------------------------------------
307
-
308
- /** Open the socket, resolving when it is actually open. */
309
- function openSocket(url) {
310
- return new Promise((resolve, reject) => {
311
- let socket;
312
- try {
313
- socket = new WebSocket(url, SUBPROTOCOL);
314
- } catch (err) {
315
- reject(err);
316
- return;
317
- }
318
- socket.binaryType = 'arraybuffer';
319
- const onOpen = () => { cleanup(); resolve(socket); };
320
- const onFail = () => { cleanup(); reject(new Error('the call could not be connected')); };
321
- const cleanup = () => {
322
- socket.removeEventListener('open', onOpen);
323
- socket.removeEventListener('error', onFail);
324
- socket.removeEventListener('close', onFail);
325
- };
326
- socket.addEventListener('open', onOpen);
327
- socket.addEventListener('error', onFail);
328
- socket.addEventListener('close', onFail);
329
- });
330
- }
331
-
332
- /**
333
- * Let go of everything.
334
- *
335
- * The microphone track especially: a page that closes its socket and leaves the
336
- * track live shows a recording indicator in the tab for as long as it stays
337
- * open, which reads — correctly — as being listened to after hanging up.
338
- */
339
- function stopEverything(stream, context, capture, socket) {
340
- try { capture?.disconnect(); } catch { /* already gone */ }
341
- if (capture) capture.onaudioprocess = null;
342
- for (const track of stream?.getTracks() ?? []) {
343
- try { track.stop(); } catch { /* already stopped */ }
344
- }
345
- try { context?.close(); } catch { /* already closed */ }
346
- try { socket?.close(); } catch { /* already closing */ }
347
- }
348
-
349
- /**
350
- * Whether this is something somebody said, or bytes wearing a string's type.
351
- *
352
- * The caption fields on this protocol are typed as text and are not reliably
353
- * text: the same call site sends the audio and the caption, and against a real
354
- * synthesiser both have carried µ-law. A NUL settles it — no transcription
355
- * contains one — and a line that is mostly control characters is not speech
356
- * however it is typed.
357
- */
358
- function isSpeech(text) {
359
- if (typeof text !== 'string' || !text) return false;
360
- if (text.includes('\u0000')) return false;
361
- // eslint-disable-next-line no-control-regex
362
- const control = (text.match(/[\u0001-\u0008\u000e-\u001f]/g) || []).length;
363
- return control <= text.length / 10;
364
- }
365
-
366
- /** Base64 to bytes, without pulling in anything to do it. */
367
- function base64ToBytes(value) {
368
- const binary = window.atob(value);
369
- const bytes = new Uint8Array(binary.length);
370
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
371
- return bytes;
372
- }