@sphoro/bugcatch-web-sdk 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -74
- package/dist/bugcatch.es.js +1752 -1685
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +154 -110
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/drafts.js +32 -0
- package/src/index.d.ts +114 -75
- package/src/index.js +147 -164
- package/src/references.js +172 -0
- package/src/updates.js +45 -74
- package/src/widget.js +320 -105
- package/src/voice.js +0 -372
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
|
-
}
|