@cloudflare/voice-telnyx 0.0.0 → 0.0.2
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 +160 -0
- package/dist/browser.d.ts +304 -0
- package/dist/browser.js +1010 -0
- package/dist/browser.js.map +1 -0
- package/dist/client-C14kiRwB.js +11 -0
- package/dist/client-C14kiRwB.js.map +1 -0
- package/dist/client-tnkkrw_G.d.ts +19 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +180 -0
- package/dist/index.js.map +1 -0
- package/dist/stt-DPden1_S.js +138 -0
- package/dist/stt-DPden1_S.js.map +1 -0
- package/dist/stt-HkxzilR4.d.ts +73 -0
- package/dist/stt.d.ts +6 -0
- package/dist/stt.js +2 -0
- package/dist/tts-BNsM0WUr.js +181 -0
- package/dist/tts-BNsM0WUr.js.map +1 -0
- package/dist/tts-D73UUcew.d.ts +53 -0
- package/dist/tts.d.ts +2 -0
- package/dist/tts.js +2 -0
- package/package.json +61 -8
package/dist/browser.js
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import { TelnyxRTC } from "@telnyx/webrtc";
|
|
2
|
+
//#region src/audio/utils.ts
|
|
3
|
+
/**
|
|
4
|
+
* Convert Float32 audio samples (-1.0..1.0) to Int16 PCM (-32768..32767).
|
|
5
|
+
* Clamps values outside the -1..1 range.
|
|
6
|
+
*/
|
|
7
|
+
function float32ToInt16(float32) {
|
|
8
|
+
const int16 = new Int16Array(float32.length);
|
|
9
|
+
for (let i = 0; i < float32.length; i++) {
|
|
10
|
+
const clamped = Math.max(-1, Math.min(1, float32[i]));
|
|
11
|
+
int16[i] = clamped < 0 ? clamped * 32768 : clamped * 32767;
|
|
12
|
+
}
|
|
13
|
+
return int16;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Compute the Root Mean Square (RMS) of audio samples.
|
|
17
|
+
* Returns 0 for empty input.
|
|
18
|
+
*/
|
|
19
|
+
function computeRMS(samples) {
|
|
20
|
+
if (samples.length === 0) return 0;
|
|
21
|
+
let sum = 0;
|
|
22
|
+
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
|
|
23
|
+
return Math.sqrt(sum / samples.length);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* AudioWorklet processor source code for capturing PCM from a MediaStream.
|
|
27
|
+
*
|
|
28
|
+
* This processor collects Float32 audio frames and posts them to the
|
|
29
|
+
* main thread via the MessagePort. It runs in the AudioWorklet thread.
|
|
30
|
+
*
|
|
31
|
+
* Expected AudioContext sampleRate: 16000 (browser resamples from source).
|
|
32
|
+
*/
|
|
33
|
+
const PCM_CAPTURE_PROCESSOR_SOURCE = `
|
|
34
|
+
class PcmCaptureProcessor extends AudioWorkletProcessor {
|
|
35
|
+
process(inputs) {
|
|
36
|
+
const input = inputs[0];
|
|
37
|
+
if (input && input[0] && input[0].length > 0) {
|
|
38
|
+
// Post a copy of the Float32 channel data to the main thread
|
|
39
|
+
this.port.postMessage(new Float32Array(input[0]));
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
registerProcessor("pcm-capture-processor", PcmCaptureProcessor);
|
|
45
|
+
`;
|
|
46
|
+
/**
|
|
47
|
+
* AudioWorklet processor source code for playing back PCM into a MediaStream.
|
|
48
|
+
*
|
|
49
|
+
* Receives Float32 audio frames from the main thread via MessagePort
|
|
50
|
+
* and writes them to the output buffer. Buffers frames to handle timing
|
|
51
|
+
* differences between the main thread and the audio thread.
|
|
52
|
+
*
|
|
53
|
+
* Expected AudioContext sampleRate: 48000 (matching WebRTC).
|
|
54
|
+
*/
|
|
55
|
+
const PCM_PLAYBACK_PROCESSOR_SOURCE = `
|
|
56
|
+
class PcmPlaybackProcessor extends AudioWorkletProcessor {
|
|
57
|
+
constructor() {
|
|
58
|
+
super();
|
|
59
|
+
this._buffer = [];
|
|
60
|
+
this._maxBufferFrames = 50; // ~1s at 48kHz/128 samples per frame
|
|
61
|
+
this.port.onmessage = (e) => {
|
|
62
|
+
if (e.data === 'clear') {
|
|
63
|
+
this._buffer = [];
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this._buffer.push(e.data);
|
|
67
|
+
// Evict oldest frames if buffer grows too large
|
|
68
|
+
while (this._buffer.length > this._maxBufferFrames) {
|
|
69
|
+
this._buffer.shift();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
process(inputs, outputs) {
|
|
75
|
+
const output = outputs[0];
|
|
76
|
+
if (!output || !output[0]) return true;
|
|
77
|
+
|
|
78
|
+
const channel = output[0];
|
|
79
|
+
let written = 0;
|
|
80
|
+
|
|
81
|
+
while (written < channel.length && this._buffer.length > 0) {
|
|
82
|
+
const frame = this._buffer[0];
|
|
83
|
+
const available = frame.length;
|
|
84
|
+
const needed = channel.length - written;
|
|
85
|
+
|
|
86
|
+
if (available <= needed) {
|
|
87
|
+
channel.set(frame, written);
|
|
88
|
+
written += available;
|
|
89
|
+
this._buffer.shift();
|
|
90
|
+
} else {
|
|
91
|
+
channel.set(frame.subarray(0, needed), written);
|
|
92
|
+
this._buffer[0] = frame.subarray(needed);
|
|
93
|
+
written += needed;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fill remainder with silence
|
|
98
|
+
for (let i = written; i < channel.length; i++) {
|
|
99
|
+
channel[i] = 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
registerProcessor("pcm-playback-processor", PcmPlaybackProcessor);
|
|
106
|
+
`;
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/providers/call-bridge.ts
|
|
109
|
+
function isRecord(value) {
|
|
110
|
+
return typeof value === "object" && value !== null;
|
|
111
|
+
}
|
|
112
|
+
function isTelnyxNotification(value) {
|
|
113
|
+
return isRecord(value);
|
|
114
|
+
}
|
|
115
|
+
function hasNewCall(client) {
|
|
116
|
+
return "newCall" in client && typeof client.newCall === "function";
|
|
117
|
+
}
|
|
118
|
+
function getPeerConnection(call) {
|
|
119
|
+
return call.peer?.instance;
|
|
120
|
+
}
|
|
121
|
+
function getRemoteStream(call) {
|
|
122
|
+
return call.remoteStream ?? null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Bridges Telnyx phone calls into the Cloudflare voice pipeline.
|
|
126
|
+
*
|
|
127
|
+
* Implements `VoiceAudioInput` from @cloudflare/voice — extracts PCM
|
|
128
|
+
* audio from inbound phone calls and feeds it to the AI pipeline.
|
|
129
|
+
* Also provides `playAudio()` for injecting response audio back
|
|
130
|
+
* into the phone call.
|
|
131
|
+
*
|
|
132
|
+
* Usage:
|
|
133
|
+
* ```typescript
|
|
134
|
+
* const bridge = new TelnyxCallBridge({ loginToken: jwt });
|
|
135
|
+
* const voiceClient = new VoiceClient({
|
|
136
|
+
* agent: "my-agent",
|
|
137
|
+
* audioInput: bridge,
|
|
138
|
+
* });
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
var TelnyxCallBridge = class {
|
|
142
|
+
constructor(config) {
|
|
143
|
+
this.onAudioLevel = null;
|
|
144
|
+
this.onAudioData = null;
|
|
145
|
+
this._connected = false;
|
|
146
|
+
this._activeCall = null;
|
|
147
|
+
this.client = null;
|
|
148
|
+
this.captureContext = null;
|
|
149
|
+
this.captureSource = null;
|
|
150
|
+
this.captureWorklet = null;
|
|
151
|
+
this.captureBlobUrl = null;
|
|
152
|
+
this.captureAudioEl = null;
|
|
153
|
+
this.statsInterval = null;
|
|
154
|
+
this.playbackContext = null;
|
|
155
|
+
this.playbackWorklet = null;
|
|
156
|
+
this.playbackBlobUrl = null;
|
|
157
|
+
this.startPromise = null;
|
|
158
|
+
this.finishStart = null;
|
|
159
|
+
this.startAttempt = 0;
|
|
160
|
+
this.mediaSetupAttempt = 0;
|
|
161
|
+
this.config = config;
|
|
162
|
+
}
|
|
163
|
+
/** Whether the Telnyx client is connected to the platform. */
|
|
164
|
+
get connected() {
|
|
165
|
+
return this._connected;
|
|
166
|
+
}
|
|
167
|
+
/** The currently active Telnyx call, or null. */
|
|
168
|
+
get activeCall() {
|
|
169
|
+
return this._activeCall;
|
|
170
|
+
}
|
|
171
|
+
/** Connect to Telnyx and start listening for calls. */
|
|
172
|
+
async start() {
|
|
173
|
+
if (this._connected) return;
|
|
174
|
+
if (this.startPromise) return this.startPromise;
|
|
175
|
+
const attempt = ++this.startAttempt;
|
|
176
|
+
const client = new TelnyxRTC({
|
|
177
|
+
login_token: this.config.loginToken,
|
|
178
|
+
debug: this.config.debug
|
|
179
|
+
});
|
|
180
|
+
this.client = client;
|
|
181
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
182
|
+
this.finishStart = resolve;
|
|
183
|
+
client.on("telnyx.ready", () => {
|
|
184
|
+
if (this.startAttempt !== attempt || this.client !== client) {
|
|
185
|
+
resolve();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this._connected = true;
|
|
189
|
+
resolve();
|
|
190
|
+
});
|
|
191
|
+
client.on("telnyx.error", (error) => {
|
|
192
|
+
if (this.startAttempt !== attempt || this.client !== client) {
|
|
193
|
+
resolve();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
reject(error);
|
|
197
|
+
});
|
|
198
|
+
client.on("telnyx.notification", (notification) => {
|
|
199
|
+
if (this.startAttempt !== attempt || this.client !== client) return;
|
|
200
|
+
this.handleNotification(notification);
|
|
201
|
+
});
|
|
202
|
+
client.connect();
|
|
203
|
+
}).finally(() => {
|
|
204
|
+
if (this.startAttempt === attempt) {
|
|
205
|
+
this.startPromise = null;
|
|
206
|
+
this.finishStart = null;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
return this.startPromise;
|
|
210
|
+
}
|
|
211
|
+
/** Answer the current inbound call. */
|
|
212
|
+
answer() {
|
|
213
|
+
if (!this._activeCall) throw new Error("No active call");
|
|
214
|
+
this._activeCall.answer?.();
|
|
215
|
+
}
|
|
216
|
+
/** End the active call. */
|
|
217
|
+
hangup() {
|
|
218
|
+
if (!this._activeCall) return;
|
|
219
|
+
this._activeCall.hangup?.();
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Initiate an outbound PSTN call.
|
|
223
|
+
* @param destination Phone number or SIP URI to call.
|
|
224
|
+
* @param callerNumber The caller ID number to present.
|
|
225
|
+
* @returns The Telnyx Call object.
|
|
226
|
+
*/
|
|
227
|
+
dial(destination, callerNumber) {
|
|
228
|
+
if (!this.client) throw new Error("Not connected — call start() first");
|
|
229
|
+
if (!hasNewCall(this.client)) throw new Error("Telnyx client does not expose newCall()");
|
|
230
|
+
const call = this.client.newCall({
|
|
231
|
+
destinationNumber: destination,
|
|
232
|
+
callerNumber
|
|
233
|
+
});
|
|
234
|
+
this._activeCall = call;
|
|
235
|
+
return call;
|
|
236
|
+
}
|
|
237
|
+
/** Send DTMF digits on the active call. */
|
|
238
|
+
sendDTMF(digits) {
|
|
239
|
+
if (!this._activeCall) throw new Error("No active call");
|
|
240
|
+
this._activeCall.dtmf?.(digits);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Clear any buffered audio in the playback pipeline.
|
|
244
|
+
* Used during interrupt detection to stop stale audio from playing.
|
|
245
|
+
*/
|
|
246
|
+
clearPlaybackBuffer() {
|
|
247
|
+
this.playbackWorklet?.port.postMessage("clear");
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Inject PCM audio into the active phone call (agent → caller).
|
|
251
|
+
* Accepts 16kHz mono Int16 PCM. Upsamples to 48kHz for WebRTC.
|
|
252
|
+
* No-op if no active call.
|
|
253
|
+
*/
|
|
254
|
+
playAudio(pcm) {
|
|
255
|
+
if (!this.playbackWorklet) return;
|
|
256
|
+
const int16 = new Int16Array(pcm);
|
|
257
|
+
const upsampleRatio = 3;
|
|
258
|
+
const float32 = new Float32Array(int16.length * upsampleRatio);
|
|
259
|
+
for (let i = 0; i < int16.length; i++) {
|
|
260
|
+
const current = int16[i] / 32768;
|
|
261
|
+
const next = i < int16.length - 1 ? int16[i + 1] / 32768 : current;
|
|
262
|
+
const base = i * upsampleRatio;
|
|
263
|
+
for (let j = 0; j < upsampleRatio; j++) float32[base + j] = current + (next - current) * (j / upsampleRatio);
|
|
264
|
+
}
|
|
265
|
+
this.playbackWorklet.port.postMessage(float32);
|
|
266
|
+
}
|
|
267
|
+
/** Disconnect from Telnyx and clean up all resources. */
|
|
268
|
+
stop() {
|
|
269
|
+
this.startAttempt++;
|
|
270
|
+
this.mediaSetupAttempt++;
|
|
271
|
+
this.finishStart?.();
|
|
272
|
+
this.finishStart = null;
|
|
273
|
+
this.startPromise = null;
|
|
274
|
+
this.stopAudioCapture();
|
|
275
|
+
this.stopAudioPlayback();
|
|
276
|
+
this._activeCall = null;
|
|
277
|
+
this._connected = false;
|
|
278
|
+
if (this.client) {
|
|
279
|
+
this.client.disconnect();
|
|
280
|
+
this.client = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
handleNotification(notification) {
|
|
284
|
+
if (!isTelnyxNotification(notification)) return;
|
|
285
|
+
console.log("[TelnyxCallBridge] notification:", notification.type, "call:", !!notification.call, "state:", notification.call?.state);
|
|
286
|
+
if (notification.type !== "callUpdate" || !notification.call) return;
|
|
287
|
+
const call = notification.call;
|
|
288
|
+
console.log("[TelnyxCallBridge] call state:", call.state);
|
|
289
|
+
switch (call.state) {
|
|
290
|
+
case "ringing":
|
|
291
|
+
this._activeCall = call;
|
|
292
|
+
if (this.config.autoAnswer) {
|
|
293
|
+
console.log("[TelnyxCallBridge] auto-answering call");
|
|
294
|
+
call.answer?.();
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
case "active":
|
|
298
|
+
this._activeCall = call;
|
|
299
|
+
console.log("[TelnyxCallBridge] call active — starting audio capture + playback");
|
|
300
|
+
this.mediaSetupAttempt++;
|
|
301
|
+
this.stopAudioCapture();
|
|
302
|
+
this.stopAudioPlayback();
|
|
303
|
+
this.startAudioCapture(call, this.mediaSetupAttempt).catch((err) => console.error("[TelnyxCallBridge] startAudioCapture failed:", err));
|
|
304
|
+
this.startAudioPlayback(call, this.mediaSetupAttempt).catch((err) => console.error("[TelnyxCallBridge] startAudioPlayback failed:", err));
|
|
305
|
+
break;
|
|
306
|
+
case "hangup":
|
|
307
|
+
case "destroy":
|
|
308
|
+
case "purge":
|
|
309
|
+
this.mediaSetupAttempt++;
|
|
310
|
+
this.stopAudioCapture();
|
|
311
|
+
this.stopAudioPlayback();
|
|
312
|
+
this._activeCall = null;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
isCurrentMediaSetup(setupAttempt) {
|
|
317
|
+
return this.mediaSetupAttempt === setupAttempt;
|
|
318
|
+
}
|
|
319
|
+
cleanupCaptureResources(resources) {
|
|
320
|
+
resources.worklet?.disconnect();
|
|
321
|
+
resources.source?.disconnect();
|
|
322
|
+
resources.context?.close();
|
|
323
|
+
if (resources.blobUrl) URL.revokeObjectURL(resources.blobUrl);
|
|
324
|
+
if (resources.audioEl) {
|
|
325
|
+
resources.audioEl.pause();
|
|
326
|
+
resources.audioEl.srcObject = null;
|
|
327
|
+
resources.audioEl.remove();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
cleanupPlaybackResources(resources) {
|
|
331
|
+
resources.worklet?.disconnect();
|
|
332
|
+
resources.context?.close();
|
|
333
|
+
if (resources.blobUrl) URL.revokeObjectURL(resources.blobUrl);
|
|
334
|
+
}
|
|
335
|
+
async startAudioCapture(call, setupAttempt) {
|
|
336
|
+
const pc = getPeerConnection(call);
|
|
337
|
+
let track = null;
|
|
338
|
+
if (pc) track = pc.getReceivers().find((r) => r.track?.kind === "audio")?.track ?? null;
|
|
339
|
+
if (!track) track = getRemoteStream(call)?.getAudioTracks()?.[0] ?? null;
|
|
340
|
+
if (!track || track.readyState !== "live") {
|
|
341
|
+
console.warn("[TelnyxCallBridge] No live audio track — audio capture skipped");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
track.enabled = true;
|
|
345
|
+
const remoteStream = new MediaStream([track]);
|
|
346
|
+
const captureAudioEl = document.createElement("audio");
|
|
347
|
+
captureAudioEl.srcObject = remoteStream;
|
|
348
|
+
captureAudioEl.autoplay = true;
|
|
349
|
+
captureAudioEl.volume = 0;
|
|
350
|
+
document.body.appendChild(captureAudioEl);
|
|
351
|
+
try {
|
|
352
|
+
await captureAudioEl.play();
|
|
353
|
+
} catch (e) {
|
|
354
|
+
console.warn("[TelnyxCallBridge] audio element play() failed:", e);
|
|
355
|
+
}
|
|
356
|
+
if (track.muted) {
|
|
357
|
+
console.log("[TelnyxCallBridge] track muted — waiting for unmute...");
|
|
358
|
+
await new Promise((resolve) => {
|
|
359
|
+
const onUnmute = () => {
|
|
360
|
+
track.removeEventListener("unmute", onUnmute);
|
|
361
|
+
console.log("[TelnyxCallBridge] track unmuted");
|
|
362
|
+
resolve();
|
|
363
|
+
};
|
|
364
|
+
track.addEventListener("unmute", onUnmute);
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
track.removeEventListener("unmute", onUnmute);
|
|
367
|
+
resolve();
|
|
368
|
+
}, 5e3);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (!this.isCurrentMediaSetup(setupAttempt)) {
|
|
372
|
+
this.cleanupCaptureResources({ audioEl: captureAudioEl });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const captureContext = new AudioContext({ sampleRate: 48e3 });
|
|
376
|
+
if (captureContext.state === "suspended") await captureContext.resume();
|
|
377
|
+
if (!this.isCurrentMediaSetup(setupAttempt)) {
|
|
378
|
+
this.cleanupCaptureResources({
|
|
379
|
+
audioEl: captureAudioEl,
|
|
380
|
+
context: captureContext
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const blob = new Blob([PCM_CAPTURE_PROCESSOR_SOURCE], { type: "application/javascript" });
|
|
385
|
+
const captureBlobUrl = URL.createObjectURL(blob);
|
|
386
|
+
await captureContext.audioWorklet.addModule(captureBlobUrl);
|
|
387
|
+
if (!this.isCurrentMediaSetup(setupAttempt)) {
|
|
388
|
+
this.cleanupCaptureResources({
|
|
389
|
+
audioEl: captureAudioEl,
|
|
390
|
+
context: captureContext,
|
|
391
|
+
blobUrl: captureBlobUrl
|
|
392
|
+
});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const captureSource = captureContext.createMediaStreamSource(remoteStream);
|
|
396
|
+
const captureWorklet = new AudioWorkletNode(captureContext, "pcm-capture-processor");
|
|
397
|
+
const downsampleRatio = 3;
|
|
398
|
+
let captureCount = 0;
|
|
399
|
+
captureWorklet.port.onmessage = (event) => {
|
|
400
|
+
if (!(event.data instanceof Float32Array)) return;
|
|
401
|
+
const raw = event.data;
|
|
402
|
+
const outLen = Math.floor(raw.length / downsampleRatio);
|
|
403
|
+
const float32 = new Float32Array(outLen);
|
|
404
|
+
for (let i = 0; i < outLen; i++) {
|
|
405
|
+
const srcIdx = i * downsampleRatio;
|
|
406
|
+
const idx0 = Math.floor(srcIdx);
|
|
407
|
+
const idx1 = Math.min(idx0 + 1, raw.length - 1);
|
|
408
|
+
const frac = srcIdx - idx0;
|
|
409
|
+
float32[i] = raw[idx0] * (1 - frac) + raw[idx1] * frac;
|
|
410
|
+
}
|
|
411
|
+
const rms = computeRMS(float32);
|
|
412
|
+
captureCount++;
|
|
413
|
+
if (captureCount <= 5 || captureCount % 200 === 0) console.log(`[TelnyxCallBridge] capture #${captureCount} rms=${rms.toFixed(4)} samples=${float32.length}`);
|
|
414
|
+
this.onAudioLevel?.(rms);
|
|
415
|
+
const int16 = float32ToInt16(float32);
|
|
416
|
+
this.onAudioData?.(int16.buffer);
|
|
417
|
+
};
|
|
418
|
+
this.captureAudioEl = captureAudioEl;
|
|
419
|
+
this.captureContext = captureContext;
|
|
420
|
+
this.captureBlobUrl = captureBlobUrl;
|
|
421
|
+
this.captureSource = captureSource;
|
|
422
|
+
this.captureWorklet = captureWorklet;
|
|
423
|
+
captureSource.connect(captureWorklet);
|
|
424
|
+
captureWorklet.connect(captureContext.destination);
|
|
425
|
+
if (pc) this.monitorInboundStats(pc);
|
|
426
|
+
}
|
|
427
|
+
monitorInboundStats(pc) {
|
|
428
|
+
let count = 0;
|
|
429
|
+
this.statsInterval = setInterval(async () => {
|
|
430
|
+
count++;
|
|
431
|
+
if (count > 10 || pc.connectionState === "closed") {
|
|
432
|
+
if (this.statsInterval) {
|
|
433
|
+
clearInterval(this.statsInterval);
|
|
434
|
+
this.statsInterval = null;
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
const stats = await pc.getStats();
|
|
440
|
+
for (const [, report] of stats) {
|
|
441
|
+
const inbound = report;
|
|
442
|
+
if (inbound.type === "inbound-rtp" && inbound.kind === "audio") console.log("[TelnyxCallBridge] inbound-rtp:", `bytesRx=${inbound.bytesReceived}`, `pktsRx=${inbound.packetsReceived}`, `pktsLost=${inbound.packetsLost}`, `pktsDiscard=${inbound.packetsDiscarded ?? "n/a"}`, `samplesRx=${inbound.totalSamplesReceived}`, `jbEmit=${inbound.jitterBufferEmittedCount}`);
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
if (this.statsInterval) {
|
|
446
|
+
clearInterval(this.statsInterval);
|
|
447
|
+
this.statsInterval = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}, 2e3);
|
|
451
|
+
}
|
|
452
|
+
stopAudioCapture() {
|
|
453
|
+
if (this.statsInterval) {
|
|
454
|
+
clearInterval(this.statsInterval);
|
|
455
|
+
this.statsInterval = null;
|
|
456
|
+
}
|
|
457
|
+
if (this.captureWorklet) {
|
|
458
|
+
this.captureWorklet.disconnect();
|
|
459
|
+
this.captureWorklet = null;
|
|
460
|
+
}
|
|
461
|
+
if (this.captureSource) {
|
|
462
|
+
this.captureSource.disconnect();
|
|
463
|
+
this.captureSource = null;
|
|
464
|
+
}
|
|
465
|
+
if (this.captureContext) {
|
|
466
|
+
this.captureContext.close();
|
|
467
|
+
this.captureContext = null;
|
|
468
|
+
}
|
|
469
|
+
if (this.captureBlobUrl) {
|
|
470
|
+
URL.revokeObjectURL(this.captureBlobUrl);
|
|
471
|
+
this.captureBlobUrl = null;
|
|
472
|
+
}
|
|
473
|
+
if (this.captureAudioEl) {
|
|
474
|
+
this.captureAudioEl.pause();
|
|
475
|
+
this.captureAudioEl.srcObject = null;
|
|
476
|
+
this.captureAudioEl.remove();
|
|
477
|
+
this.captureAudioEl = null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async startAudioPlayback(call, setupAttempt) {
|
|
481
|
+
const peerConnection = getPeerConnection(call);
|
|
482
|
+
if (!peerConnection) return;
|
|
483
|
+
const playbackContext = new AudioContext({ sampleRate: 48e3 });
|
|
484
|
+
if (playbackContext.state === "suspended") await playbackContext.resume();
|
|
485
|
+
if (!this.isCurrentMediaSetup(setupAttempt)) {
|
|
486
|
+
this.cleanupPlaybackResources({ context: playbackContext });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const blob = new Blob([PCM_PLAYBACK_PROCESSOR_SOURCE], { type: "application/javascript" });
|
|
490
|
+
const playbackBlobUrl = URL.createObjectURL(blob);
|
|
491
|
+
await playbackContext.audioWorklet.addModule(playbackBlobUrl);
|
|
492
|
+
if (!this.isCurrentMediaSetup(setupAttempt)) {
|
|
493
|
+
this.cleanupPlaybackResources({
|
|
494
|
+
context: playbackContext,
|
|
495
|
+
blobUrl: playbackBlobUrl
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const playbackWorklet = new AudioWorkletNode(playbackContext, "pcm-playback-processor");
|
|
500
|
+
const destination = playbackContext.createMediaStreamDestination();
|
|
501
|
+
this.playbackContext = playbackContext;
|
|
502
|
+
this.playbackBlobUrl = playbackBlobUrl;
|
|
503
|
+
this.playbackWorklet = playbackWorklet;
|
|
504
|
+
playbackWorklet.connect(destination);
|
|
505
|
+
const audioTrack = destination.stream.getAudioTracks()[0];
|
|
506
|
+
if (audioTrack) {
|
|
507
|
+
const sender = peerConnection.getSenders().find((s) => s.track?.kind === "audio");
|
|
508
|
+
if (sender) await sender.replaceTrack(audioTrack);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
stopAudioPlayback() {
|
|
512
|
+
if (this.playbackWorklet) {
|
|
513
|
+
this.playbackWorklet.disconnect();
|
|
514
|
+
this.playbackWorklet = null;
|
|
515
|
+
}
|
|
516
|
+
if (this.playbackContext) {
|
|
517
|
+
this.playbackContext.close();
|
|
518
|
+
this.playbackContext = null;
|
|
519
|
+
}
|
|
520
|
+
if (this.playbackBlobUrl) {
|
|
521
|
+
URL.revokeObjectURL(this.playbackBlobUrl);
|
|
522
|
+
this.playbackBlobUrl = null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/phone-client.ts
|
|
528
|
+
var TelnyxPhoneClient = class {
|
|
529
|
+
constructor(config) {
|
|
530
|
+
this._status = "idle";
|
|
531
|
+
this._transcript = [];
|
|
532
|
+
this._metrics = null;
|
|
533
|
+
this._audioLevel = 0;
|
|
534
|
+
this._isMuted = false;
|
|
535
|
+
this._connected = false;
|
|
536
|
+
this._error = null;
|
|
537
|
+
this._interimTranscript = null;
|
|
538
|
+
this._lastCustomMessage = null;
|
|
539
|
+
this._audioFormat = null;
|
|
540
|
+
this._serverProtocolVersion = null;
|
|
541
|
+
this.inCall = false;
|
|
542
|
+
this.isPlaying = false;
|
|
543
|
+
this.isSpeaking = false;
|
|
544
|
+
this.silenceTimer = null;
|
|
545
|
+
this.interruptChunkCount = 0;
|
|
546
|
+
this.warnedFormat = false;
|
|
547
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
548
|
+
this.decodeContext = null;
|
|
549
|
+
this.transport = config.transport;
|
|
550
|
+
this.bridge = config.bridge;
|
|
551
|
+
this.preferredFormat = config.preferredFormat ?? "pcm16";
|
|
552
|
+
this.silenceThreshold = config.silenceThreshold ?? .04;
|
|
553
|
+
this.silenceDurationMs = config.silenceDurationMs ?? 500;
|
|
554
|
+
this.interruptThreshold = config.interruptThreshold ?? .05;
|
|
555
|
+
this.interruptChunks = config.interruptChunks ?? 2;
|
|
556
|
+
this.maxTranscriptMessages = config.maxTranscriptMessages ?? 200;
|
|
557
|
+
}
|
|
558
|
+
get status() {
|
|
559
|
+
return this._status;
|
|
560
|
+
}
|
|
561
|
+
get transcript() {
|
|
562
|
+
return this._transcript;
|
|
563
|
+
}
|
|
564
|
+
get metrics() {
|
|
565
|
+
return this._metrics;
|
|
566
|
+
}
|
|
567
|
+
get audioLevel() {
|
|
568
|
+
return this._audioLevel;
|
|
569
|
+
}
|
|
570
|
+
get isMuted() {
|
|
571
|
+
return this._isMuted;
|
|
572
|
+
}
|
|
573
|
+
get connected() {
|
|
574
|
+
return this._connected;
|
|
575
|
+
}
|
|
576
|
+
get error() {
|
|
577
|
+
return this._error;
|
|
578
|
+
}
|
|
579
|
+
get interimTranscript() {
|
|
580
|
+
return this._interimTranscript;
|
|
581
|
+
}
|
|
582
|
+
get lastCustomMessage() {
|
|
583
|
+
return this._lastCustomMessage;
|
|
584
|
+
}
|
|
585
|
+
get audioFormat() {
|
|
586
|
+
return this._audioFormat;
|
|
587
|
+
}
|
|
588
|
+
get serverProtocolVersion() {
|
|
589
|
+
return this._serverProtocolVersion;
|
|
590
|
+
}
|
|
591
|
+
addEventListener(event, listener) {
|
|
592
|
+
let set = this.listeners.get(event);
|
|
593
|
+
if (!set) {
|
|
594
|
+
set = /* @__PURE__ */ new Set();
|
|
595
|
+
this.listeners.set(event, set);
|
|
596
|
+
}
|
|
597
|
+
set.add(listener);
|
|
598
|
+
}
|
|
599
|
+
removeEventListener(event, listener) {
|
|
600
|
+
this.listeners.get(event)?.delete(listener);
|
|
601
|
+
}
|
|
602
|
+
emit(event, data) {
|
|
603
|
+
const set = this.listeners.get(event);
|
|
604
|
+
if (set) for (const fn of set) fn(data);
|
|
605
|
+
}
|
|
606
|
+
/** Open the transport connection and send the protocol handshake. */
|
|
607
|
+
connect() {
|
|
608
|
+
this.transport.onopen = () => {
|
|
609
|
+
this._connected = true;
|
|
610
|
+
this._error = null;
|
|
611
|
+
this.transport.sendJSON({
|
|
612
|
+
type: "hello",
|
|
613
|
+
protocol_version: 1
|
|
614
|
+
});
|
|
615
|
+
this.emit("connectionchange", true);
|
|
616
|
+
this.emit("error", null);
|
|
617
|
+
if (this.inCall) this.transport.sendJSON(this.createStartCallMessage());
|
|
618
|
+
};
|
|
619
|
+
this.transport.onclose = () => {
|
|
620
|
+
this._connected = false;
|
|
621
|
+
this.emit("connectionchange", false);
|
|
622
|
+
};
|
|
623
|
+
this.transport.onerror = () => {
|
|
624
|
+
this._error = "Connection lost. Reconnecting...";
|
|
625
|
+
this.emit("error", this._error);
|
|
626
|
+
};
|
|
627
|
+
this.transport.onmessage = (data) => {
|
|
628
|
+
if (typeof data === "string") this.handleJSON(data);
|
|
629
|
+
else if (data instanceof ArrayBuffer) this.handleAudio(data);
|
|
630
|
+
else if (data instanceof Blob) data.arrayBuffer().then((buf) => this.handleAudio(buf));
|
|
631
|
+
};
|
|
632
|
+
this.transport.connect();
|
|
633
|
+
}
|
|
634
|
+
/** End any active call, then close the transport. */
|
|
635
|
+
disconnect() {
|
|
636
|
+
this.endCall();
|
|
637
|
+
this.transport.disconnect();
|
|
638
|
+
this._connected = false;
|
|
639
|
+
this.emit("connectionchange", false);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Start a voice call. Wires up the bridge audio callbacks,
|
|
643
|
+
* starts the bridge, and sends `start_call` to the server.
|
|
644
|
+
*
|
|
645
|
+
* The bridge's `start()` is called here — do not call it separately.
|
|
646
|
+
*/
|
|
647
|
+
async startCall() {
|
|
648
|
+
if (!this.transport.connected) {
|
|
649
|
+
this._error = "Cannot start call: not connected. Call connect() first.";
|
|
650
|
+
this.emit("error", this._error);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
this.inCall = true;
|
|
654
|
+
this._error = null;
|
|
655
|
+
this._metrics = null;
|
|
656
|
+
this.emit("error", null);
|
|
657
|
+
this.emit("metricschange", null);
|
|
658
|
+
this.transport.sendJSON(this.createStartCallMessage());
|
|
659
|
+
this.bridge.onAudioLevel = (rms) => this.processAudioLevel(rms);
|
|
660
|
+
this.bridge.onAudioData = (pcm) => {
|
|
661
|
+
if (this.transport.connected && !this._isMuted) this.transport.sendBinary(pcm);
|
|
662
|
+
};
|
|
663
|
+
await this.bridge.start();
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* End the voice call. Detaches audio callbacks from the bridge
|
|
667
|
+
* and sends `end_call` to the server.
|
|
668
|
+
*
|
|
669
|
+
* Does NOT stop the bridge or hang up the phone — call
|
|
670
|
+
* `bridge.stop()` or `cleanup()` separately for that.
|
|
671
|
+
*/
|
|
672
|
+
endCall() {
|
|
673
|
+
this.inCall = false;
|
|
674
|
+
if (this.transport.connected) this.transport.sendJSON({ type: "end_call" });
|
|
675
|
+
this.bridge.onAudioLevel = null;
|
|
676
|
+
if (this.bridge.onAudioData !== void 0) this.bridge.onAudioData = null;
|
|
677
|
+
this.isPlaying = false;
|
|
678
|
+
this.resetDetection();
|
|
679
|
+
this._status = "idle";
|
|
680
|
+
this.emit("statuschange", "idle");
|
|
681
|
+
}
|
|
682
|
+
/** Toggle mute. When muted, audio is not sent to the server. */
|
|
683
|
+
toggleMute() {
|
|
684
|
+
this._isMuted = !this._isMuted;
|
|
685
|
+
if (this._isMuted) {
|
|
686
|
+
this._audioLevel = 0;
|
|
687
|
+
this.emit("audiolevelchange", 0);
|
|
688
|
+
}
|
|
689
|
+
if (this._isMuted && this.isSpeaking) {
|
|
690
|
+
this.isSpeaking = false;
|
|
691
|
+
if (this.silenceTimer) {
|
|
692
|
+
clearTimeout(this.silenceTimer);
|
|
693
|
+
this.silenceTimer = null;
|
|
694
|
+
}
|
|
695
|
+
if (this.transport.connected) this.transport.sendJSON({ type: "end_of_speech" });
|
|
696
|
+
}
|
|
697
|
+
this.emit("mutechange", this._isMuted);
|
|
698
|
+
}
|
|
699
|
+
/** Send a text message to the agent (bypasses STT, goes to onTurn). */
|
|
700
|
+
sendText(text) {
|
|
701
|
+
if (this.transport.connected) this.transport.sendJSON({
|
|
702
|
+
type: "text_message",
|
|
703
|
+
text
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
/** Send arbitrary JSON to the agent (app-level messages). */
|
|
707
|
+
sendJSON(data) {
|
|
708
|
+
if (this.transport.connected) this.transport.sendJSON(data);
|
|
709
|
+
}
|
|
710
|
+
createStartCallMessage() {
|
|
711
|
+
const startMsg = { type: "start_call" };
|
|
712
|
+
if (this.preferredFormat) startMsg.preferred_format = this.preferredFormat;
|
|
713
|
+
return startMsg;
|
|
714
|
+
}
|
|
715
|
+
handleJSON(raw) {
|
|
716
|
+
let msg;
|
|
717
|
+
try {
|
|
718
|
+
msg = JSON.parse(raw);
|
|
719
|
+
} catch {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
switch (msg.type) {
|
|
723
|
+
case "welcome":
|
|
724
|
+
this._serverProtocolVersion = msg.protocol_version;
|
|
725
|
+
if (msg.protocol_version !== 1) console.warn(`[TelnyxPhoneClient] Protocol version mismatch: client=1, server=${msg.protocol_version}`);
|
|
726
|
+
break;
|
|
727
|
+
case "audio_config":
|
|
728
|
+
this._audioFormat = msg.format;
|
|
729
|
+
this.warnedFormat = false;
|
|
730
|
+
break;
|
|
731
|
+
case "status":
|
|
732
|
+
this._status = msg.status;
|
|
733
|
+
this.isPlaying = msg.status === "speaking";
|
|
734
|
+
if (msg.status === "listening" || msg.status === "idle") {
|
|
735
|
+
this._error = null;
|
|
736
|
+
this.emit("error", null);
|
|
737
|
+
}
|
|
738
|
+
this.emit("statuschange", this._status);
|
|
739
|
+
break;
|
|
740
|
+
case "transcript_interim":
|
|
741
|
+
this._interimTranscript = msg.text;
|
|
742
|
+
this.emit("interimtranscript", this._interimTranscript);
|
|
743
|
+
break;
|
|
744
|
+
case "transcript":
|
|
745
|
+
this._interimTranscript = null;
|
|
746
|
+
this.emit("interimtranscript", null);
|
|
747
|
+
if (msg.role === "user" && this.isPlaying) {
|
|
748
|
+
this.isPlaying = false;
|
|
749
|
+
this.bridge.clearPlaybackBuffer();
|
|
750
|
+
}
|
|
751
|
+
this._transcript = [...this._transcript, {
|
|
752
|
+
role: msg.role,
|
|
753
|
+
text: msg.text,
|
|
754
|
+
timestamp: Date.now()
|
|
755
|
+
}];
|
|
756
|
+
this.trimTranscript();
|
|
757
|
+
this.emit("transcriptchange", this._transcript);
|
|
758
|
+
break;
|
|
759
|
+
case "transcript_start":
|
|
760
|
+
this._transcript = [...this._transcript, {
|
|
761
|
+
role: "assistant",
|
|
762
|
+
text: "",
|
|
763
|
+
timestamp: Date.now()
|
|
764
|
+
}];
|
|
765
|
+
this.trimTranscript();
|
|
766
|
+
this.emit("transcriptchange", this._transcript);
|
|
767
|
+
break;
|
|
768
|
+
case "transcript_delta": {
|
|
769
|
+
if (this._transcript.length === 0) break;
|
|
770
|
+
const updated = [...this._transcript];
|
|
771
|
+
const last = updated[updated.length - 1];
|
|
772
|
+
if (last.role === "assistant") {
|
|
773
|
+
updated[updated.length - 1] = {
|
|
774
|
+
...last,
|
|
775
|
+
text: last.text + msg.text
|
|
776
|
+
};
|
|
777
|
+
this._transcript = updated;
|
|
778
|
+
this.emit("transcriptchange", this._transcript);
|
|
779
|
+
}
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
case "transcript_end": {
|
|
783
|
+
if (this._transcript.length === 0) break;
|
|
784
|
+
const updated = [...this._transcript];
|
|
785
|
+
const last = updated[updated.length - 1];
|
|
786
|
+
if (last.role === "assistant") {
|
|
787
|
+
updated[updated.length - 1] = {
|
|
788
|
+
...last,
|
|
789
|
+
text: msg.text
|
|
790
|
+
};
|
|
791
|
+
this._transcript = updated;
|
|
792
|
+
this.emit("transcriptchange", this._transcript);
|
|
793
|
+
}
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
case "metrics":
|
|
797
|
+
this._metrics = {
|
|
798
|
+
llm_ms: msg.llm_ms,
|
|
799
|
+
tts_ms: msg.tts_ms,
|
|
800
|
+
first_audio_ms: msg.first_audio_ms,
|
|
801
|
+
total_ms: msg.total_ms
|
|
802
|
+
};
|
|
803
|
+
this.emit("metricschange", this._metrics);
|
|
804
|
+
break;
|
|
805
|
+
case "error":
|
|
806
|
+
this._error = msg.message;
|
|
807
|
+
this.emit("error", this._error);
|
|
808
|
+
break;
|
|
809
|
+
default:
|
|
810
|
+
this._lastCustomMessage = msg;
|
|
811
|
+
this.emit("custommessage", msg);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
handleAudio(audio) {
|
|
816
|
+
if (this._audioFormat === "pcm16" || this._audioFormat === null) this.bridge.playAudio(audio);
|
|
817
|
+
else this.decodeAndPlay(audio);
|
|
818
|
+
}
|
|
819
|
+
async decodeAndPlay(audio) {
|
|
820
|
+
try {
|
|
821
|
+
if (!this.decodeContext) this.decodeContext = new AudioContext({ sampleRate: 16e3 });
|
|
822
|
+
const float32 = (await this.decodeContext.decodeAudioData(audio.slice(0))).getChannelData(0);
|
|
823
|
+
const int16 = new Int16Array(float32.length);
|
|
824
|
+
for (let i = 0; i < float32.length; i++) {
|
|
825
|
+
const s = Math.max(-1, Math.min(1, float32[i]));
|
|
826
|
+
int16[i] = s < 0 ? s * 32768 : s * 32767;
|
|
827
|
+
}
|
|
828
|
+
this.bridge.playAudio(int16.buffer);
|
|
829
|
+
} catch (err) {
|
|
830
|
+
if (!this.warnedFormat) {
|
|
831
|
+
this.warnedFormat = true;
|
|
832
|
+
console.warn(`[TelnyxPhoneClient] Failed to decode "${this._audioFormat}" audio:`, err);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
processAudioLevel(rms) {
|
|
837
|
+
if (this._isMuted) return;
|
|
838
|
+
this._audioLevel = rms;
|
|
839
|
+
this.emit("audiolevelchange", rms);
|
|
840
|
+
if (this.isPlaying && rms > this.interruptThreshold) {
|
|
841
|
+
this.interruptChunkCount++;
|
|
842
|
+
if (this.interruptChunkCount >= this.interruptChunks) {
|
|
843
|
+
this.isPlaying = false;
|
|
844
|
+
this.interruptChunkCount = 0;
|
|
845
|
+
this.bridge.clearPlaybackBuffer();
|
|
846
|
+
if (this.transport.connected) this.transport.sendJSON({ type: "interrupt" });
|
|
847
|
+
}
|
|
848
|
+
} else this.interruptChunkCount = 0;
|
|
849
|
+
if (rms > this.silenceThreshold) {
|
|
850
|
+
if (!this.isSpeaking) {
|
|
851
|
+
this.isSpeaking = true;
|
|
852
|
+
if (this.transport.connected) this.transport.sendJSON({ type: "start_of_speech" });
|
|
853
|
+
}
|
|
854
|
+
if (this.silenceTimer) {
|
|
855
|
+
clearTimeout(this.silenceTimer);
|
|
856
|
+
this.silenceTimer = null;
|
|
857
|
+
}
|
|
858
|
+
} else if (this.isSpeaking) {
|
|
859
|
+
if (!this.silenceTimer) this.silenceTimer = setTimeout(() => {
|
|
860
|
+
this.isSpeaking = false;
|
|
861
|
+
this.silenceTimer = null;
|
|
862
|
+
if (this.transport.connected) this.transport.sendJSON({ type: "end_of_speech" });
|
|
863
|
+
}, this.silenceDurationMs);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
resetDetection() {
|
|
867
|
+
if (this.silenceTimer) {
|
|
868
|
+
clearTimeout(this.silenceTimer);
|
|
869
|
+
this.silenceTimer = null;
|
|
870
|
+
}
|
|
871
|
+
this.isSpeaking = false;
|
|
872
|
+
this.interruptChunkCount = 0;
|
|
873
|
+
this._audioLevel = 0;
|
|
874
|
+
this.emit("audiolevelchange", 0);
|
|
875
|
+
}
|
|
876
|
+
trimTranscript() {
|
|
877
|
+
if (this._transcript.length > this.maxTranscriptMessages) this._transcript = this._transcript.slice(-this.maxTranscriptMessages);
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region src/transport/phone-transport.ts
|
|
882
|
+
var TelnyxPhoneTransport = class {
|
|
883
|
+
constructor(config) {
|
|
884
|
+
this.audioFormat = null;
|
|
885
|
+
this.warnedFormat = false;
|
|
886
|
+
this.onopen = null;
|
|
887
|
+
this.onclose = null;
|
|
888
|
+
this.onerror = null;
|
|
889
|
+
this.onmessage = null;
|
|
890
|
+
this.inner = config.inner;
|
|
891
|
+
this.bridge = config.bridge;
|
|
892
|
+
this.userAudioCallback = config.onServerAudio;
|
|
893
|
+
}
|
|
894
|
+
get connected() {
|
|
895
|
+
return this.inner.connected;
|
|
896
|
+
}
|
|
897
|
+
sendJSON(data) {
|
|
898
|
+
this.inner.sendJSON(data);
|
|
899
|
+
}
|
|
900
|
+
sendBinary(data) {
|
|
901
|
+
this.inner.sendBinary(data);
|
|
902
|
+
}
|
|
903
|
+
connect() {
|
|
904
|
+
this.inner.onopen = () => this.onopen?.();
|
|
905
|
+
this.inner.onclose = () => this.onclose?.();
|
|
906
|
+
this.inner.onerror = (err) => this.onerror?.(err);
|
|
907
|
+
this.inner.onmessage = (data) => {
|
|
908
|
+
this.intercept(data);
|
|
909
|
+
this.onmessage?.(data);
|
|
910
|
+
};
|
|
911
|
+
this.inner.connect();
|
|
912
|
+
}
|
|
913
|
+
disconnect() {
|
|
914
|
+
this.inner.disconnect();
|
|
915
|
+
}
|
|
916
|
+
intercept(data) {
|
|
917
|
+
if (typeof data === "string") this.trackAudioConfig(data);
|
|
918
|
+
else if (data instanceof ArrayBuffer) this.routeAudio(data);
|
|
919
|
+
else if (data instanceof Blob) data.arrayBuffer().then((buf) => this.routeAudio(buf));
|
|
920
|
+
}
|
|
921
|
+
/** Parse audio_config messages to know what format the server is sending. */
|
|
922
|
+
trackAudioConfig(json) {
|
|
923
|
+
try {
|
|
924
|
+
const msg = JSON.parse(json);
|
|
925
|
+
if (msg.type === "audio_config" && msg.format) {
|
|
926
|
+
this.audioFormat = msg.format;
|
|
927
|
+
this.warnedFormat = false;
|
|
928
|
+
}
|
|
929
|
+
} catch {}
|
|
930
|
+
}
|
|
931
|
+
/** Fork audio to the bridge (pcm16 only) and optional user callback. */
|
|
932
|
+
routeAudio(audio) {
|
|
933
|
+
this.userAudioCallback?.(audio);
|
|
934
|
+
if (this.audioFormat === "pcm16" || this.audioFormat === null) this.bridge.playAudio(audio);
|
|
935
|
+
else if (this.audioFormat && !this.warnedFormat) {
|
|
936
|
+
this.warnedFormat = true;
|
|
937
|
+
console.warn(`[TelnyxPhoneTransport] Server audio format is "${this.audioFormat}". TelnyxCallBridge expects pcm16 (16kHz mono Int16 LE). Set audioFormat: "pcm16" in your server-side VoiceAgentOptions.`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region src/helpers/transport-config.ts
|
|
943
|
+
/**
|
|
944
|
+
* Factory helper that wires up JWT auth + TelnyxCallBridge
|
|
945
|
+
* into a ready-to-use VoiceClient configuration.
|
|
946
|
+
*
|
|
947
|
+
* Usage:
|
|
948
|
+
* ```typescript
|
|
949
|
+
* import { createTelnyxVoiceConfig } from "@cloudflare/voice-telnyx/browser";
|
|
950
|
+
* import { VoiceClient } from "@cloudflare/voice/client";
|
|
951
|
+
*
|
|
952
|
+
* const telnyx = await createTelnyxVoiceConfig({
|
|
953
|
+
* jwtEndpoint: "/api/telnyx-token",
|
|
954
|
+
* autoAnswer: true,
|
|
955
|
+
* });
|
|
956
|
+
*
|
|
957
|
+
* const voiceClient = new VoiceClient({
|
|
958
|
+
* agent: "my-agent",
|
|
959
|
+
* audioInput: telnyx.audioInput,
|
|
960
|
+
* });
|
|
961
|
+
*
|
|
962
|
+
* // Inject agent audio back into the phone call:
|
|
963
|
+
* voiceClient.on("audio", (pcm) => telnyx.bridge.playAudio(pcm));
|
|
964
|
+
*
|
|
965
|
+
* // On disconnect, clean up server-side credential:
|
|
966
|
+
* await telnyx.cleanup();
|
|
967
|
+
* ```
|
|
968
|
+
*/
|
|
969
|
+
/**
|
|
970
|
+
* Fetch a JWT from the server, create a TelnyxCallBridge, and return
|
|
971
|
+
* everything needed to configure a VoiceClient for phone calls.
|
|
972
|
+
*/
|
|
973
|
+
async function createTelnyxVoiceConfig(options) {
|
|
974
|
+
const response = await fetch(options.jwtEndpoint, {
|
|
975
|
+
method: "POST",
|
|
976
|
+
headers: { "Content-Type": "application/json" }
|
|
977
|
+
});
|
|
978
|
+
if (!response.ok) throw new Error(`Failed to fetch JWT: ${response.status}`);
|
|
979
|
+
const body = await response.json();
|
|
980
|
+
if (!body.token) throw new Error("JWT response missing token");
|
|
981
|
+
const credentialId = body.credentialId ?? "";
|
|
982
|
+
const sipUsername = body.sipUsername ?? "";
|
|
983
|
+
const bridge = new TelnyxCallBridge({
|
|
984
|
+
loginToken: body.token,
|
|
985
|
+
autoAnswer: options.autoAnswer,
|
|
986
|
+
debug: options.debug
|
|
987
|
+
});
|
|
988
|
+
const cleanup = async () => {
|
|
989
|
+
bridge.stop();
|
|
990
|
+
if (credentialId) {
|
|
991
|
+
const response = await fetch(options.jwtEndpoint, {
|
|
992
|
+
method: "DELETE",
|
|
993
|
+
headers: { "Content-Type": "application/json" },
|
|
994
|
+
body: JSON.stringify({ credentialId })
|
|
995
|
+
});
|
|
996
|
+
if (!response.ok) throw new Error(`Failed to revoke Telnyx credential: ${response.status}`);
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
return {
|
|
1000
|
+
bridge,
|
|
1001
|
+
audioInput: bridge,
|
|
1002
|
+
credentialId,
|
|
1003
|
+
sipUsername,
|
|
1004
|
+
cleanup
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
//#endregion
|
|
1008
|
+
export { TelnyxCallBridge, TelnyxPhoneClient, TelnyxPhoneTransport, createTelnyxVoiceConfig };
|
|
1009
|
+
|
|
1010
|
+
//# sourceMappingURL=browser.js.map
|