@craftedxp/voice-js 0.5.4 → 0.9.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/CONSUMING.md +6 -2
- package/README.md +31 -4
- package/dist/assistant.d.mts +32 -0
- package/dist/assistant.d.ts +32 -0
- package/dist/assistant.js +1241 -0
- package/dist/assistant.js.map +1 -0
- package/dist/assistant.mjs +23 -0
- package/dist/assistant.mjs.map +1 -0
- package/dist/browser.d.mts +12 -509
- package/dist/browser.d.ts +12 -608
- package/dist/browser.js +1020 -896
- package/dist/browser.js.map +1 -1
- package/dist/browser.mjs +25 -1283
- package/dist/browser.mjs.map +1 -1
- package/dist/chunk-LV7JGPYW.mjs +200 -0
- package/dist/chunk-LV7JGPYW.mjs.map +1 -0
- package/dist/chunk-ZW22Y67M.mjs +1208 -0
- package/dist/chunk-ZW22Y67M.mjs.map +1 -0
- package/dist/config-D2TbvIqT.d.mts +297 -0
- package/dist/config-D2TbvIqT.d.ts +297 -0
- package/dist/embed.iife.js +30 -23358
- package/dist/incomingCall-CfRRzj2P.d.mts +103 -0
- package/dist/incomingCall-CfRRzj2P.d.ts +103 -0
- package/dist/node.d.mts +69 -139
- package/dist/node.d.ts +342 -496
- package/dist/node.js +472 -467
- package/dist/node.js.map +1 -1
- package/dist/node.mjs +19 -0
- package/dist/node.mjs.map +1 -1
- package/dist/room.d.mts +156 -0
- package/dist/room.d.ts +156 -0
- package/dist/room.js +236 -0
- package/dist/room.js.map +1 -0
- package/dist/room.mjs +7 -0
- package/dist/room.mjs.map +1 -0
- package/dist/transcribe.d.mts +14 -0
- package/dist/transcribe.d.ts +14 -0
- package/dist/transcribe.js +1213 -0
- package/dist/transcribe.js.map +1 -0
- package/dist/transcribe.mjs +18 -0
- package/dist/transcribe.mjs.map +1 -0
- package/package.json +22 -4
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/assistant.ts
|
|
21
|
+
var assistant_exports = {};
|
|
22
|
+
__export(assistant_exports, {
|
|
23
|
+
buildWsUrl: () => buildWsUrl,
|
|
24
|
+
configureVoiceClient: () => configureVoiceClient,
|
|
25
|
+
createAudioCapture: () => createAudioCapture,
|
|
26
|
+
createAudioPlayback: () => createAudioPlayback,
|
|
27
|
+
createProtocolState: () => createProtocolState,
|
|
28
|
+
createReconnectingWebSocket: () => createReconnectingWebSocket,
|
|
29
|
+
handleServerMessage: () => handleServerMessage,
|
|
30
|
+
parseIncomingCall: () => parseIncomingCall,
|
|
31
|
+
startTextSession: () => startTextSession
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(assistant_exports);
|
|
34
|
+
|
|
35
|
+
// src/config.ts
|
|
36
|
+
function normalizeConfig(config) {
|
|
37
|
+
if (!config) throw new Error("configureVoiceClient: config is required");
|
|
38
|
+
if ("apiKey" in config) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"configureVoiceClient: `apiKey` is no longer supported. Embedding sk_ in JS code ships server-grade credentials to every client. Pass `fetchToken: async ({ agentId }) => { /* call YOUR backend mint */ }` instead \u2014 see the @craftedxp/voice-js README for the migration recipe."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (!config.apiBase) {
|
|
44
|
+
throw new Error("configureVoiceClient: apiBase is required");
|
|
45
|
+
}
|
|
46
|
+
if (typeof config.fetchToken !== "function") {
|
|
47
|
+
throw new Error("configureVoiceClient: fetchToken must be a function");
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...config,
|
|
51
|
+
apiBase: config.apiBase.replace(/\/+$/, "")
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function mergeStartCallContext(factory, call) {
|
|
55
|
+
const context = factory.defaultContext || call.context ? { ...factory.defaultContext ?? {}, ...call.context ?? {} } : void 0;
|
|
56
|
+
const metadata = factory.defaultMetadata || call.metadata ? { ...factory.defaultMetadata ?? {}, ...call.metadata ?? {} } : void 0;
|
|
57
|
+
return { context, metadata };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/worklets/mic-downsampler.worklet.js
|
|
61
|
+
var mic_downsampler_worklet_default = "// AudioWorklet \u2014 runs off the main thread in the audio rendering graph.\n//\n// Input: Float32 samples at the AudioContext's native sampleRate (typically\n// 48000 Hz on desktop, 44100 Hz on some iOS devices).\n// Output: 16 kHz mono Int16 PCM, shipped to the main thread via\n// `port.postMessage(ArrayBuffer, [ArrayBuffer])` (transferred, not copied).\n//\n// Why AudioWorklet instead of ScriptProcessorNode: ScriptProcessorNode is\n// deprecated + main-thread-bound, so any JS jank produces audible audio\n// glitches. AudioWorklet's `process()` runs on the audio rendering thread\n// at the graph's block cadence (128 frames by default) and backpressures\n// via returning `true` / `false`.\n//\n// This file is loaded as text (see tsup.config.ts loader) and registered\n// at runtime via `audioWorklet.addModule(blobUrl)`.\n\nclass MicDownsampler extends AudioWorkletProcessor {\n constructor() {\n super()\n // Target sample rate for STT. Matches Deepgram Nova-3 + the platform's\n // server-side SAMPLE_RATE constant in AgentCallHandler.\n this.targetRate = 16000\n // Accumulator for the downsample. We collect incoming samples and emit\n // an Int16 chunk when we've accumulated ~1024 target-rate samples\n // (~64 ms at 16 kHz) \u2014 matches the mobile SDK's chunk size so both\n // platforms have the same server-side framing.\n this.outputFrames = 1024\n this.acc = []\n // Running index used for fractional resampling.\n this.readCursor = 0\n }\n\n // `inputs[0][0]` = first channel of first input. 128 Float32 samples per\n // call at the context's sampleRate. Return true = keep processing.\n process(inputs) {\n const input = inputs[0]\n if (!input || input.length === 0) return true\n const channel = input[0]\n if (!channel || channel.length === 0) return true\n\n const ctxRate = sampleRate // global inside AudioWorkletProcessor\n const ratio = ctxRate / this.targetRate\n\n // Simple linear-interp downsample. For 48000 \u2192 16000 that's 3:1, which\n // linear handles fine for voice. Anti-alias filtering would be\n // theoretically better but inaudible for speech.\n for (let i = 0; i < channel.length; i++) {\n this.acc.push(channel[i])\n }\n\n while (this.acc.length - this.readCursor >= ratio * this.outputFrames) {\n const out = new Int16Array(this.outputFrames)\n let readIdx = this.readCursor\n for (let i = 0; i < this.outputFrames; i++) {\n // Linear interp between floor(readIdx) and ceil(readIdx)\n const low = Math.floor(readIdx)\n const high = Math.min(low + 1, this.acc.length - 1)\n const frac = readIdx - low\n const sample = this.acc[low] * (1 - frac) + this.acc[high] * frac\n // Clip + convert to int16\n const clipped = Math.max(-1, Math.min(1, sample))\n out[i] = clipped < 0 ? clipped * 0x8000 : clipped * 0x7fff\n readIdx += ratio\n }\n // Transfer the ArrayBuffer (zero-copy) to the main thread.\n this.port.postMessage(out.buffer, [out.buffer])\n this.readCursor = readIdx\n }\n\n // Garbage-collect the consumed portion of `acc` every so often so it\n // doesn't grow without bound. Leave ~one chunk of headroom.\n if (this.readCursor > ratio * this.outputFrames) {\n this.acc = this.acc.slice(Math.floor(this.readCursor))\n this.readCursor -= Math.floor(this.readCursor)\n }\n\n return true\n }\n}\n\nregisterProcessor('mic-downsampler', MicDownsampler)\n";
|
|
62
|
+
|
|
63
|
+
// src/AudioCapture.ts
|
|
64
|
+
var VOLUME_INTERVAL_MS = 100;
|
|
65
|
+
var createAudioCapture = (options) => {
|
|
66
|
+
let audioContext = null;
|
|
67
|
+
let mediaStream = null;
|
|
68
|
+
let sourceNode = null;
|
|
69
|
+
let workletNode = null;
|
|
70
|
+
let analyser = null;
|
|
71
|
+
let volumeTimer = null;
|
|
72
|
+
let muted = false;
|
|
73
|
+
let capturing = false;
|
|
74
|
+
const computeRms = (buf) => {
|
|
75
|
+
let sum = 0;
|
|
76
|
+
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
|
|
77
|
+
const rms = Math.sqrt(sum / buf.length);
|
|
78
|
+
return Math.min(1, rms * 1.8);
|
|
79
|
+
};
|
|
80
|
+
const start = async () => {
|
|
81
|
+
if (capturing) return;
|
|
82
|
+
try {
|
|
83
|
+
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
84
|
+
audio: {
|
|
85
|
+
// Hand tuning for voice agent use: we want the raw signal so the
|
|
86
|
+
// server-side STT can do its own noise handling. Disable browser
|
|
87
|
+
// AEC/AGC/NR — experimentally they fight with whatever processing
|
|
88
|
+
// the TTS playback path feeds back in over speakers.
|
|
89
|
+
echoCancellation: true,
|
|
90
|
+
noiseSuppression: true,
|
|
91
|
+
autoGainControl: true,
|
|
92
|
+
channelCount: 1
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
audioContext = new AudioContext();
|
|
96
|
+
if (audioContext.state === "suspended") await audioContext.resume();
|
|
97
|
+
const blob = new Blob([mic_downsampler_worklet_default], { type: "application/javascript" });
|
|
98
|
+
const url = URL.createObjectURL(blob);
|
|
99
|
+
try {
|
|
100
|
+
await audioContext.audioWorklet.addModule(url);
|
|
101
|
+
} finally {
|
|
102
|
+
URL.revokeObjectURL(url);
|
|
103
|
+
}
|
|
104
|
+
sourceNode = audioContext.createMediaStreamSource(mediaStream);
|
|
105
|
+
workletNode = new AudioWorkletNode(audioContext, "mic-downsampler");
|
|
106
|
+
workletNode.port.onmessage = (event) => {
|
|
107
|
+
if (muted) return;
|
|
108
|
+
options.onChunk(event.data);
|
|
109
|
+
};
|
|
110
|
+
if (options.onVolume) {
|
|
111
|
+
analyser = audioContext.createAnalyser();
|
|
112
|
+
analyser.fftSize = 256;
|
|
113
|
+
sourceNode.connect(analyser);
|
|
114
|
+
const buf = new Float32Array(analyser.fftSize);
|
|
115
|
+
volumeTimer = setInterval(() => {
|
|
116
|
+
if (!analyser) return;
|
|
117
|
+
analyser.getFloatTimeDomainData(buf);
|
|
118
|
+
options.onVolume?.(computeRms(buf));
|
|
119
|
+
}, VOLUME_INTERVAL_MS);
|
|
120
|
+
}
|
|
121
|
+
sourceNode.connect(workletNode);
|
|
122
|
+
const sink = audioContext.createGain();
|
|
123
|
+
sink.gain.value = 0;
|
|
124
|
+
workletNode.connect(sink).connect(audioContext.destination);
|
|
125
|
+
capturing = true;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const wrapped = err instanceof Error ? err : new Error(typeof err === "string" ? err : "capture failed");
|
|
128
|
+
options.onError?.(wrapped);
|
|
129
|
+
throw wrapped;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const stop = () => {
|
|
133
|
+
if (!capturing) return;
|
|
134
|
+
capturing = false;
|
|
135
|
+
if (volumeTimer) {
|
|
136
|
+
clearInterval(volumeTimer);
|
|
137
|
+
volumeTimer = null;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
workletNode?.disconnect();
|
|
141
|
+
analyser?.disconnect();
|
|
142
|
+
sourceNode?.disconnect();
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
workletNode = null;
|
|
146
|
+
analyser = null;
|
|
147
|
+
sourceNode = null;
|
|
148
|
+
if (mediaStream) {
|
|
149
|
+
for (const track of mediaStream.getTracks()) track.stop();
|
|
150
|
+
mediaStream = null;
|
|
151
|
+
}
|
|
152
|
+
if (audioContext && audioContext.state !== "closed") {
|
|
153
|
+
void audioContext.close().catch(() => void 0);
|
|
154
|
+
}
|
|
155
|
+
audioContext = null;
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
start,
|
|
159
|
+
stop,
|
|
160
|
+
mute: (v) => {
|
|
161
|
+
muted = v;
|
|
162
|
+
},
|
|
163
|
+
isCapturing: () => capturing
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// src/AudioPlayback.ts
|
|
168
|
+
var DEFAULT_SAMPLE_RATE = 16e3;
|
|
169
|
+
var VOLUME_INTERVAL_MS2 = 100;
|
|
170
|
+
var createAudioPlayback = (options = {}) => {
|
|
171
|
+
const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
172
|
+
let audioContext = null;
|
|
173
|
+
let gainNode = null;
|
|
174
|
+
let analyser = null;
|
|
175
|
+
let volumeTimer = null;
|
|
176
|
+
let nextStartTime = 0;
|
|
177
|
+
let scheduledNodes = [];
|
|
178
|
+
let speaking = false;
|
|
179
|
+
const ensureContext = async () => {
|
|
180
|
+
if (audioContext) {
|
|
181
|
+
if (audioContext.state === "suspended") await audioContext.resume();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
audioContext = new AudioContext({ sampleRate });
|
|
185
|
+
gainNode = audioContext.createGain();
|
|
186
|
+
if (options.onVolume) {
|
|
187
|
+
analyser = audioContext.createAnalyser();
|
|
188
|
+
analyser.fftSize = 256;
|
|
189
|
+
gainNode.connect(analyser);
|
|
190
|
+
const buf = new Float32Array(analyser.fftSize);
|
|
191
|
+
volumeTimer = setInterval(() => {
|
|
192
|
+
if (!analyser) return;
|
|
193
|
+
analyser.getFloatTimeDomainData(buf);
|
|
194
|
+
let sum = 0;
|
|
195
|
+
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
|
|
196
|
+
const rms = Math.sqrt(sum / buf.length);
|
|
197
|
+
options.onVolume?.(Math.min(1, rms * 1.8));
|
|
198
|
+
}, VOLUME_INTERVAL_MS2);
|
|
199
|
+
}
|
|
200
|
+
gainNode.connect(audioContext.destination);
|
|
201
|
+
nextStartTime = audioContext.currentTime;
|
|
202
|
+
};
|
|
203
|
+
const setSpeaking = (v) => {
|
|
204
|
+
if (v === speaking) return;
|
|
205
|
+
speaking = v;
|
|
206
|
+
options.onSpeakingChange?.(v);
|
|
207
|
+
};
|
|
208
|
+
const pruneFinished = () => {
|
|
209
|
+
const now = audioContext?.currentTime ?? 0;
|
|
210
|
+
scheduledNodes = scheduledNodes.filter((n) => {
|
|
211
|
+
const node = n;
|
|
212
|
+
return (node._endsAt ?? 0) > now;
|
|
213
|
+
});
|
|
214
|
+
if (scheduledNodes.length === 0) setSpeaking(false);
|
|
215
|
+
};
|
|
216
|
+
const enqueue = (pcm) => {
|
|
217
|
+
if (!audioContext) {
|
|
218
|
+
void ensureContext().then(() => enqueue(pcm));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!audioContext || !gainNode) return;
|
|
222
|
+
const int16 = new Int16Array(pcm);
|
|
223
|
+
if (int16.length === 0) return;
|
|
224
|
+
const audioBuffer = audioContext.createBuffer(1, int16.length, sampleRate);
|
|
225
|
+
const float32 = audioBuffer.getChannelData(0);
|
|
226
|
+
for (let i = 0; i < int16.length; i++) {
|
|
227
|
+
float32[i] = int16[i] / 32768;
|
|
228
|
+
}
|
|
229
|
+
const node = audioContext.createBufferSource();
|
|
230
|
+
node.buffer = audioBuffer;
|
|
231
|
+
node.connect(gainNode);
|
|
232
|
+
const now = audioContext.currentTime;
|
|
233
|
+
const startAt = Math.max(now, nextStartTime);
|
|
234
|
+
node.start(startAt);
|
|
235
|
+
const duration = int16.length / sampleRate;
|
|
236
|
+
node._endsAt = startAt + duration;
|
|
237
|
+
nextStartTime = startAt + duration;
|
|
238
|
+
scheduledNodes.push(node);
|
|
239
|
+
setSpeaking(true);
|
|
240
|
+
node.onended = () => pruneFinished();
|
|
241
|
+
};
|
|
242
|
+
const flush = () => {
|
|
243
|
+
if (!audioContext || !gainNode) return;
|
|
244
|
+
for (const node of scheduledNodes) {
|
|
245
|
+
try {
|
|
246
|
+
node.stop();
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
scheduledNodes = [];
|
|
251
|
+
gainNode.disconnect();
|
|
252
|
+
gainNode = audioContext.createGain();
|
|
253
|
+
if (analyser) {
|
|
254
|
+
analyser.disconnect();
|
|
255
|
+
gainNode.connect(analyser);
|
|
256
|
+
}
|
|
257
|
+
gainNode.connect(audioContext.destination);
|
|
258
|
+
nextStartTime = audioContext.currentTime;
|
|
259
|
+
setSpeaking(false);
|
|
260
|
+
};
|
|
261
|
+
const close = () => {
|
|
262
|
+
flush();
|
|
263
|
+
if (volumeTimer) {
|
|
264
|
+
clearInterval(volumeTimer);
|
|
265
|
+
volumeTimer = null;
|
|
266
|
+
}
|
|
267
|
+
if (audioContext && audioContext.state !== "closed") {
|
|
268
|
+
void audioContext.close().catch(() => void 0);
|
|
269
|
+
}
|
|
270
|
+
audioContext = null;
|
|
271
|
+
gainNode = null;
|
|
272
|
+
analyser = null;
|
|
273
|
+
};
|
|
274
|
+
const resume = async () => {
|
|
275
|
+
await ensureContext();
|
|
276
|
+
};
|
|
277
|
+
return { enqueue, flush, close, resume };
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// src/ReconnectingWebSocket.ts
|
|
281
|
+
var READYSTATE_OPEN = 1;
|
|
282
|
+
var READYSTATE_CLOSED = 3;
|
|
283
|
+
var createReconnectingWebSocket = (options, onEvent) => {
|
|
284
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
285
|
+
const initialBackoff = options.initialBackoffMs ?? 500;
|
|
286
|
+
const maxBackoff = options.maxBackoffMs ?? 8e3;
|
|
287
|
+
let ws = null;
|
|
288
|
+
let intentionalClose = false;
|
|
289
|
+
let retries = 0;
|
|
290
|
+
let backoff = initialBackoff;
|
|
291
|
+
let reconnectTimer = null;
|
|
292
|
+
const openOnce = () => {
|
|
293
|
+
ws = options.wsFactory(options.url);
|
|
294
|
+
ws.binaryType = "arraybuffer";
|
|
295
|
+
ws.onopen = () => {
|
|
296
|
+
if (retries === 0) onEvent({ type: "open" });
|
|
297
|
+
else onEvent({ type: "reconnected" });
|
|
298
|
+
retries = 0;
|
|
299
|
+
backoff = initialBackoff;
|
|
300
|
+
};
|
|
301
|
+
ws.onmessage = (ev) => {
|
|
302
|
+
onEvent({ type: "message", data: ev.data });
|
|
303
|
+
};
|
|
304
|
+
ws.onerror = () => {
|
|
305
|
+
onEvent({ type: "error", error: new Error("WebSocket error") });
|
|
306
|
+
};
|
|
307
|
+
ws.onclose = (ev) => {
|
|
308
|
+
ws = null;
|
|
309
|
+
const shouldRetry = !intentionalClose && retries < maxRetries;
|
|
310
|
+
if (!shouldRetry) {
|
|
311
|
+
onEvent({
|
|
312
|
+
type: "close",
|
|
313
|
+
code: ev.code,
|
|
314
|
+
reason: ev.reason,
|
|
315
|
+
permanent: true
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
onEvent({
|
|
320
|
+
type: "close",
|
|
321
|
+
code: ev.code,
|
|
322
|
+
reason: ev.reason,
|
|
323
|
+
permanent: false
|
|
324
|
+
});
|
|
325
|
+
retries++;
|
|
326
|
+
const delay = Math.min(backoff, maxBackoff);
|
|
327
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
328
|
+
reconnectTimer = setTimeout(openOnce, delay);
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
openOnce();
|
|
332
|
+
return {
|
|
333
|
+
send: (data) => {
|
|
334
|
+
if (ws && ws.readyState === READYSTATE_OPEN) ws.send(data);
|
|
335
|
+
},
|
|
336
|
+
close: (code = 1e3, reason = "client-requested") => {
|
|
337
|
+
intentionalClose = true;
|
|
338
|
+
if (reconnectTimer) {
|
|
339
|
+
clearTimeout(reconnectTimer);
|
|
340
|
+
reconnectTimer = null;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
ws?.close(code, reason);
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
readyState: () => ws?.readyState ?? READYSTATE_CLOSED
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// src/protocol.ts
|
|
352
|
+
var createProtocolState = () => ({
|
|
353
|
+
state: "idle",
|
|
354
|
+
transcript: [],
|
|
355
|
+
agentBubbleId: null,
|
|
356
|
+
idCounter: 0,
|
|
357
|
+
endReason: null
|
|
358
|
+
});
|
|
359
|
+
var mapEndReason = (raw) => {
|
|
360
|
+
if (raw === "agent_ended") return "agent_ended";
|
|
361
|
+
if (raw === "caller_hung_up") return "user_hangup";
|
|
362
|
+
if (raw === "silence_timeout" || raw === "max_duration") return "timeout";
|
|
363
|
+
return "error";
|
|
364
|
+
};
|
|
365
|
+
function handleServerMessage(raw, state, cb) {
|
|
366
|
+
let msg;
|
|
367
|
+
try {
|
|
368
|
+
msg = JSON.parse(raw);
|
|
369
|
+
} catch {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
switch (msg.type) {
|
|
373
|
+
case "connected":
|
|
374
|
+
cb.onConnected();
|
|
375
|
+
setState(state, "listening", cb);
|
|
376
|
+
return;
|
|
377
|
+
case "transcript": {
|
|
378
|
+
const text = msg.text ?? "";
|
|
379
|
+
if (!text) return;
|
|
380
|
+
const isFinal = !!msg.isFinal;
|
|
381
|
+
if (!isFinal) setState(state, "user_speaking", cb);
|
|
382
|
+
upsertUserPartial(state, text, isFinal);
|
|
383
|
+
cb.onTranscript(state.transcript);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
case "agent_turn_start": {
|
|
387
|
+
const id = `m${state.idCounter++}`;
|
|
388
|
+
state.agentBubbleId = id;
|
|
389
|
+
state.transcript = [...state.transcript, { id, role: "agent", text: "" }];
|
|
390
|
+
cb.onTranscript(state.transcript);
|
|
391
|
+
const seq = typeof msg.seq === "number" ? msg.seq : void 0;
|
|
392
|
+
cb.onAgentTurnStart(seq);
|
|
393
|
+
setState(state, "agent_speaking", cb);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
case "agent_text": {
|
|
397
|
+
const delta = msg.text ?? "";
|
|
398
|
+
if (!delta || !state.agentBubbleId) return;
|
|
399
|
+
const id = state.agentBubbleId;
|
|
400
|
+
state.transcript = state.transcript.map(
|
|
401
|
+
(e) => e.id === id && e.role === "agent" ? { ...e, text: e.text + delta } : e
|
|
402
|
+
);
|
|
403
|
+
cb.onTranscript(state.transcript);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
case "agent_turn_end": {
|
|
407
|
+
state.agentBubbleId = null;
|
|
408
|
+
const seq = typeof msg.seq === "number" ? msg.seq : void 0;
|
|
409
|
+
cb.onAgentTurnEnd(seq);
|
|
410
|
+
setState(state, "listening", cb);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
case "interrupt":
|
|
414
|
+
cb.onInterrupt();
|
|
415
|
+
return;
|
|
416
|
+
case "agent_turn_abort": {
|
|
417
|
+
const committed = (msg.committedText ?? "").trim();
|
|
418
|
+
if (state.agentBubbleId) {
|
|
419
|
+
const id = state.agentBubbleId;
|
|
420
|
+
if (committed) {
|
|
421
|
+
state.transcript = state.transcript.map(
|
|
422
|
+
(e) => e.id === id && e.role === "agent" ? { ...e, text: committed, interrupted: true } : e
|
|
423
|
+
);
|
|
424
|
+
} else {
|
|
425
|
+
state.transcript = state.transcript.filter((e) => e.id !== id);
|
|
426
|
+
}
|
|
427
|
+
cb.onTranscript(state.transcript);
|
|
428
|
+
}
|
|
429
|
+
state.agentBubbleId = null;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
case "tool_call":
|
|
433
|
+
state.transcript = [
|
|
434
|
+
...state.transcript,
|
|
435
|
+
{
|
|
436
|
+
id: `m${state.idCounter++}`,
|
|
437
|
+
role: "tool",
|
|
438
|
+
text: `\u2192 ${String(msg.tool ?? "?")}(${msg.args ? JSON.stringify(msg.args) : ""})`
|
|
439
|
+
}
|
|
440
|
+
];
|
|
441
|
+
cb.onTranscript(state.transcript);
|
|
442
|
+
return;
|
|
443
|
+
case "tool_result":
|
|
444
|
+
state.transcript = [
|
|
445
|
+
...state.transcript,
|
|
446
|
+
{
|
|
447
|
+
id: `m${state.idCounter++}`,
|
|
448
|
+
role: "tool",
|
|
449
|
+
text: `${msg.ok ? "\u2713" : "\u2717"} ${String(msg.tool ?? "?")}`
|
|
450
|
+
}
|
|
451
|
+
];
|
|
452
|
+
cb.onTranscript(state.transcript);
|
|
453
|
+
return;
|
|
454
|
+
case "client_tool_call": {
|
|
455
|
+
const toolCallId = String(msg.toolCallId ?? "");
|
|
456
|
+
const name = String(msg.name ?? "");
|
|
457
|
+
const args = msg.args ?? {};
|
|
458
|
+
if (!toolCallId || !name) return;
|
|
459
|
+
cb.onClientToolCall({ toolCallId, name, args });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
case "call_end": {
|
|
463
|
+
const reasonRaw = String(msg.reason ?? "");
|
|
464
|
+
const reason = mapEndReason(reasonRaw);
|
|
465
|
+
state.endReason = reason;
|
|
466
|
+
state.transcript = [
|
|
467
|
+
...state.transcript,
|
|
468
|
+
{
|
|
469
|
+
id: `m${state.idCounter++}`,
|
|
470
|
+
role: "system",
|
|
471
|
+
text: `call ended${reasonRaw ? ` (${reasonRaw})` : ""}`
|
|
472
|
+
}
|
|
473
|
+
];
|
|
474
|
+
cb.onTranscript(state.transcript);
|
|
475
|
+
cb.onCallEnd(reason);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
case "error": {
|
|
479
|
+
const code = msg.code ?? "server_error";
|
|
480
|
+
const message = msg.message ?? "server error";
|
|
481
|
+
cb.onError({ code, message });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
var setState = (state, next, cb) => {
|
|
487
|
+
if (state.state === next) return;
|
|
488
|
+
cb.onState(next);
|
|
489
|
+
};
|
|
490
|
+
var upsertUserPartial = (state, text, isFinal) => {
|
|
491
|
+
let idx = -1;
|
|
492
|
+
for (let i = state.transcript.length - 1; i >= 0; i--) {
|
|
493
|
+
const e = state.transcript[i];
|
|
494
|
+
if (e.role === "user" && e.committed === false) {
|
|
495
|
+
idx = i;
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (idx === -1) {
|
|
500
|
+
state.transcript = [
|
|
501
|
+
...state.transcript,
|
|
502
|
+
{ id: `m${state.idCounter++}`, role: "user", text, committed: isFinal }
|
|
503
|
+
];
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const target = state.transcript[idx];
|
|
507
|
+
const next = [...state.transcript];
|
|
508
|
+
next[idx] = { ...target, text, committed: isFinal };
|
|
509
|
+
state.transcript = next;
|
|
510
|
+
};
|
|
511
|
+
function buildWsUrl(args) {
|
|
512
|
+
const base = new URL(args.apiBase);
|
|
513
|
+
const proto = base.protocol === "https:" ? "wss:" : "ws:";
|
|
514
|
+
const bargeQS = args.bargeIn === false ? "&barge=off" : "";
|
|
515
|
+
return `${proto}//${base.host}/v1/agents/${encodeURIComponent(args.agentId)}/call?token=${encodeURIComponent(args.token)}${bargeQS}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/clientTools.ts
|
|
519
|
+
var NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
520
|
+
var MAX_TOOLS = 64;
|
|
521
|
+
var MAX_USAGE = 500;
|
|
522
|
+
var MAX_TIMEOUT_MS = 3e4;
|
|
523
|
+
var validateClientToolMap = (tools) => {
|
|
524
|
+
if (tools === void 0) return;
|
|
525
|
+
if (typeof tools !== "object" || tools === null || Array.isArray(tools)) {
|
|
526
|
+
throw new Error("clientTools must be an object keyed by tool name");
|
|
527
|
+
}
|
|
528
|
+
const entries = Object.entries(tools);
|
|
529
|
+
if (entries.length > MAX_TOOLS) {
|
|
530
|
+
throw new Error(`clientTools may declare at most 64 tools (got ${entries.length})`);
|
|
531
|
+
}
|
|
532
|
+
for (const [name, def] of entries) {
|
|
533
|
+
if (!NAME_RE.test(name)) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`clientTools["${name}"]: name must be a valid identifier (^[a-zA-Z_][a-zA-Z0-9_]*$)`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
if (!def || typeof def !== "object") {
|
|
539
|
+
throw new Error(`clientTools["${name}"]: must be an object`);
|
|
540
|
+
}
|
|
541
|
+
if (typeof def.description !== "string" || def.description.length === 0) {
|
|
542
|
+
throw new Error(`clientTools["${name}"]: must have a description`);
|
|
543
|
+
}
|
|
544
|
+
if (typeof def.handler !== "function") {
|
|
545
|
+
throw new Error(`clientTools["${name}"]: must have a handler function`);
|
|
546
|
+
}
|
|
547
|
+
if (def.usage !== void 0 && def.usage.length > MAX_USAGE) {
|
|
548
|
+
throw new Error(`clientTools["${name}"]: usage must be \u2264500 chars`);
|
|
549
|
+
}
|
|
550
|
+
if (def.timeoutMs !== void 0 && (!Number.isFinite(def.timeoutMs) || def.timeoutMs <= 0 || def.timeoutMs > MAX_TIMEOUT_MS)) {
|
|
551
|
+
throw new Error(`clientTools["${name}"]: timeoutMs must be in (0, 30000]`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
var buildRegisterFrame = (tools) => ({
|
|
556
|
+
type: "client_tools_register",
|
|
557
|
+
tools: Object.entries(tools).map(([name, def]) => ({
|
|
558
|
+
name,
|
|
559
|
+
description: def.description,
|
|
560
|
+
parameters: def.parameters,
|
|
561
|
+
...def.usage !== void 0 ? { usage: def.usage } : {},
|
|
562
|
+
...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
|
|
563
|
+
}))
|
|
564
|
+
});
|
|
565
|
+
var dispatchClientToolCall = (send, tools, frame) => {
|
|
566
|
+
const safeSend = (payload) => {
|
|
567
|
+
try {
|
|
568
|
+
send(payload);
|
|
569
|
+
} catch {
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
const tool = tools[frame.name];
|
|
573
|
+
if (!tool) {
|
|
574
|
+
safeSend({
|
|
575
|
+
type: "client_tool_result",
|
|
576
|
+
toolCallId: frame.toolCallId,
|
|
577
|
+
error: `No handler for ${frame.name}`
|
|
578
|
+
});
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
void (async () => {
|
|
582
|
+
try {
|
|
583
|
+
const out = await tool.handler(frame.args);
|
|
584
|
+
safeSend({
|
|
585
|
+
type: "client_tool_result",
|
|
586
|
+
toolCallId: frame.toolCallId,
|
|
587
|
+
result: typeof out === "string" ? out : JSON.stringify(out)
|
|
588
|
+
});
|
|
589
|
+
} catch (err) {
|
|
590
|
+
safeSend({
|
|
591
|
+
type: "client_tool_result",
|
|
592
|
+
toolCallId: frame.toolCallId,
|
|
593
|
+
error: err instanceof Error ? err.message : String(err)
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
})();
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// src/ClientMarksBuffer.ts
|
|
600
|
+
var createClientMarksBuffer = (args) => {
|
|
601
|
+
const now = args.now ?? (() => performance.now());
|
|
602
|
+
let pendingFirstOutboundAt = null;
|
|
603
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
604
|
+
const tryEmit = (seq) => {
|
|
605
|
+
const slot = inFlight.get(seq);
|
|
606
|
+
if (!slot) return;
|
|
607
|
+
if (!slot.ended) return;
|
|
608
|
+
const marks = {};
|
|
609
|
+
if (slot.firstOutboundAt !== null && slot.firstAudibleAt !== null) {
|
|
610
|
+
marks.client_mic_to_first_audible_ms = slot.firstAudibleAt - slot.firstOutboundAt;
|
|
611
|
+
}
|
|
612
|
+
args.send({
|
|
613
|
+
type: "client_marks",
|
|
614
|
+
seq,
|
|
615
|
+
marks,
|
|
616
|
+
clientNow: Date.now()
|
|
617
|
+
});
|
|
618
|
+
inFlight.delete(seq);
|
|
619
|
+
};
|
|
620
|
+
const markFirstOutboundAudio = () => {
|
|
621
|
+
if (pendingFirstOutboundAt !== null) return;
|
|
622
|
+
pendingFirstOutboundAt = now();
|
|
623
|
+
};
|
|
624
|
+
const markFirstAudibleOutput = () => {
|
|
625
|
+
let target;
|
|
626
|
+
for (const slot of inFlight.values()) {
|
|
627
|
+
if (!slot.ended) {
|
|
628
|
+
target = slot;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!target) return;
|
|
632
|
+
if (target.firstAudibleAt !== null) return;
|
|
633
|
+
target.firstAudibleAt = now();
|
|
634
|
+
};
|
|
635
|
+
const onAgentTurnStart = (seq) => {
|
|
636
|
+
inFlight.set(seq, {
|
|
637
|
+
firstOutboundAt: pendingFirstOutboundAt,
|
|
638
|
+
firstAudibleAt: null,
|
|
639
|
+
ended: false
|
|
640
|
+
});
|
|
641
|
+
pendingFirstOutboundAt = null;
|
|
642
|
+
};
|
|
643
|
+
const onAgentTurnEnd = (seq) => {
|
|
644
|
+
const slot = inFlight.get(seq);
|
|
645
|
+
if (!slot) {
|
|
646
|
+
args.send({ type: "client_marks", seq, marks: {}, clientNow: Date.now() });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
slot.ended = true;
|
|
650
|
+
tryEmit(seq);
|
|
651
|
+
};
|
|
652
|
+
const flush = () => {
|
|
653
|
+
for (const seq of [...inFlight.keys()]) {
|
|
654
|
+
const slot = inFlight.get(seq);
|
|
655
|
+
slot.ended = true;
|
|
656
|
+
tryEmit(seq);
|
|
657
|
+
}
|
|
658
|
+
pendingFirstOutboundAt = null;
|
|
659
|
+
};
|
|
660
|
+
return {
|
|
661
|
+
markFirstOutboundAudio,
|
|
662
|
+
markFirstAudibleOutput,
|
|
663
|
+
onAgentTurnStart,
|
|
664
|
+
onAgentTurnEnd,
|
|
665
|
+
flush
|
|
666
|
+
};
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// src/VoiceClient.ts
|
|
670
|
+
var BrowserVoiceClient = class {
|
|
671
|
+
constructor(args) {
|
|
672
|
+
this.rws = null;
|
|
673
|
+
this.capture = null;
|
|
674
|
+
this.playback = null;
|
|
675
|
+
this.muted = false;
|
|
676
|
+
this.inputVolume = 0;
|
|
677
|
+
this.outputVolume = 0;
|
|
678
|
+
this.startedAt = null;
|
|
679
|
+
this.endedFired = false;
|
|
680
|
+
this.lastError = null;
|
|
681
|
+
this.end = () => {
|
|
682
|
+
this.teardown("user_hangup");
|
|
683
|
+
};
|
|
684
|
+
this.mute = () => {
|
|
685
|
+
if (this.muted) return;
|
|
686
|
+
this.muted = true;
|
|
687
|
+
this.capture?.mute(true);
|
|
688
|
+
};
|
|
689
|
+
this.unmute = () => {
|
|
690
|
+
if (!this.muted) return;
|
|
691
|
+
this.muted = false;
|
|
692
|
+
this.capture?.mute(false);
|
|
693
|
+
};
|
|
694
|
+
// ---------------------------------------------------------------
|
|
695
|
+
// Internal
|
|
696
|
+
// ---------------------------------------------------------------
|
|
697
|
+
this.sendClientToolsRegister = () => {
|
|
698
|
+
const frame = buildRegisterFrame(this.args.options.clientTools ?? {});
|
|
699
|
+
this.rws?.send(JSON.stringify(frame));
|
|
700
|
+
};
|
|
701
|
+
this.setState = (next) => {
|
|
702
|
+
if (this.proto.state === next) return;
|
|
703
|
+
this.proto.state = next;
|
|
704
|
+
this.args.options.onStateChange?.(next);
|
|
705
|
+
};
|
|
706
|
+
this.emitError = (err) => {
|
|
707
|
+
this.lastError = err;
|
|
708
|
+
this.args.options.onError?.(err);
|
|
709
|
+
};
|
|
710
|
+
this.handleSocketEvent = (ev) => {
|
|
711
|
+
switch (ev.type) {
|
|
712
|
+
case "open":
|
|
713
|
+
void this.startCapture();
|
|
714
|
+
break;
|
|
715
|
+
case "reconnected":
|
|
716
|
+
this.proto.transcript = [];
|
|
717
|
+
this.proto.agentBubbleId = null;
|
|
718
|
+
this.args.options.onTranscript?.(this.proto.transcript);
|
|
719
|
+
void this.startCapture();
|
|
720
|
+
this.setState("listening");
|
|
721
|
+
break;
|
|
722
|
+
case "message":
|
|
723
|
+
if (typeof ev.data === "string") {
|
|
724
|
+
handleServerMessage(ev.data, this.proto, {
|
|
725
|
+
onState: this.setState,
|
|
726
|
+
onTranscript: (entries) => this.args.options.onTranscript?.(entries),
|
|
727
|
+
onError: this.emitError,
|
|
728
|
+
onInterrupt: () => {
|
|
729
|
+
this.playback?.flush();
|
|
730
|
+
this.args.options.onInterrupt?.();
|
|
731
|
+
},
|
|
732
|
+
onAgentTurnStart: (seq) => {
|
|
733
|
+
if (typeof seq === "number") this.marks.onAgentTurnStart(seq);
|
|
734
|
+
this.args.options.onAgentTurnStart?.();
|
|
735
|
+
},
|
|
736
|
+
onAgentTurnEnd: (seq) => {
|
|
737
|
+
if (typeof seq === "number") this.marks.onAgentTurnEnd(seq);
|
|
738
|
+
},
|
|
739
|
+
onCallEnd: (reason) => this.teardown(reason),
|
|
740
|
+
onConnected: () => this.sendClientToolsRegister(),
|
|
741
|
+
onClientToolCall: (frame) => dispatchClientToolCall(
|
|
742
|
+
(f) => this.rws?.send(JSON.stringify(f)),
|
|
743
|
+
this.args.options.clientTools ?? {},
|
|
744
|
+
frame
|
|
745
|
+
)
|
|
746
|
+
});
|
|
747
|
+
} else {
|
|
748
|
+
this.marks.markFirstAudibleOutput();
|
|
749
|
+
this.playback?.enqueue(ev.data);
|
|
750
|
+
}
|
|
751
|
+
break;
|
|
752
|
+
case "close":
|
|
753
|
+
if (ev.permanent) {
|
|
754
|
+
const reason = this.proto.endReason ?? (this.lastError ? "error" : "user_hangup");
|
|
755
|
+
this.teardown(reason);
|
|
756
|
+
}
|
|
757
|
+
break;
|
|
758
|
+
case "error":
|
|
759
|
+
this.emitError({ code: "socket_error", message: ev.error.message });
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
this.startCapture = async () => {
|
|
764
|
+
if (this.capture?.isCapturing()) return;
|
|
765
|
+
this.capture = createAudioCapture({
|
|
766
|
+
onChunk: (pcm) => {
|
|
767
|
+
this.marks.markFirstOutboundAudio();
|
|
768
|
+
this.rws?.send(pcm);
|
|
769
|
+
},
|
|
770
|
+
onVolume: (v) => {
|
|
771
|
+
this.inputVolume = v;
|
|
772
|
+
this.args.options.onVolume?.({ input: v, output: this.outputVolume });
|
|
773
|
+
},
|
|
774
|
+
onError: (err) => {
|
|
775
|
+
this.emitError({
|
|
776
|
+
code: err.name === "NotAllowedError" ? "mic_denied" : "mic_start_failed",
|
|
777
|
+
message: err.message
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
if (this.muted) this.capture.mute(true);
|
|
782
|
+
try {
|
|
783
|
+
await this.capture.start();
|
|
784
|
+
} catch {
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
this.teardown = (reason) => {
|
|
788
|
+
try {
|
|
789
|
+
this.marks.flush();
|
|
790
|
+
} catch {
|
|
791
|
+
}
|
|
792
|
+
this.capture?.stop();
|
|
793
|
+
this.capture = null;
|
|
794
|
+
this.playback?.close();
|
|
795
|
+
this.playback = null;
|
|
796
|
+
try {
|
|
797
|
+
this.rws?.close(1e3, reason);
|
|
798
|
+
} catch {
|
|
799
|
+
}
|
|
800
|
+
this.rws = null;
|
|
801
|
+
this.setState("ended");
|
|
802
|
+
this.fireEndOnce(reason);
|
|
803
|
+
};
|
|
804
|
+
this.fireEndOnce = (reason) => {
|
|
805
|
+
if (this.endedFired) return;
|
|
806
|
+
this.endedFired = true;
|
|
807
|
+
const startedAt = this.startedAt ?? Date.now();
|
|
808
|
+
this.args.options.onEnd?.({
|
|
809
|
+
reason,
|
|
810
|
+
errorCode: reason === "error" ? this.lastError?.code : void 0,
|
|
811
|
+
durationMs: Date.now() - startedAt
|
|
812
|
+
});
|
|
813
|
+
};
|
|
814
|
+
this.args = args;
|
|
815
|
+
this.proto = createProtocolState();
|
|
816
|
+
validateClientToolMap(args.options.clientTools);
|
|
817
|
+
this.marks = createClientMarksBuffer({
|
|
818
|
+
send: (frame) => {
|
|
819
|
+
try {
|
|
820
|
+
this.rws?.send(JSON.stringify(frame));
|
|
821
|
+
} catch {
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
// ---------------------------------------------------------------
|
|
827
|
+
// Call interface
|
|
828
|
+
// ---------------------------------------------------------------
|
|
829
|
+
get state() {
|
|
830
|
+
return this.proto.state;
|
|
831
|
+
}
|
|
832
|
+
get transcript() {
|
|
833
|
+
return this.proto.transcript.slice();
|
|
834
|
+
}
|
|
835
|
+
get isMuted() {
|
|
836
|
+
return this.muted;
|
|
837
|
+
}
|
|
838
|
+
// ---------------------------------------------------------------
|
|
839
|
+
// Lifecycle — called by the factory immediately after construction.
|
|
840
|
+
// Resolves once the WS is open and capture is starting; mid-call
|
|
841
|
+
// failures arrive via `onError`.
|
|
842
|
+
// ---------------------------------------------------------------
|
|
843
|
+
async start() {
|
|
844
|
+
this.setState("connecting");
|
|
845
|
+
this.startedAt = Date.now();
|
|
846
|
+
const url = buildWsUrl({
|
|
847
|
+
apiBase: this.args.config.apiBase,
|
|
848
|
+
agentId: this.args.options.agentId,
|
|
849
|
+
token: this.args.token,
|
|
850
|
+
bargeIn: this.args.options.bargeIn
|
|
851
|
+
});
|
|
852
|
+
this.playback = createAudioPlayback({
|
|
853
|
+
onVolume: (v) => {
|
|
854
|
+
this.outputVolume = v;
|
|
855
|
+
this.args.options.onVolume?.({ input: this.inputVolume, output: v });
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
try {
|
|
859
|
+
await this.playback.resume();
|
|
860
|
+
} catch {
|
|
861
|
+
}
|
|
862
|
+
this.rws = createReconnectingWebSocket(
|
|
863
|
+
{
|
|
864
|
+
url,
|
|
865
|
+
wsFactory: this.args.wsFactory,
|
|
866
|
+
maxRetries: 3
|
|
867
|
+
},
|
|
868
|
+
(ev) => this.handleSocketEvent(ev)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
// src/webrtc/createWebRtcCall.ts
|
|
874
|
+
async function createWebRtcCall(opts) {
|
|
875
|
+
validateClientToolMap(opts.clientTools);
|
|
876
|
+
const proto = createProtocolState();
|
|
877
|
+
let muted = false;
|
|
878
|
+
let ended = false;
|
|
879
|
+
const tools = opts.clientTools ?? {};
|
|
880
|
+
const sendControl = (frame) => {
|
|
881
|
+
if (dc?.readyState !== "open") return;
|
|
882
|
+
try {
|
|
883
|
+
dc.send(JSON.stringify(frame));
|
|
884
|
+
} catch {
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
const fireState = (next) => {
|
|
888
|
+
if (proto.state === next) return;
|
|
889
|
+
proto.state = next;
|
|
890
|
+
opts.onStateChange?.(next);
|
|
891
|
+
};
|
|
892
|
+
const dispatch = (raw) => {
|
|
893
|
+
handleServerMessage(raw, proto, {
|
|
894
|
+
onState: fireState,
|
|
895
|
+
onTranscript: (entries) => opts.onTranscript?.(entries),
|
|
896
|
+
onError: (err) => opts.onError?.(err),
|
|
897
|
+
onInterrupt: () => opts.onInterrupt?.(),
|
|
898
|
+
onAgentTurnStart: () => opts.onAgentTurnStart?.(),
|
|
899
|
+
onAgentTurnEnd: () => {
|
|
900
|
+
},
|
|
901
|
+
onCallEnd: () => teardown(),
|
|
902
|
+
onConnected: () => {
|
|
903
|
+
if (Object.keys(tools).length > 0) {
|
|
904
|
+
sendControl(buildRegisterFrame(tools));
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
onClientToolCall: (frame) => {
|
|
908
|
+
dispatchClientToolCall(sendControl, tools, frame);
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
};
|
|
912
|
+
fireState("connecting");
|
|
913
|
+
const pc = new RTCPeerConnection({
|
|
914
|
+
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
|
|
915
|
+
});
|
|
916
|
+
const audioEl = document.createElement("audio");
|
|
917
|
+
audioEl.autoplay = true;
|
|
918
|
+
audioEl.style.display = "none";
|
|
919
|
+
document.body.appendChild(audioEl);
|
|
920
|
+
pc.ontrack = (event) => {
|
|
921
|
+
audioEl.srcObject = event.streams[0] ?? new MediaStream([event.track]);
|
|
922
|
+
};
|
|
923
|
+
let mic;
|
|
924
|
+
try {
|
|
925
|
+
mic = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
926
|
+
} catch (err) {
|
|
927
|
+
const code = err instanceof DOMException && err.name === "NotAllowedError" ? "mic_denied" : "mic_start_failed";
|
|
928
|
+
opts.onError?.({
|
|
929
|
+
code,
|
|
930
|
+
message: err instanceof Error ? err.message : "getUserMedia failed"
|
|
931
|
+
});
|
|
932
|
+
fireState("error");
|
|
933
|
+
pc.close();
|
|
934
|
+
audioEl.remove();
|
|
935
|
+
throw err;
|
|
936
|
+
}
|
|
937
|
+
for (const track of mic.getAudioTracks()) pc.addTrack(track, mic);
|
|
938
|
+
const dc = pc.createDataChannel("control", { ordered: true });
|
|
939
|
+
dc.onmessage = (e) => {
|
|
940
|
+
if (typeof e.data === "string") dispatch(e.data);
|
|
941
|
+
};
|
|
942
|
+
dc.onerror = () => {
|
|
943
|
+
opts.onError?.({ code: "socket_error", message: "control channel error" });
|
|
944
|
+
};
|
|
945
|
+
dc.onopen = () => {
|
|
946
|
+
if (Object.keys(tools).length > 0) {
|
|
947
|
+
sendControl(buildRegisterFrame(tools));
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
const gateway = opts.webrtcGatewayBase || "";
|
|
951
|
+
const offerUrl = gateway ? `${gateway}/webrtc/offer?token=${encodeURIComponent(opts.token)}` : `${opts.apiBase}/v1/agents/${encodeURIComponent(opts.agentId)}/webrtc/offer?token=${encodeURIComponent(opts.token)}`;
|
|
952
|
+
const iceUrl = gateway ? `${gateway}/webrtc/ice?token=${encodeURIComponent(opts.token)}` : `${opts.apiBase}/v1/agents/${encodeURIComponent(opts.agentId)}/webrtc/ice?token=${encodeURIComponent(opts.token)}`;
|
|
953
|
+
const teardown = () => {
|
|
954
|
+
if (ended) return;
|
|
955
|
+
ended = true;
|
|
956
|
+
try {
|
|
957
|
+
mic.getTracks().forEach((t) => t.stop());
|
|
958
|
+
} catch {
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
pc.close();
|
|
962
|
+
} catch {
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
audioEl.remove();
|
|
966
|
+
} catch {
|
|
967
|
+
}
|
|
968
|
+
fireState("ended");
|
|
969
|
+
opts.onEnd?.();
|
|
970
|
+
};
|
|
971
|
+
let callId = null;
|
|
972
|
+
const pendingCandidates = [];
|
|
973
|
+
const postCandidate = (candidate) => {
|
|
974
|
+
void fetch(iceUrl, {
|
|
975
|
+
method: "POST",
|
|
976
|
+
headers: { "content-type": "application/json" },
|
|
977
|
+
body: JSON.stringify({ callId, candidate })
|
|
978
|
+
}).catch(() => {
|
|
979
|
+
});
|
|
980
|
+
};
|
|
981
|
+
pc.onicecandidate = (e) => {
|
|
982
|
+
if (!e.candidate) return;
|
|
983
|
+
if (callId) postCandidate(e.candidate);
|
|
984
|
+
else pendingCandidates.push(e.candidate);
|
|
985
|
+
};
|
|
986
|
+
pc.onconnectionstatechange = () => {
|
|
987
|
+
const s = pc.connectionState;
|
|
988
|
+
if (s === "connected") fireState("listening");
|
|
989
|
+
if (s === "failed" || s === "disconnected") {
|
|
990
|
+
opts.onError?.({ code: "socket_error", message: `webrtc connection ${s}` });
|
|
991
|
+
teardown();
|
|
992
|
+
}
|
|
993
|
+
if (s === "closed" && !ended) teardown();
|
|
994
|
+
};
|
|
995
|
+
await pc.setLocalDescription(await pc.createOffer());
|
|
996
|
+
try {
|
|
997
|
+
const offerRes = await fetch(offerUrl, {
|
|
998
|
+
method: "POST",
|
|
999
|
+
headers: { "content-type": "application/json" },
|
|
1000
|
+
body: JSON.stringify({ sdp: pc.localDescription.sdp, type: "offer", agentId: opts.agentId })
|
|
1001
|
+
});
|
|
1002
|
+
if (!offerRes.ok) {
|
|
1003
|
+
const code = offerRes.status === 401 ? "unauthorized" : "server_error";
|
|
1004
|
+
opts.onError?.({ code, message: `signaling failed: HTTP ${offerRes.status}` });
|
|
1005
|
+
fireState("error");
|
|
1006
|
+
mic.getTracks().forEach((t) => t.stop());
|
|
1007
|
+
pc.close();
|
|
1008
|
+
audioEl.remove();
|
|
1009
|
+
throw new Error(`webrtc offer failed: ${offerRes.status}`);
|
|
1010
|
+
}
|
|
1011
|
+
const body = await offerRes.json();
|
|
1012
|
+
callId = body.callId;
|
|
1013
|
+
await pc.setRemoteDescription({ type: "answer", sdp: body.sdp });
|
|
1014
|
+
while (pendingCandidates.length > 0) postCandidate(pendingCandidates.shift());
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
if (!ended) {
|
|
1017
|
+
opts.onError?.({
|
|
1018
|
+
code: "network_unreachable",
|
|
1019
|
+
message: err instanceof Error ? err.message : "signaling failed"
|
|
1020
|
+
});
|
|
1021
|
+
fireState("error");
|
|
1022
|
+
mic.getTracks().forEach((t) => t.stop());
|
|
1023
|
+
pc.close();
|
|
1024
|
+
audioEl.remove();
|
|
1025
|
+
}
|
|
1026
|
+
throw err;
|
|
1027
|
+
}
|
|
1028
|
+
return {
|
|
1029
|
+
get state() {
|
|
1030
|
+
return proto.state;
|
|
1031
|
+
},
|
|
1032
|
+
get transcript() {
|
|
1033
|
+
return proto.transcript.slice();
|
|
1034
|
+
},
|
|
1035
|
+
get isMuted() {
|
|
1036
|
+
return muted;
|
|
1037
|
+
},
|
|
1038
|
+
end: () => teardown(),
|
|
1039
|
+
mute: () => {
|
|
1040
|
+
if (muted) return;
|
|
1041
|
+
muted = true;
|
|
1042
|
+
mic.getAudioTracks().forEach((t) => t.enabled = false);
|
|
1043
|
+
},
|
|
1044
|
+
unmute: () => {
|
|
1045
|
+
if (!muted) return;
|
|
1046
|
+
muted = false;
|
|
1047
|
+
mic.getAudioTracks().forEach((t) => t.enabled = true);
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/textSession.ts
|
|
1053
|
+
async function startTextSession(opts) {
|
|
1054
|
+
const f = opts.fetch ?? fetch;
|
|
1055
|
+
const tokenQs = `?token=${encodeURIComponent(opts.token)}`;
|
|
1056
|
+
const startUrl = `${opts.baseUrl}/v1/agents/${opts.agentId}/chat${tokenQs}`;
|
|
1057
|
+
const startBody = opts.text ? JSON.stringify({ text: opts.text }) : "{}";
|
|
1058
|
+
const res = await f(startUrl, {
|
|
1059
|
+
method: "POST",
|
|
1060
|
+
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
|
|
1061
|
+
body: startBody
|
|
1062
|
+
});
|
|
1063
|
+
if (!res.ok || !res.body) {
|
|
1064
|
+
const text = await res.text().catch(() => "");
|
|
1065
|
+
throw new Error(`startTextSession failed: ${res.status} ${text}`);
|
|
1066
|
+
}
|
|
1067
|
+
const iter = parseSse(res.body);
|
|
1068
|
+
let chatId = "";
|
|
1069
|
+
let callId = "";
|
|
1070
|
+
const buffered = [];
|
|
1071
|
+
const it = iter[Symbol.asyncIterator]();
|
|
1072
|
+
while (true) {
|
|
1073
|
+
const { value, done } = await it.next();
|
|
1074
|
+
if (done) break;
|
|
1075
|
+
if (value.type === "chat.started") {
|
|
1076
|
+
chatId = value.chatId;
|
|
1077
|
+
callId = value.callId;
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
buffered.push(value);
|
|
1081
|
+
}
|
|
1082
|
+
return {
|
|
1083
|
+
id: chatId,
|
|
1084
|
+
callId,
|
|
1085
|
+
greeting: replayThen(buffered, { [Symbol.asyncIterator]: () => it }),
|
|
1086
|
+
async send(text) {
|
|
1087
|
+
const r = await f(`${opts.baseUrl}/v1/chats/${chatId}/messages${tokenQs}`, {
|
|
1088
|
+
method: "POST",
|
|
1089
|
+
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
|
|
1090
|
+
body: JSON.stringify({ text })
|
|
1091
|
+
});
|
|
1092
|
+
if (!r.ok || !r.body) {
|
|
1093
|
+
const errText = await r.text().catch(() => "");
|
|
1094
|
+
throw new Error(`send failed: ${r.status} ${errText}`);
|
|
1095
|
+
}
|
|
1096
|
+
return parseSse(r.body);
|
|
1097
|
+
},
|
|
1098
|
+
async end() {
|
|
1099
|
+
await f(`${opts.baseUrl}/v1/calls/${callId}`, { method: "DELETE" });
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
async function* parseSse(body) {
|
|
1104
|
+
const reader = body.getReader();
|
|
1105
|
+
const decoder = new TextDecoder();
|
|
1106
|
+
let buf = "";
|
|
1107
|
+
while (true) {
|
|
1108
|
+
const { value, done } = await reader.read();
|
|
1109
|
+
if (done) return;
|
|
1110
|
+
buf += decoder.decode(value, { stream: true });
|
|
1111
|
+
let idx;
|
|
1112
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
1113
|
+
const chunk = buf.slice(0, idx);
|
|
1114
|
+
buf = buf.slice(idx + 2);
|
|
1115
|
+
let event = "message";
|
|
1116
|
+
let data = "";
|
|
1117
|
+
for (const line of chunk.split("\n")) {
|
|
1118
|
+
if (line.startsWith(":")) continue;
|
|
1119
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
1120
|
+
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
1121
|
+
}
|
|
1122
|
+
if (!data) continue;
|
|
1123
|
+
try {
|
|
1124
|
+
const parsed = JSON.parse(data);
|
|
1125
|
+
yield { type: event, ...parsed };
|
|
1126
|
+
} catch {
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
async function* replayThen(buffered, rest) {
|
|
1132
|
+
for (const x of buffered) yield x;
|
|
1133
|
+
for await (const x of rest) yield x;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// src/incomingCall.ts
|
|
1137
|
+
var parseIncomingCall = (raw) => {
|
|
1138
|
+
if (typeof raw !== "object" || raw === null) {
|
|
1139
|
+
throw new Error("parseIncomingCall: payload must be an object");
|
|
1140
|
+
}
|
|
1141
|
+
const p = raw;
|
|
1142
|
+
if (typeof p.token !== "string" || !p.token.startsWith("ct_")) {
|
|
1143
|
+
throw new Error("parseIncomingCall: missing or invalid `token` (expected a ct_ string)");
|
|
1144
|
+
}
|
|
1145
|
+
if (typeof p.agentId !== "string" || p.agentId.length === 0) {
|
|
1146
|
+
throw new Error("parseIncomingCall: missing `agentId`");
|
|
1147
|
+
}
|
|
1148
|
+
const transport = p.transport === "webrtc" ? "webrtc" : "ws";
|
|
1149
|
+
const out = { token: p.token, agentId: p.agentId, transport };
|
|
1150
|
+
if (transport === "webrtc" && typeof p.webrtcGatewayBase === "string") {
|
|
1151
|
+
out.webrtcGatewayBase = p.webrtcGatewayBase;
|
|
1152
|
+
}
|
|
1153
|
+
if (typeof p.expiresAt === "number") out.expiresAt = p.expiresAt;
|
|
1154
|
+
if (typeof p.agentName === "string") out.agentName = p.agentName;
|
|
1155
|
+
if (typeof p.agentAvatarUrl === "string") out.agentAvatarUrl = p.agentAvatarUrl;
|
|
1156
|
+
return out;
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
// src/assistant.ts
|
|
1160
|
+
var browserWsFactory = (url) => new globalThis.WebSocket(url);
|
|
1161
|
+
var AssistantVoiceFactory = class {
|
|
1162
|
+
constructor(config) {
|
|
1163
|
+
this.startCall = async (options) => {
|
|
1164
|
+
if (!options.agentId) {
|
|
1165
|
+
throw new Error("startCall: agentId is required");
|
|
1166
|
+
}
|
|
1167
|
+
const { context, metadata } = mergeStartCallContext(this.config, options);
|
|
1168
|
+
const fetchArgs = {
|
|
1169
|
+
agentId: options.agentId,
|
|
1170
|
+
userId: options.userId,
|
|
1171
|
+
context,
|
|
1172
|
+
metadata
|
|
1173
|
+
};
|
|
1174
|
+
let resolved;
|
|
1175
|
+
if (options.token) {
|
|
1176
|
+
resolved = { token: options.token, transport: "ws" };
|
|
1177
|
+
} else {
|
|
1178
|
+
const r = await this.config.fetchToken(fetchArgs);
|
|
1179
|
+
if (!r) {
|
|
1180
|
+
throw new Error("configureVoiceClient.fetchToken returned empty token");
|
|
1181
|
+
}
|
|
1182
|
+
resolved = typeof r === "string" ? { token: r, transport: "ws" } : r;
|
|
1183
|
+
if (!resolved.token) {
|
|
1184
|
+
throw new Error("configureVoiceClient.fetchToken returned an object without `token`");
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (resolved.transport === "webrtc") {
|
|
1188
|
+
return createWebRtcCall({
|
|
1189
|
+
agentId: options.agentId,
|
|
1190
|
+
apiBase: this.config.apiBase,
|
|
1191
|
+
token: resolved.token,
|
|
1192
|
+
webrtcGatewayBase: resolved.webrtcGatewayBase,
|
|
1193
|
+
onStateChange: options.onStateChange,
|
|
1194
|
+
onTranscript: options.onTranscript,
|
|
1195
|
+
onError: options.onError,
|
|
1196
|
+
// Synthesise a minimal CallEndEvent. WebRTC doesn't carry an end reason
|
|
1197
|
+
// from the server yet — use 'agent_ended' as placeholder. durationMs is
|
|
1198
|
+
// tracked at 0 until the followup lands (see spec Followups section).
|
|
1199
|
+
onEnd: options.onEnd ? () => options.onEnd({ reason: "agent_ended", durationMs: 0 }) : void 0,
|
|
1200
|
+
onInterrupt: options.onInterrupt,
|
|
1201
|
+
onAgentTurnStart: options.onAgentTurnStart,
|
|
1202
|
+
clientTools: options.clientTools
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
const client = new BrowserVoiceClient({
|
|
1206
|
+
config: this.config,
|
|
1207
|
+
// Carry merged context/metadata through to startCall so server can
|
|
1208
|
+
// see what the SDK saw.
|
|
1209
|
+
options: { ...options, context, metadata },
|
|
1210
|
+
token: resolved.token,
|
|
1211
|
+
wsFactory: browserWsFactory
|
|
1212
|
+
});
|
|
1213
|
+
await client.start();
|
|
1214
|
+
return client;
|
|
1215
|
+
};
|
|
1216
|
+
// Text-channel chat session (no microphone / audio).
|
|
1217
|
+
// Mint a `ct_` token with `channel: 'text'` on your backend, then call
|
|
1218
|
+
// this to open an SSE stream against the chat API.
|
|
1219
|
+
this.startTextSession = (opts) => startTextSession({
|
|
1220
|
+
...opts,
|
|
1221
|
+
baseUrl: this.config.apiBase
|
|
1222
|
+
});
|
|
1223
|
+
this.config = config;
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
function configureVoiceClient(config) {
|
|
1227
|
+
return new AssistantVoiceFactory(normalizeConfig(config));
|
|
1228
|
+
}
|
|
1229
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1230
|
+
0 && (module.exports = {
|
|
1231
|
+
buildWsUrl,
|
|
1232
|
+
configureVoiceClient,
|
|
1233
|
+
createAudioCapture,
|
|
1234
|
+
createAudioPlayback,
|
|
1235
|
+
createProtocolState,
|
|
1236
|
+
createReconnectingWebSocket,
|
|
1237
|
+
handleServerMessage,
|
|
1238
|
+
parseIncomingCall,
|
|
1239
|
+
startTextSession
|
|
1240
|
+
});
|
|
1241
|
+
//# sourceMappingURL=assistant.js.map
|