@dialgem/client 0.0.0-stage → 1.0.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/CHANGELOG.md +13 -0
- package/LICENSE +202 -0
- package/README.md +148 -2
- package/dist/call-Cv2GrNfJ.d.cts +160 -0
- package/dist/call-Cv2GrNfJ.d.ts +160 -0
- package/dist/index.cjs +567 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +562 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +623 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +41 -0
- package/dist/react.d.ts +41 -0
- package/dist/react.js +621 -0
- package/dist/react.js.map +1 -0
- package/package.json +97 -4
package/dist/react.cjs
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// src/react.ts
|
|
6
|
+
|
|
7
|
+
// src/emitter.ts
|
|
8
|
+
var Emitter = class {
|
|
9
|
+
handlers = /* @__PURE__ */ new Map();
|
|
10
|
+
on(event, handler) {
|
|
11
|
+
let set = this.handlers.get(event);
|
|
12
|
+
if (!set) this.handlers.set(event, set = /* @__PURE__ */ new Set());
|
|
13
|
+
set.add(handler);
|
|
14
|
+
return () => this.off(event, handler);
|
|
15
|
+
}
|
|
16
|
+
off(event, handler) {
|
|
17
|
+
this.handlers.get(event)?.delete(handler);
|
|
18
|
+
}
|
|
19
|
+
emit(event, payload) {
|
|
20
|
+
for (const handler of [...this.handlers.get(event) ?? []]) {
|
|
21
|
+
try {
|
|
22
|
+
;
|
|
23
|
+
handler(payload);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (typeof reportError === "function") reportError(error);
|
|
26
|
+
else console.error(error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/protocol.ts
|
|
33
|
+
var DEFAULT_BASE_URL = "wss://api.dialgem.com";
|
|
34
|
+
var SAMPLE_RATE = 16e3;
|
|
35
|
+
var CAPTURE_CHUNK_SAMPLES = 640;
|
|
36
|
+
var UrlError = class extends Error {
|
|
37
|
+
};
|
|
38
|
+
function buildCallUrl(baseURL, accessToken) {
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(baseURL);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new UrlError(`baseURL is not a URL: ${baseURL}`);
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
46
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
47
|
+
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new UrlError("baseURL must be a wss:// or ws:// address");
|
|
48
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
49
|
+
if (url.protocol === "ws:" && !local) throw new UrlError("baseURL must use wss:// except for localhost");
|
|
50
|
+
if (!accessToken) throw new UrlError("accessToken is empty");
|
|
51
|
+
if (/^zk_/i.test(accessToken.trim())) {
|
|
52
|
+
throw new UrlError(
|
|
53
|
+
"That is an organization API key, not a call token. Never put an API key in a web page: create the call on your server with webCalls.create() and give the page its access_token."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const base = url.toString().replace(/\/+$/, "");
|
|
57
|
+
const query = new URLSearchParams({ web_token: accessToken, live_transcript: "1" });
|
|
58
|
+
return `${base}/v1/agent/call?${query.toString()}`;
|
|
59
|
+
}
|
|
60
|
+
var INIT_FRAME = JSON.stringify({ agent_config: {} });
|
|
61
|
+
function parseServerFrame(text) {
|
|
62
|
+
let msg;
|
|
63
|
+
try {
|
|
64
|
+
msg = JSON.parse(text);
|
|
65
|
+
} catch {
|
|
66
|
+
return { type: "ignored" };
|
|
67
|
+
}
|
|
68
|
+
if (typeof msg !== "object" || msg === null) return { type: "ignored" };
|
|
69
|
+
const m = msg;
|
|
70
|
+
if (typeof m["error"] === "string") {
|
|
71
|
+
return { type: "error", reason: typeof m["disconnection_reason"] === "string" ? m["disconnection_reason"] : m["error"] };
|
|
72
|
+
}
|
|
73
|
+
switch (m["type"]) {
|
|
74
|
+
case "session_started":
|
|
75
|
+
return { type: "session_started", sessionId: typeof m["session_id"] === "string" ? m["session_id"] : "" };
|
|
76
|
+
case "transcript":
|
|
77
|
+
if ((m["role"] === "user" || m["role"] === "agent") && typeof m["text"] === "string") {
|
|
78
|
+
return { type: "transcript", role: m["role"], text: m["text"] };
|
|
79
|
+
}
|
|
80
|
+
return { type: "ignored" };
|
|
81
|
+
case "transcript_partial":
|
|
82
|
+
return m["role"] === "user" && typeof m["text"] === "string" ? { type: "transcript_partial", text: m["text"] } : { type: "ignored" };
|
|
83
|
+
case "agent_interrupted":
|
|
84
|
+
return { type: "agent_interrupted" };
|
|
85
|
+
case "call_ended":
|
|
86
|
+
return { type: "call_ended" };
|
|
87
|
+
default:
|
|
88
|
+
return { type: "ignored" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function stripRiffHeader(chunk) {
|
|
92
|
+
if (chunk.byteLength >= 4) {
|
|
93
|
+
const magic = new Uint8Array(chunk, 0, 4);
|
|
94
|
+
if (magic[0] === 82 && magic[1] === 73 && magic[2] === 70 && magic[3] === 70) {
|
|
95
|
+
return chunk.byteLength <= 44 ? null : chunk.slice(44);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return chunk;
|
|
99
|
+
}
|
|
100
|
+
function describeRefusal(reason) {
|
|
101
|
+
switch (reason) {
|
|
102
|
+
case "origin_not_allowed":
|
|
103
|
+
return { code: "refused", message: "This page's origin is not allowed to start calls. Add it under Web calls in your Dialgem settings." };
|
|
104
|
+
case "token_expired":
|
|
105
|
+
return { code: "refused", message: "The access token expired before the call connected. Request a new one and start again." };
|
|
106
|
+
case "token_already_used":
|
|
107
|
+
return { code: "refused", message: "The access token was already used. Each call needs its own token from your backend." };
|
|
108
|
+
case "token_invalid":
|
|
109
|
+
case "bad_request":
|
|
110
|
+
return { code: "refused", message: "The access token is not valid for a call." };
|
|
111
|
+
case "concurrency_limit_reached":
|
|
112
|
+
return { code: "busy", message: "Too many calls are running right now. Try again in a moment." };
|
|
113
|
+
case "web_calls_unavailable":
|
|
114
|
+
return { code: "server_error", message: "Browser calls are not available right now." };
|
|
115
|
+
default:
|
|
116
|
+
return { code: "call_refused", message: `The call was declined: ${reason}.` };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function describeClose(code) {
|
|
120
|
+
switch (code) {
|
|
121
|
+
case 1008:
|
|
122
|
+
return {
|
|
123
|
+
code: "refused",
|
|
124
|
+
message: "The call was refused. The access token was invalid, expired or already used, or this page's origin is not allowed to start calls."
|
|
125
|
+
};
|
|
126
|
+
case 1013:
|
|
127
|
+
return { code: "busy", message: "Too many calls are running right now. Try again in a moment." };
|
|
128
|
+
case 1011:
|
|
129
|
+
case 1002:
|
|
130
|
+
return { code: "server_error", message: "The call could not start because of a problem on Dialgem's side." };
|
|
131
|
+
default:
|
|
132
|
+
return { code: "connection_failed", message: "Could not connect to the call." };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/player.ts
|
|
137
|
+
var PcmPlayer = class {
|
|
138
|
+
constructor(ctx) {
|
|
139
|
+
this.ctx = ctx;
|
|
140
|
+
}
|
|
141
|
+
ctx;
|
|
142
|
+
nextTime = 0;
|
|
143
|
+
sources = [];
|
|
144
|
+
/** A trailing odd byte: frames are not guaranteed to end on a sample boundary. */
|
|
145
|
+
carry = null;
|
|
146
|
+
feed(chunk) {
|
|
147
|
+
let data = stripRiffHeader(chunk);
|
|
148
|
+
if (data === null) return;
|
|
149
|
+
if (this.carry) {
|
|
150
|
+
const joined = new Uint8Array(this.carry.length + data.byteLength);
|
|
151
|
+
joined.set(this.carry, 0);
|
|
152
|
+
joined.set(new Uint8Array(data), this.carry.length);
|
|
153
|
+
data = joined.buffer;
|
|
154
|
+
this.carry = null;
|
|
155
|
+
}
|
|
156
|
+
const usable = data.byteLength - data.byteLength % 2;
|
|
157
|
+
if (usable !== data.byteLength) this.carry = new Uint8Array(data.slice(usable));
|
|
158
|
+
const samples = usable / 2;
|
|
159
|
+
if (samples < 1) return;
|
|
160
|
+
const pcm = new Int16Array(data.slice(0, usable));
|
|
161
|
+
const floats = new Float32Array(samples);
|
|
162
|
+
for (let i = 0; i < samples; i++) floats[i] = (pcm[i] ?? 0) / 32768;
|
|
163
|
+
const buffer = this.ctx.createBuffer(1, samples, SAMPLE_RATE);
|
|
164
|
+
buffer.copyToChannel(floats, 0);
|
|
165
|
+
const source = this.ctx.createBufferSource();
|
|
166
|
+
source.buffer = buffer;
|
|
167
|
+
source.connect(this.ctx.destination);
|
|
168
|
+
source.onended = () => {
|
|
169
|
+
const i = this.sources.indexOf(source);
|
|
170
|
+
if (i !== -1) this.sources.splice(i, 1);
|
|
171
|
+
};
|
|
172
|
+
const now = this.ctx.currentTime;
|
|
173
|
+
if (this.nextTime < now) this.nextTime = now + 0.02;
|
|
174
|
+
source.start(this.nextTime);
|
|
175
|
+
this.sources.push(source);
|
|
176
|
+
this.nextTime += buffer.duration;
|
|
177
|
+
}
|
|
178
|
+
/** Stop everything queued, immediately: the caller started talking over the agent. */
|
|
179
|
+
interrupt() {
|
|
180
|
+
for (const s of this.sources) {
|
|
181
|
+
try {
|
|
182
|
+
s.stop(0);
|
|
183
|
+
} catch {
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.sources = [];
|
|
187
|
+
this.nextTime = 0;
|
|
188
|
+
this.carry = null;
|
|
189
|
+
}
|
|
190
|
+
/** Whether anything is queued or playing. */
|
|
191
|
+
active() {
|
|
192
|
+
return this.sources.length > 0;
|
|
193
|
+
}
|
|
194
|
+
/** Milliseconds of audio still queued ahead of the audio clock. */
|
|
195
|
+
remainingMs() {
|
|
196
|
+
return Math.max(0, (this.nextTime - this.ctx.currentTime) * 1e3);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// src/worklet.ts
|
|
201
|
+
var WORKLET_NAME = "dialgem-capture";
|
|
202
|
+
var WORKLET_SOURCE = `
|
|
203
|
+
class DialgemCapture extends AudioWorkletProcessor {
|
|
204
|
+
constructor(options) {
|
|
205
|
+
super()
|
|
206
|
+
const o = (options && options.processorOptions) || {}
|
|
207
|
+
this.chunk = o.chunkSamples || ${CAPTURE_CHUNK_SAMPLES}
|
|
208
|
+
this.ratio = sampleRate / (o.targetRate || ${SAMPLE_RATE})
|
|
209
|
+
this.pos = 0
|
|
210
|
+
this.boundary = this.ratio
|
|
211
|
+
this.sum = 0
|
|
212
|
+
this.count = 0
|
|
213
|
+
this.last = 0
|
|
214
|
+
this.out = new Int16Array(this.chunk)
|
|
215
|
+
this.n = 0
|
|
216
|
+
this.peak = 0
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
emit(v) {
|
|
220
|
+
const a = v < 0 ? -v : v
|
|
221
|
+
if (a > this.peak) this.peak = a
|
|
222
|
+
const s = v > 1 ? 1 : v < -1 ? -1 : v
|
|
223
|
+
this.out[this.n++] = s < 0 ? s * 32768 : s * 32767
|
|
224
|
+
if (this.n === this.chunk) {
|
|
225
|
+
const pcm = this.out.buffer
|
|
226
|
+
this.port.postMessage({ pcm: pcm, peak: this.peak }, [pcm])
|
|
227
|
+
this.out = new Int16Array(this.chunk)
|
|
228
|
+
this.n = 0
|
|
229
|
+
this.peak = 0
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
process(inputs) {
|
|
234
|
+
const input = inputs[0] && inputs[0][0]
|
|
235
|
+
if (!input) return true
|
|
236
|
+
for (let i = 0; i < input.length; i++) {
|
|
237
|
+
this.sum += input[i]
|
|
238
|
+
this.count++
|
|
239
|
+
this.pos++
|
|
240
|
+
while (this.pos >= this.boundary) {
|
|
241
|
+
const v = this.count > 0 ? this.sum / this.count : this.last
|
|
242
|
+
this.last = v
|
|
243
|
+
this.sum = 0
|
|
244
|
+
this.count = 0
|
|
245
|
+
this.boundary += this.ratio
|
|
246
|
+
this.emit(v)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
registerProcessor(${JSON.stringify(WORKLET_NAME)}, DialgemCapture)
|
|
253
|
+
`;
|
|
254
|
+
|
|
255
|
+
// src/call.ts
|
|
256
|
+
var LEVEL_INTERVAL_MS = 100;
|
|
257
|
+
var MAX_DRAIN_MS = 1e4;
|
|
258
|
+
function browser() {
|
|
259
|
+
const g = globalThis;
|
|
260
|
+
const nav = g["navigator"];
|
|
261
|
+
const url = g["URL"];
|
|
262
|
+
const out = {};
|
|
263
|
+
if (g["WebSocket"]) out.WebSocket = g["WebSocket"];
|
|
264
|
+
const audio = g["AudioContext"] ?? g["webkitAudioContext"];
|
|
265
|
+
if (audio) out.AudioContext = audio;
|
|
266
|
+
if (g["AudioWorkletNode"]) out.AudioWorkletNode = g["AudioWorkletNode"];
|
|
267
|
+
if (g["Blob"]) out.Blob = g["Blob"];
|
|
268
|
+
if (nav?.mediaDevices?.getUserMedia) out.getUserMedia = (c) => nav.mediaDevices.getUserMedia(c);
|
|
269
|
+
if (url?.createObjectURL) out.createObjectURL = (b) => url.createObjectURL(b);
|
|
270
|
+
if (url?.revokeObjectURL) out.revokeObjectURL = (u) => url.revokeObjectURL(u);
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
var DialgemCall = class extends Emitter {
|
|
274
|
+
constructor(options) {
|
|
275
|
+
super();
|
|
276
|
+
this.options = options;
|
|
277
|
+
this.platform = { ...browser(), ...options.platform ?? {} };
|
|
278
|
+
}
|
|
279
|
+
options;
|
|
280
|
+
_state = "idle";
|
|
281
|
+
_muted = false;
|
|
282
|
+
_callId = null;
|
|
283
|
+
platform;
|
|
284
|
+
ctx = null;
|
|
285
|
+
stream = null;
|
|
286
|
+
ws = null;
|
|
287
|
+
player = null;
|
|
288
|
+
source = null;
|
|
289
|
+
node = null;
|
|
290
|
+
sink = null;
|
|
291
|
+
moduleUrl = null;
|
|
292
|
+
speakingTimer = null;
|
|
293
|
+
drainTimer = null;
|
|
294
|
+
ending = false;
|
|
295
|
+
pendingRefusal = null;
|
|
296
|
+
failure = null;
|
|
297
|
+
lastLevelAt = 0;
|
|
298
|
+
speaking = false;
|
|
299
|
+
get state() {
|
|
300
|
+
return this._state;
|
|
301
|
+
}
|
|
302
|
+
/** Set once the call has started. */
|
|
303
|
+
get callId() {
|
|
304
|
+
return this._callId;
|
|
305
|
+
}
|
|
306
|
+
get muted() {
|
|
307
|
+
return this._muted;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Opens the microphone and the call. Call it directly from a click or tap: the browser decides whether the page
|
|
311
|
+
* may play sound at the moment this runs, and a call started from a timer or after other awaits can be silent.
|
|
312
|
+
* Resolves when the connection is open; the call has started when `started` fires. A failure is reported as an
|
|
313
|
+
* `error` event and also rejects.
|
|
314
|
+
*/
|
|
315
|
+
async start() {
|
|
316
|
+
if (this._state !== "idle") throw new Error("A call object can only be started once. Create a new DialgemCall for each call.");
|
|
317
|
+
this.setState("connecting");
|
|
318
|
+
const p = this.platform;
|
|
319
|
+
if (!p.WebSocket || !p.AudioContext || !p.AudioWorkletNode || !p.getUserMedia || !p.createObjectURL || !p.Blob) {
|
|
320
|
+
throw this.fail({ code: "unsupported", message: "This browser cannot run voice calls (it needs WebSocket, Web Audio with AudioWorklet, and a microphone API)." });
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
this.ctx = new p.AudioContext();
|
|
324
|
+
void this.ctx.resume();
|
|
325
|
+
} catch {
|
|
326
|
+
throw this.fail({ code: "unsupported", message: "This browser could not start audio." });
|
|
327
|
+
}
|
|
328
|
+
this.player = new PcmPlayer(this.ctx);
|
|
329
|
+
const mic = this.options.microphone ?? {};
|
|
330
|
+
try {
|
|
331
|
+
this.stream = await p.getUserMedia({
|
|
332
|
+
audio: {
|
|
333
|
+
channelCount: 1,
|
|
334
|
+
echoCancellation: mic.echoCancellation ?? true,
|
|
335
|
+
noiseSuppression: mic.noiseSuppression ?? true,
|
|
336
|
+
autoGainControl: mic.autoGainControl ?? true,
|
|
337
|
+
...mic.deviceId ? { deviceId: { exact: mic.deviceId } } : {}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const name = error?.name ?? "";
|
|
342
|
+
throw this.fail(
|
|
343
|
+
name === "NotAllowedError" || name === "SecurityError" ? { code: "mic_denied", message: "Microphone access was denied. Allow the microphone for this page and try again." } : { code: "mic_unavailable", message: "The microphone could not be opened. Check that one is connected and not in use by another app." }
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (this.dead()) return this.cleanup();
|
|
347
|
+
try {
|
|
348
|
+
this.moduleUrl = p.createObjectURL(new p.Blob([WORKLET_SOURCE], { type: "application/javascript" }));
|
|
349
|
+
await this.ctx.audioWorklet.addModule(this.moduleUrl);
|
|
350
|
+
} catch {
|
|
351
|
+
throw this.fail({
|
|
352
|
+
code: "unsupported",
|
|
353
|
+
message: "The audio processor could not be loaded. If this page sets a Content-Security-Policy, allow blob: in script-src."
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (this.dead()) return this.cleanup();
|
|
357
|
+
let token;
|
|
358
|
+
try {
|
|
359
|
+
const t = this.options.accessToken;
|
|
360
|
+
token = typeof t === "function" ? await t() : t;
|
|
361
|
+
if (!token) throw new Error("empty token");
|
|
362
|
+
} catch {
|
|
363
|
+
throw this.fail({ code: "token_unavailable", message: "Could not get an access token for the call." });
|
|
364
|
+
}
|
|
365
|
+
if (this.dead()) return this.cleanup();
|
|
366
|
+
let url;
|
|
367
|
+
try {
|
|
368
|
+
url = buildCallUrl(this.options.baseURL ?? DEFAULT_BASE_URL, token);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw this.fail({ code: "connection_failed", message: error instanceof UrlError ? error.message : "Invalid call address." });
|
|
371
|
+
}
|
|
372
|
+
await new Promise((resolve) => {
|
|
373
|
+
const ws = new p.WebSocket(url);
|
|
374
|
+
ws.binaryType = "arraybuffer";
|
|
375
|
+
this.ws = ws;
|
|
376
|
+
ws.onopen = () => {
|
|
377
|
+
ws.send(INIT_FRAME);
|
|
378
|
+
resolve();
|
|
379
|
+
};
|
|
380
|
+
ws.onmessage = (event) => this.onMessage(event.data);
|
|
381
|
+
ws.onerror = () => resolve();
|
|
382
|
+
ws.onclose = (event) => {
|
|
383
|
+
resolve();
|
|
384
|
+
this.onClose(event.code);
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
if (this.failure) throw this.fail(this.failure);
|
|
388
|
+
}
|
|
389
|
+
/** Ends the call. Safe to call at any time, any number of times. */
|
|
390
|
+
stop() {
|
|
391
|
+
if (this._state === "idle") {
|
|
392
|
+
this.setState("ended");
|
|
393
|
+
this.emit("ended", { reason: "hangup" });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (this._state !== "active" && this._state !== "connecting") return;
|
|
397
|
+
try {
|
|
398
|
+
if (this.ws?.readyState === 1) this.ws.send("hangup");
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
this.finish("hangup");
|
|
402
|
+
}
|
|
403
|
+
/** While muted the agent hears silence. The call stays open. */
|
|
404
|
+
setMuted(muted) {
|
|
405
|
+
this._muted = muted;
|
|
406
|
+
if (muted) this.emit("level", { level: 0 });
|
|
407
|
+
}
|
|
408
|
+
// ── server to client ──────────────────────────────────────────────────────────
|
|
409
|
+
onMessage(data) {
|
|
410
|
+
if (typeof data !== "string") {
|
|
411
|
+
if (data instanceof ArrayBuffer) this.onAudio(data);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const frame = parseServerFrame(data);
|
|
415
|
+
switch (frame.type) {
|
|
416
|
+
case "session_started":
|
|
417
|
+
this.onStarted(frame.sessionId);
|
|
418
|
+
break;
|
|
419
|
+
case "transcript":
|
|
420
|
+
this.emit("transcript", { role: frame.role, text: frame.text, final: true });
|
|
421
|
+
break;
|
|
422
|
+
case "transcript_partial":
|
|
423
|
+
this.emit("transcript", { role: "user", text: frame.text, final: false });
|
|
424
|
+
break;
|
|
425
|
+
case "agent_interrupted":
|
|
426
|
+
this.player?.interrupt();
|
|
427
|
+
this.setSpeaking(false);
|
|
428
|
+
this.emit("interrupted", {});
|
|
429
|
+
break;
|
|
430
|
+
case "call_ended":
|
|
431
|
+
this.onCallEnded();
|
|
432
|
+
break;
|
|
433
|
+
case "error":
|
|
434
|
+
this.pendingRefusal = frame.reason;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
onStarted(sessionId) {
|
|
439
|
+
if (this._state !== "connecting" || !this.ctx || !this.stream) return;
|
|
440
|
+
this._callId = sessionId || null;
|
|
441
|
+
this.setState("active");
|
|
442
|
+
const ctx = this.ctx;
|
|
443
|
+
this.source = ctx.createMediaStreamSource(this.stream);
|
|
444
|
+
this.node = new this.platform.AudioWorkletNode(ctx, WORKLET_NAME, {
|
|
445
|
+
numberOfInputs: 1,
|
|
446
|
+
numberOfOutputs: 1,
|
|
447
|
+
outputChannelCount: [1],
|
|
448
|
+
processorOptions: { targetRate: SAMPLE_RATE, chunkSamples: CAPTURE_CHUNK_SAMPLES }
|
|
449
|
+
});
|
|
450
|
+
this.sink = ctx.createGain();
|
|
451
|
+
this.sink.gain.value = 0;
|
|
452
|
+
this.source.connect(this.node);
|
|
453
|
+
this.node.connect(this.sink);
|
|
454
|
+
this.sink.connect(ctx.destination);
|
|
455
|
+
this.node.port.onmessage = (event) => this.onCaptured(event.data);
|
|
456
|
+
this.emit("started", { callId: this._callId ?? "" });
|
|
457
|
+
}
|
|
458
|
+
onCaptured(frame) {
|
|
459
|
+
const ws = this.ws;
|
|
460
|
+
if (!ws || ws.readyState !== 1 || this.ending) return;
|
|
461
|
+
ws.send(this._muted ? new ArrayBuffer(frame.pcm.byteLength) : frame.pcm);
|
|
462
|
+
const now = Date.now();
|
|
463
|
+
if (!this._muted && now - this.lastLevelAt >= LEVEL_INTERVAL_MS) {
|
|
464
|
+
this.lastLevelAt = now;
|
|
465
|
+
this.emit("level", { level: Math.min(1, frame.peak) });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
onAudio(data) {
|
|
469
|
+
if (this._state !== "active" || !this.player) return;
|
|
470
|
+
this.player.feed(data);
|
|
471
|
+
this.setSpeaking(true);
|
|
472
|
+
if (this.speakingTimer) clearTimeout(this.speakingTimer);
|
|
473
|
+
this.speakingTimer = setTimeout(() => this.setSpeaking(false), this.player.remainingMs() + 50);
|
|
474
|
+
}
|
|
475
|
+
setSpeaking(speaking) {
|
|
476
|
+
if (speaking === this.speaking) return;
|
|
477
|
+
this.speaking = speaking;
|
|
478
|
+
if (!speaking && this.speakingTimer) {
|
|
479
|
+
clearTimeout(this.speakingTimer);
|
|
480
|
+
this.speakingTimer = null;
|
|
481
|
+
}
|
|
482
|
+
this.emit("agent_speaking", { speaking });
|
|
483
|
+
}
|
|
484
|
+
onCallEnded() {
|
|
485
|
+
if (this.ending || this.dead()) return;
|
|
486
|
+
this.ending = true;
|
|
487
|
+
const wait = Math.min(MAX_DRAIN_MS, (this.player?.remainingMs() ?? 0) + 50);
|
|
488
|
+
this.drainTimer = setTimeout(() => this.finish("agent_ended"), wait);
|
|
489
|
+
}
|
|
490
|
+
onClose(code) {
|
|
491
|
+
if (this.dead()) return;
|
|
492
|
+
if (this._state === "active") {
|
|
493
|
+
if (!this.ending) this.finish("connection_lost");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (this.pendingRefusal) {
|
|
497
|
+
this.fail({ ...describeRefusal(this.pendingRefusal), reason: this.pendingRefusal });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.fail(describeClose(code));
|
|
501
|
+
}
|
|
502
|
+
// ── ending ────────────────────────────────────────────────────────────────────
|
|
503
|
+
dead() {
|
|
504
|
+
return this._state === "ended" || this._state === "error";
|
|
505
|
+
}
|
|
506
|
+
/** Ends the call as failed and reports it once. Returns the error, for `start()` to throw. */
|
|
507
|
+
fail(error) {
|
|
508
|
+
if (!this.dead()) {
|
|
509
|
+
this.failure = error;
|
|
510
|
+
this.cleanup();
|
|
511
|
+
this.setState("error");
|
|
512
|
+
this.emit("error", error);
|
|
513
|
+
}
|
|
514
|
+
const shown = this.failure ?? error;
|
|
515
|
+
return Object.assign(new Error(shown.message), { code: shown.code, reason: shown.reason });
|
|
516
|
+
}
|
|
517
|
+
finish(reason) {
|
|
518
|
+
if (this.dead()) return;
|
|
519
|
+
this.cleanup();
|
|
520
|
+
this.setState("ended");
|
|
521
|
+
this.emit("ended", { reason });
|
|
522
|
+
}
|
|
523
|
+
setState(state) {
|
|
524
|
+
this._state = state;
|
|
525
|
+
this.emit("state", state);
|
|
526
|
+
}
|
|
527
|
+
cleanup() {
|
|
528
|
+
this.ending = true;
|
|
529
|
+
for (const t of [this.speakingTimer, this.drainTimer]) if (t) clearTimeout(t);
|
|
530
|
+
this.speakingTimer = this.drainTimer = null;
|
|
531
|
+
if (this.speaking) {
|
|
532
|
+
this.speaking = false;
|
|
533
|
+
this.emit("agent_speaking", { speaking: false });
|
|
534
|
+
}
|
|
535
|
+
this.player?.interrupt();
|
|
536
|
+
if (this.node) {
|
|
537
|
+
this.node.port.onmessage = null;
|
|
538
|
+
try {
|
|
539
|
+
this.node.disconnect();
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
this.source?.disconnect();
|
|
545
|
+
this.sink?.disconnect();
|
|
546
|
+
} catch {
|
|
547
|
+
}
|
|
548
|
+
this.stream?.getTracks().forEach((t) => t.stop());
|
|
549
|
+
if (this.ws) {
|
|
550
|
+
this.ws.onmessage = this.ws.onerror = this.ws.onopen = null;
|
|
551
|
+
try {
|
|
552
|
+
this.ws.close();
|
|
553
|
+
} catch {
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (this.moduleUrl) this.platform.revokeObjectURL?.(this.moduleUrl);
|
|
557
|
+
void this.ctx?.close().catch(() => void 0);
|
|
558
|
+
this.node = this.source = this.sink = this.stream = this.ctx = this.player = null;
|
|
559
|
+
this.moduleUrl = null;
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// src/react.ts
|
|
564
|
+
function useDialgemCall(options) {
|
|
565
|
+
const [state, setState] = react.useState("idle");
|
|
566
|
+
const [transcript, setTranscript] = react.useState([]);
|
|
567
|
+
const [agentSpeaking, setAgentSpeaking] = react.useState(false);
|
|
568
|
+
const [muted, setMutedState] = react.useState(false);
|
|
569
|
+
const [level, setLevel] = react.useState(0);
|
|
570
|
+
const [callId, setCallId] = react.useState(null);
|
|
571
|
+
const [endReason, setEndReason] = react.useState(null);
|
|
572
|
+
const [error, setError] = react.useState(null);
|
|
573
|
+
const callRef = react.useRef(null);
|
|
574
|
+
const optionsRef = react.useRef(options);
|
|
575
|
+
optionsRef.current = options;
|
|
576
|
+
const nextId = react.useRef(0);
|
|
577
|
+
react.useEffect(() => () => callRef.current?.stop(), []);
|
|
578
|
+
const start = react.useCallback(async () => {
|
|
579
|
+
const running = callRef.current;
|
|
580
|
+
if (running && (running.state === "connecting" || running.state === "active")) return;
|
|
581
|
+
setTranscript([]);
|
|
582
|
+
setAgentSpeaking(false);
|
|
583
|
+
setMutedState(false);
|
|
584
|
+
setLevel(0);
|
|
585
|
+
setCallId(null);
|
|
586
|
+
setEndReason(null);
|
|
587
|
+
setError(null);
|
|
588
|
+
const rest = { ...optionsRef.current };
|
|
589
|
+
delete rest.getAccessToken;
|
|
590
|
+
const call = new DialgemCall({ ...rest, accessToken: () => optionsRef.current.getAccessToken() });
|
|
591
|
+
callRef.current = call;
|
|
592
|
+
call.on("state", setState);
|
|
593
|
+
call.on("started", (e) => setCallId(e.callId));
|
|
594
|
+
call.on("agent_speaking", (e) => setAgentSpeaking(e.speaking));
|
|
595
|
+
call.on("level", (e) => setLevel(e.level));
|
|
596
|
+
call.on("ended", (e) => setEndReason(e.reason));
|
|
597
|
+
call.on("error", setError);
|
|
598
|
+
call.on(
|
|
599
|
+
"transcript",
|
|
600
|
+
(t) => setTranscript((lines) => {
|
|
601
|
+
const last = lines[lines.length - 1];
|
|
602
|
+
if (last && last.role === t.role && !last.final && t.role === "user") {
|
|
603
|
+
return [...lines.slice(0, -1), { ...last, text: t.text, final: t.final }];
|
|
604
|
+
}
|
|
605
|
+
return [...lines, { id: nextId.current++, role: t.role, text: t.text, final: t.final }];
|
|
606
|
+
})
|
|
607
|
+
);
|
|
608
|
+
try {
|
|
609
|
+
await call.start();
|
|
610
|
+
} catch {
|
|
611
|
+
}
|
|
612
|
+
}, []);
|
|
613
|
+
const stop = react.useCallback(() => callRef.current?.stop(), []);
|
|
614
|
+
const setMuted = react.useCallback((m) => {
|
|
615
|
+
callRef.current?.setMuted(m);
|
|
616
|
+
setMutedState(m);
|
|
617
|
+
}, []);
|
|
618
|
+
return { state, transcript, agentSpeaking, muted, level, callId, endReason, error, start, stop, setMuted };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
exports.useDialgemCall = useDialgemCall;
|
|
622
|
+
//# sourceMappingURL=react.cjs.map
|
|
623
|
+
//# sourceMappingURL=react.cjs.map
|