@kuralle-syrinx/server-websocket 4.2.0 → 4.3.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/package.json +6 -5
- package/src/carrier-commands.ts +332 -0
- package/src/edge-telnyx.ts +389 -0
- package/src/edge.ts +7 -2
- package/src/index.ts +25 -2
- package/src/telnyx-codec.ts +163 -0
- package/src/telnyx.ts +54 -59
- package/src/twilio.ts +23 -0
- package/src/wire-carrier-control.ts +179 -0
- package/src/admission-control.test.ts +0 -270
- package/src/background-audio.test.ts +0 -224
- package/src/browser-opus.test.ts +0 -41
- package/src/browser-pacing.test.ts +0 -440
- package/src/edge-twilio.test.ts +0 -293
- package/src/edge.test.ts +0 -591
- package/src/graceful-drain.test.ts +0 -306
- package/src/inbound-audio.test.ts +0 -58
- package/src/index.test.ts +0 -2074
- package/src/outbound-playout-pipeline.test.ts +0 -314
- package/src/paced-playout.test.ts +0 -247
- package/src/playout-progress.test.ts +0 -56
- package/src/session-store.test.ts +0 -274
- package/src/smartpbx.test.ts +0 -967
- package/src/telnyx.test.ts +0 -1457
- package/src/transport-host.test.ts +0 -51
- package/src/turn-metrics.test.ts +0 -327
- package/src/twilio-auth.test.ts +0 -36
- package/src/twilio.test.ts +0 -1275
- package/src/websocket-close.test.ts +0 -63
- package/src/websocket-lifecycle.test.ts +0 -49
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -7
package/src/smartpbx.test.ts
DELETED
|
@@ -1,967 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import WebSocket from "ws";
|
|
5
|
-
import { Decoder as OpusDecoder, Encoder as OpusEncoder } from "@evan/opus";
|
|
6
|
-
import { Route, VoiceAgentSession, type ConversationMetricPacket, type RecordAssistantAudioPacket, type UserAudioReceivedPacket } from "@kuralle-syrinx/core";
|
|
7
|
-
import { createSmartPbxMediaStreamServer } from "./index.js";
|
|
8
|
-
import { encodePcm16ToMuLaw, pcm16BytesToSamples, pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
|
|
9
|
-
import {
|
|
10
|
-
openSmartPbxSocket,
|
|
11
|
-
readJsonMatching,
|
|
12
|
-
registerServer,
|
|
13
|
-
setupTransportTestCleanup,
|
|
14
|
-
waitForCondition,
|
|
15
|
-
} from "./test-helpers.js";
|
|
16
|
-
|
|
17
|
-
setupTransportTestCleanup();
|
|
18
|
-
|
|
19
|
-
function smartPbxUrl(port: number): string {
|
|
20
|
-
return `ws://127.0.0.1:${String(port)}/media-stream`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function readNthJsonMatching(socket: WebSocket, predicate: (message: any) => boolean, count: number): Promise<any> {
|
|
24
|
-
return await new Promise((resolve) => {
|
|
25
|
-
let seen = 0;
|
|
26
|
-
const onMessage = (data: WebSocket.RawData, isBinary: boolean) => {
|
|
27
|
-
if (isBinary) return;
|
|
28
|
-
const message = JSON.parse(data.toString());
|
|
29
|
-
if (!predicate(message)) return;
|
|
30
|
-
seen += 1;
|
|
31
|
-
if (seen < count) return;
|
|
32
|
-
socket.off("message", onMessage);
|
|
33
|
-
resolve(message);
|
|
34
|
-
};
|
|
35
|
-
socket.on("message", onMessage);
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function smartPbxStart(encoding = "g711_ulaw", sampleRate = 8000): Record<string, unknown> {
|
|
40
|
-
return {
|
|
41
|
-
event: "start",
|
|
42
|
-
start: {
|
|
43
|
-
callId: "call-test",
|
|
44
|
-
otherLegCallId: "call-peer",
|
|
45
|
-
callerIdNumber: "+94770000000",
|
|
46
|
-
calleeIdNumber: "+94771111111",
|
|
47
|
-
accountId: "account-test",
|
|
48
|
-
mediaFormat: {
|
|
49
|
-
encoding,
|
|
50
|
-
sampleRate,
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
describe("createSmartPbxMediaStreamServer", () => {
|
|
57
|
-
it("decodes SmartPBX g711_ulaw media into engine PCM16", async () => {
|
|
58
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
59
|
-
const received: UserAudioReceivedPacket[] = [];
|
|
60
|
-
session.bus.on("user.audio_received", (pkt) => {
|
|
61
|
-
received.push(pkt as UserAudioReceivedPacket);
|
|
62
|
-
});
|
|
63
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
64
|
-
port: 0,
|
|
65
|
-
inputSampleRateHz: 16000,
|
|
66
|
-
createSession: () => session,
|
|
67
|
-
contextId: () => "smartpbx-call-test",
|
|
68
|
-
}));
|
|
69
|
-
const address = server.address();
|
|
70
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
71
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
72
|
-
|
|
73
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
74
|
-
client.send(JSON.stringify({
|
|
75
|
-
event: "media",
|
|
76
|
-
media: {
|
|
77
|
-
payload: Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64"),
|
|
78
|
-
},
|
|
79
|
-
}));
|
|
80
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
81
|
-
|
|
82
|
-
expect(received).toEqual([expect.objectContaining({ contextId: "smartpbx-call-test" })]);
|
|
83
|
-
expect(received[0]!.audio.byteLength).toBe(16);
|
|
84
|
-
|
|
85
|
-
client.close();
|
|
86
|
-
await server.close();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("buffers SmartPBX start and media sent before session startup completes", async () => {
|
|
90
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
91
|
-
const received: UserAudioReceivedPacket[] = [];
|
|
92
|
-
session.bus.on("user.audio_received", (pkt) => {
|
|
93
|
-
received.push(pkt as UserAudioReceivedPacket);
|
|
94
|
-
});
|
|
95
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
96
|
-
port: 0,
|
|
97
|
-
inputSampleRateHz: 16000,
|
|
98
|
-
createSession: async () => {
|
|
99
|
-
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
100
|
-
return session;
|
|
101
|
-
},
|
|
102
|
-
contextId: () => "smartpbx-delayed-session",
|
|
103
|
-
}));
|
|
104
|
-
const address = server.address();
|
|
105
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
106
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
107
|
-
|
|
108
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
109
|
-
client.send(JSON.stringify({
|
|
110
|
-
event: "media",
|
|
111
|
-
media: {
|
|
112
|
-
payload: Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64"),
|
|
113
|
-
},
|
|
114
|
-
}));
|
|
115
|
-
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
116
|
-
|
|
117
|
-
expect(received).toEqual([expect.objectContaining({ contextId: "smartpbx-delayed-session" })]);
|
|
118
|
-
|
|
119
|
-
client.close();
|
|
120
|
-
await server.close();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("closes SmartPBX websocket connections when session startup exceeds startupTimeoutMs", async () => {
|
|
124
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
125
|
-
port: 0,
|
|
126
|
-
startupTimeoutMs: 10,
|
|
127
|
-
createSession: () => new Promise<VoiceAgentSession>(() => undefined),
|
|
128
|
-
}));
|
|
129
|
-
const address = server.address();
|
|
130
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
131
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
132
|
-
const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
|
|
133
|
-
const closed = new Promise<{ code: number; reason: string }>((resolve) => {
|
|
134
|
-
client.once("close", (code, reason) => {
|
|
135
|
-
resolve({ code, reason: reason.toString() });
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
await expect(errorMessage).resolves.toMatchObject({
|
|
140
|
-
event: "syrinx_error",
|
|
141
|
-
error: {
|
|
142
|
-
component: "transport",
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
await expect(closed).resolves.toEqual({
|
|
146
|
-
code: 1011,
|
|
147
|
-
reason: "session initialization failed",
|
|
148
|
-
});
|
|
149
|
-
await server.close();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("decodes SmartPBX little-endian pcm16 media and normalizes 24 kHz ingress", async () => {
|
|
153
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
154
|
-
const received: UserAudioReceivedPacket[] = [];
|
|
155
|
-
session.bus.on("user.audio_received", (pkt) => {
|
|
156
|
-
received.push(pkt as UserAudioReceivedPacket);
|
|
157
|
-
});
|
|
158
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
159
|
-
port: 0,
|
|
160
|
-
inputSampleRateHz: 16000,
|
|
161
|
-
createSession: () => session,
|
|
162
|
-
}));
|
|
163
|
-
const address = server.address();
|
|
164
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
165
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
166
|
-
|
|
167
|
-
client.send(JSON.stringify(smartPbxStart("pcm16", 24000)));
|
|
168
|
-
client.send(JSON.stringify({
|
|
169
|
-
event: "media",
|
|
170
|
-
media: { payload: Buffer.from(pcm16SamplesToBytes(new Int16Array([100, 200, 300]))).toString("base64") },
|
|
171
|
-
}));
|
|
172
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
173
|
-
|
|
174
|
-
expect(received[0]!.audio.byteLength).toBe(4);
|
|
175
|
-
// FIR anti-alias resampling (24k→16k) on a tiny 3-sample chunk is boundary-dominated;
|
|
176
|
-
// the center-tap weighted sum of [100, 200, 300] gives 93 for the first output sample.
|
|
177
|
-
expect(Buffer.from(received[0]!.audio).readInt16LE(0)).toBe(93);
|
|
178
|
-
|
|
179
|
-
client.close();
|
|
180
|
-
await server.close();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("emits assistant media with call identity in the selected SmartPBX wire codec", async () => {
|
|
184
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
185
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
186
|
-
port: 0,
|
|
187
|
-
outputSampleRateHz: 16000,
|
|
188
|
-
createSession: () => session,
|
|
189
|
-
}));
|
|
190
|
-
const address = server.address();
|
|
191
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
192
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
193
|
-
|
|
194
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
195
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
196
|
-
const outbound = readJsonMatching(client, (message) => message.event === "media");
|
|
197
|
-
session.bus.push(Route.Main, {
|
|
198
|
-
kind: "tts.audio",
|
|
199
|
-
contextId: "smartpbx-call-test",
|
|
200
|
-
timestampMs: Date.now(),
|
|
201
|
-
audio: pcm16SamplesToBytes(new Int16Array(320)),
|
|
202
|
-
sampleRateHz: 16000,
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
await expect(outbound).resolves.toMatchObject({
|
|
206
|
-
event: "media",
|
|
207
|
-
callId: "call-test",
|
|
208
|
-
accountId: "account-test",
|
|
209
|
-
});
|
|
210
|
-
const message = await outbound;
|
|
211
|
-
expect(Buffer.from(message.media.payload, "base64").byteLength).toBe(160);
|
|
212
|
-
|
|
213
|
-
client.close();
|
|
214
|
-
await server.close();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("emits pcm16 assistant media at SmartPBX 24 kHz when negotiated", async () => {
|
|
218
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
219
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
220
|
-
port: 0,
|
|
221
|
-
outputSampleRateHz: 16000,
|
|
222
|
-
createSession: () => session,
|
|
223
|
-
}));
|
|
224
|
-
const address = server.address();
|
|
225
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
226
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
227
|
-
|
|
228
|
-
client.send(JSON.stringify(smartPbxStart("pcm16", 24000)));
|
|
229
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
230
|
-
const outbound = readJsonMatching(client, (message) => message.event === "media");
|
|
231
|
-
session.bus.push(Route.Main, {
|
|
232
|
-
kind: "tts.audio",
|
|
233
|
-
contextId: "smartpbx-call-test",
|
|
234
|
-
timestampMs: Date.now(),
|
|
235
|
-
audio: pcm16SamplesToBytes(new Int16Array([1000, -1000, 500, -500])),
|
|
236
|
-
sampleRateHz: 16000,
|
|
237
|
-
});
|
|
238
|
-
const media = await outbound;
|
|
239
|
-
const bytes = Buffer.from(media.media.payload, "base64");
|
|
240
|
-
|
|
241
|
-
expect(media).toMatchObject({ callId: "call-test", accountId: "account-test" });
|
|
242
|
-
expect(bytes.readInt16LE(0)).toBe(1000);
|
|
243
|
-
|
|
244
|
-
client.close();
|
|
245
|
-
await server.close();
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("decodes SmartPBX opus media at 48 kHz into engine PCM16", async () => {
|
|
249
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
250
|
-
const received: UserAudioReceivedPacket[] = [];
|
|
251
|
-
session.bus.on("user.audio_received", (pkt) => {
|
|
252
|
-
received.push(pkt as UserAudioReceivedPacket);
|
|
253
|
-
});
|
|
254
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
255
|
-
port: 0,
|
|
256
|
-
inputSampleRateHz: 16000,
|
|
257
|
-
createSession: () => session,
|
|
258
|
-
}));
|
|
259
|
-
const address = server.address();
|
|
260
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
261
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
262
|
-
const encoder = new OpusEncoder({ channels: 1, sample_rate: 48000, application: "voip" });
|
|
263
|
-
const samples48k = new Int16Array(960);
|
|
264
|
-
samples48k[0] = 1000;
|
|
265
|
-
samples48k[3] = -1000;
|
|
266
|
-
const opus = encoder.encode(pcm16SamplesToBytes(samples48k));
|
|
267
|
-
|
|
268
|
-
client.send(JSON.stringify(smartPbxStart("opus", 48000)));
|
|
269
|
-
client.send(JSON.stringify({
|
|
270
|
-
event: "media",
|
|
271
|
-
media: { payload: Buffer.from(opus).toString("base64") },
|
|
272
|
-
}));
|
|
273
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
274
|
-
|
|
275
|
-
expect(received).toEqual([expect.objectContaining({
|
|
276
|
-
kind: "user.audio_received",
|
|
277
|
-
contextId: "smartpbx-call-test",
|
|
278
|
-
})]);
|
|
279
|
-
expect(received[0]!.audio.byteLength).toBe(640);
|
|
280
|
-
|
|
281
|
-
client.close();
|
|
282
|
-
await server.close();
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("emits opus assistant media at SmartPBX 48 kHz when negotiated", async () => {
|
|
286
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
287
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
288
|
-
port: 0,
|
|
289
|
-
outputSampleRateHz: 16000,
|
|
290
|
-
createSession: () => session,
|
|
291
|
-
}));
|
|
292
|
-
const address = server.address();
|
|
293
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
294
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
295
|
-
|
|
296
|
-
client.send(JSON.stringify(smartPbxStart("opus", 48000)));
|
|
297
|
-
await waitForCondition(() => session.state === "ready");
|
|
298
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
299
|
-
const outbound = readJsonMatching(client, (message) => message.event === "media");
|
|
300
|
-
session.bus.push(Route.Main, {
|
|
301
|
-
kind: "tts.audio",
|
|
302
|
-
contextId: "smartpbx-call-test",
|
|
303
|
-
timestampMs: Date.now(),
|
|
304
|
-
audio: pcm16SamplesToBytes(new Int16Array(320)),
|
|
305
|
-
sampleRateHz: 16000,
|
|
306
|
-
});
|
|
307
|
-
session.bus.push(Route.Main, {
|
|
308
|
-
kind: "tts.end",
|
|
309
|
-
contextId: "smartpbx-call-test",
|
|
310
|
-
timestampMs: Date.now(),
|
|
311
|
-
});
|
|
312
|
-
const media = await outbound;
|
|
313
|
-
const opus = Buffer.from(media.media.payload, "base64");
|
|
314
|
-
const decoded = pcm16BytesToSamples(new OpusDecoder({ channels: 1, sample_rate: 48000 }).decode(opus));
|
|
315
|
-
|
|
316
|
-
expect(media).toMatchObject({ callId: "call-test", accountId: "account-test" });
|
|
317
|
-
expect(opus.byteLength).toBeGreaterThan(0);
|
|
318
|
-
expect(decoded.length).toBe(960);
|
|
319
|
-
|
|
320
|
-
client.close();
|
|
321
|
-
await server.close();
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("flushes a partial SmartPBX opus assistant frame at TTS end", async () => {
|
|
325
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
326
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
327
|
-
port: 0,
|
|
328
|
-
outputSampleRateHz: 16000,
|
|
329
|
-
createSession: () => session,
|
|
330
|
-
}));
|
|
331
|
-
const address = server.address();
|
|
332
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
333
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
334
|
-
|
|
335
|
-
client.send(JSON.stringify(smartPbxStart("opus", 48000)));
|
|
336
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
337
|
-
const outbound = readJsonMatching(client, (message) => message.event === "media");
|
|
338
|
-
session.bus.push(Route.Main, {
|
|
339
|
-
kind: "tts.audio",
|
|
340
|
-
contextId: "smartpbx-call-test",
|
|
341
|
-
timestampMs: Date.now(),
|
|
342
|
-
audio: pcm16SamplesToBytes(new Int16Array(80)),
|
|
343
|
-
sampleRateHz: 16000,
|
|
344
|
-
});
|
|
345
|
-
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
346
|
-
session.bus.push(Route.Main, {
|
|
347
|
-
kind: "tts.end",
|
|
348
|
-
contextId: "smartpbx-call-test",
|
|
349
|
-
timestampMs: Date.now(),
|
|
350
|
-
});
|
|
351
|
-
const media = await outbound;
|
|
352
|
-
const decoded = pcm16BytesToSamples(new OpusDecoder({ channels: 1, sample_rate: 48000 }).decode(
|
|
353
|
-
Buffer.from(media.media.payload, "base64"),
|
|
354
|
-
));
|
|
355
|
-
|
|
356
|
-
expect(decoded.length).toBe(960);
|
|
357
|
-
|
|
358
|
-
client.close();
|
|
359
|
-
await server.close();
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it("rejects unsupported SmartPBX opus sample rates before forwarding media", async () => {
|
|
363
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
364
|
-
const received: UserAudioReceivedPacket[] = [];
|
|
365
|
-
session.bus.on("user.audio_received", (pkt) => {
|
|
366
|
-
received.push(pkt as UserAudioReceivedPacket);
|
|
367
|
-
});
|
|
368
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({ port: 0, createSession: () => session }));
|
|
369
|
-
const address = server.address();
|
|
370
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
371
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
372
|
-
const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
|
|
373
|
-
|
|
374
|
-
client.send(JSON.stringify(smartPbxStart("opus", 16000)));
|
|
375
|
-
|
|
376
|
-
await expect(errorMessage).resolves.toMatchObject({
|
|
377
|
-
error: { message: "Unsupported SmartPBX opus sample rate: 16000" },
|
|
378
|
-
});
|
|
379
|
-
expect(received).toEqual([]);
|
|
380
|
-
|
|
381
|
-
client.close();
|
|
382
|
-
await server.close();
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("rejects malformed SmartPBX base64 media payloads", async () => {
|
|
386
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
387
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({ port: 0, createSession: () => session }));
|
|
388
|
-
const address = server.address();
|
|
389
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
390
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
391
|
-
const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
|
|
392
|
-
|
|
393
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
394
|
-
client.send(JSON.stringify({ event: "media", media: { payload: "not base64" } }));
|
|
395
|
-
|
|
396
|
-
await expect(errorMessage).resolves.toMatchObject({
|
|
397
|
-
error: { message: "media.payload must be valid base64" },
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
client.close();
|
|
401
|
-
await server.close();
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it("rejects malformed SmartPBX JSON media envelopes before forwarding audio", async () => {
|
|
405
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
406
|
-
const received: UserAudioReceivedPacket[] = [];
|
|
407
|
-
session.bus.on("user.audio_received", (pkt) => {
|
|
408
|
-
received.push(pkt as UserAudioReceivedPacket);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({ port: 0, createSession: () => session }));
|
|
412
|
-
const address = server.address();
|
|
413
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
414
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
415
|
-
const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
|
|
416
|
-
|
|
417
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
418
|
-
client.send(JSON.stringify({ event: "media", media: "not an object" }));
|
|
419
|
-
|
|
420
|
-
await expect(errorMessage).resolves.toMatchObject({
|
|
421
|
-
error: { message: "SmartPBX media must be a JSON object" },
|
|
422
|
-
});
|
|
423
|
-
expect(received).toEqual([]);
|
|
424
|
-
|
|
425
|
-
client.close();
|
|
426
|
-
await server.close();
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it("treats SmartPBX hangup as a terminal stream boundary", async () => {
|
|
430
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
431
|
-
const received: UserAudioReceivedPacket[] = [];
|
|
432
|
-
session.bus.on("user.audio_received", (pkt) => {
|
|
433
|
-
received.push(pkt as UserAudioReceivedPacket);
|
|
434
|
-
});
|
|
435
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
436
|
-
port: 0,
|
|
437
|
-
createSession: () => session,
|
|
438
|
-
}));
|
|
439
|
-
const address = server.address();
|
|
440
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
441
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
442
|
-
const messages: any[] = [];
|
|
443
|
-
client.on("message", (data, isBinary) => {
|
|
444
|
-
if (!isBinary) messages.push(JSON.parse(data.toString()));
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
448
|
-
client.send(JSON.stringify({ event: "hangup" }));
|
|
449
|
-
client.send(JSON.stringify({
|
|
450
|
-
event: "media",
|
|
451
|
-
media: {
|
|
452
|
-
payload: Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64"),
|
|
453
|
-
},
|
|
454
|
-
}));
|
|
455
|
-
session.bus.push(Route.Main, {
|
|
456
|
-
kind: "tts.audio",
|
|
457
|
-
contextId: "smartpbx-call-test",
|
|
458
|
-
timestampMs: Date.now(),
|
|
459
|
-
audio: pcm16SamplesToBytes(new Int16Array(320)),
|
|
460
|
-
sampleRateHz: 16000,
|
|
461
|
-
});
|
|
462
|
-
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
463
|
-
|
|
464
|
-
expect(received).toHaveLength(0);
|
|
465
|
-
expect(messages.filter((message) => message.event === "media")).toHaveLength(0);
|
|
466
|
-
|
|
467
|
-
client.close();
|
|
468
|
-
await server.close();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it("cancels queued SmartPBX playout when the provider sends hangup", async () => {
|
|
472
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
473
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
474
|
-
const recording: RecordAssistantAudioPacket[] = [];
|
|
475
|
-
let releaseMain: () => void = () => undefined;
|
|
476
|
-
let notifyMainBlocked: () => void = () => undefined;
|
|
477
|
-
const mainReleased = new Promise<void>((resolve) => {
|
|
478
|
-
releaseMain = resolve;
|
|
479
|
-
});
|
|
480
|
-
const mainBlocked = new Promise<void>((resolve) => {
|
|
481
|
-
notifyMainBlocked = resolve;
|
|
482
|
-
});
|
|
483
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
484
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
485
|
-
});
|
|
486
|
-
session.bus.on("record.assistant_audio", (pkt) => {
|
|
487
|
-
recording.push(pkt as RecordAssistantAudioPacket);
|
|
488
|
-
});
|
|
489
|
-
session.bus.on("user.audio_received", async () => {
|
|
490
|
-
notifyMainBlocked();
|
|
491
|
-
await mainReleased;
|
|
492
|
-
});
|
|
493
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
494
|
-
port: 0,
|
|
495
|
-
outputSampleRateHz: 16000,
|
|
496
|
-
outboundFrameDurationMs: 250,
|
|
497
|
-
maxQueuedOutputAudioMs: 1250,
|
|
498
|
-
createSession: () => session,
|
|
499
|
-
}));
|
|
500
|
-
const address = server.address();
|
|
501
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
502
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
503
|
-
const messages: any[] = [];
|
|
504
|
-
client.on("message", (data) => messages.push(JSON.parse(data.toString())));
|
|
505
|
-
|
|
506
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
507
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
508
|
-
try {
|
|
509
|
-
const firstMedia = readJsonMatching(client, (message) => message.event === "media");
|
|
510
|
-
session.bus.push(Route.Main, {
|
|
511
|
-
kind: "tts.audio",
|
|
512
|
-
contextId: "smartpbx-call-test",
|
|
513
|
-
timestampMs: Date.now(),
|
|
514
|
-
audio: pcm16SamplesToBytes(new Int16Array(16000)),
|
|
515
|
-
sampleRateHz: 16000,
|
|
516
|
-
});
|
|
517
|
-
await firstMedia;
|
|
518
|
-
expect(messages.filter((message) => message.event === "media")).toHaveLength(1);
|
|
519
|
-
|
|
520
|
-
client.send(JSON.stringify({
|
|
521
|
-
event: "media",
|
|
522
|
-
media: {
|
|
523
|
-
payload: Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64"),
|
|
524
|
-
},
|
|
525
|
-
}));
|
|
526
|
-
await mainBlocked;
|
|
527
|
-
|
|
528
|
-
client.send(JSON.stringify({ event: "hangup" }));
|
|
529
|
-
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
530
|
-
|
|
531
|
-
expect(messages.filter((message) => message.event === "media")).toHaveLength(1);
|
|
532
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
533
|
-
name: "smartpbx.stop_playout_cleared_ms",
|
|
534
|
-
value: expect.stringMatching(/^[1-9]\d*$/),
|
|
535
|
-
}));
|
|
536
|
-
expect(recording).toContainEqual(expect.objectContaining({
|
|
537
|
-
contextId: "smartpbx-call-test",
|
|
538
|
-
truncate: true,
|
|
539
|
-
}));
|
|
540
|
-
} finally {
|
|
541
|
-
releaseMain();
|
|
542
|
-
client.close();
|
|
543
|
-
await server.close();
|
|
544
|
-
}
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
it("truncates queued SmartPBX playout when the websocket disconnects without hangup", async () => {
|
|
548
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
549
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
550
|
-
const recording: RecordAssistantAudioPacket[] = [];
|
|
551
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
552
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
553
|
-
});
|
|
554
|
-
session.bus.on("record.assistant_audio", (pkt) => {
|
|
555
|
-
recording.push(pkt as RecordAssistantAudioPacket);
|
|
556
|
-
});
|
|
557
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
558
|
-
port: 0,
|
|
559
|
-
outputSampleRateHz: 16000,
|
|
560
|
-
outboundFrameDurationMs: 250,
|
|
561
|
-
maxQueuedOutputAudioMs: 30_000,
|
|
562
|
-
createSession: () => session,
|
|
563
|
-
}));
|
|
564
|
-
const address = server.address();
|
|
565
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
566
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
567
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
568
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
569
|
-
|
|
570
|
-
const firstMedia = readJsonMatching(client, (message) => message.event === "media");
|
|
571
|
-
session.bus.push(Route.Main, {
|
|
572
|
-
kind: "tts.audio",
|
|
573
|
-
contextId: "smartpbx-call-test",
|
|
574
|
-
timestampMs: Date.now(),
|
|
575
|
-
audio: pcm16SamplesToBytes(new Int16Array(16000)),
|
|
576
|
-
sampleRateHz: 16000,
|
|
577
|
-
});
|
|
578
|
-
await firstMedia;
|
|
579
|
-
client.terminate();
|
|
580
|
-
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
581
|
-
|
|
582
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
583
|
-
name: "smartpbx.disconnect_playout_cleared_ms",
|
|
584
|
-
value: expect.stringMatching(/^[1-9]\d*$/),
|
|
585
|
-
}));
|
|
586
|
-
expect(recording).toContainEqual(expect.objectContaining({ truncate: true }));
|
|
587
|
-
await server.close();
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it("records local SmartPBX playout drain after the paced queue reaches tts.end", async () => {
|
|
591
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
592
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
593
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
594
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
595
|
-
});
|
|
596
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
597
|
-
port: 0,
|
|
598
|
-
outputSampleRateHz: 16000,
|
|
599
|
-
outboundFrameDurationMs: 20,
|
|
600
|
-
createSession: () => session,
|
|
601
|
-
}));
|
|
602
|
-
const address = server.address();
|
|
603
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
604
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
605
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
606
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
607
|
-
|
|
608
|
-
const secondMedia = readNthJsonMatching(client, (message) => message.event === "media", 2);
|
|
609
|
-
session.bus.push(Route.Main, {
|
|
610
|
-
kind: "tts.audio",
|
|
611
|
-
contextId: "smartpbx-call-test",
|
|
612
|
-
timestampMs: Date.now(),
|
|
613
|
-
audio: pcm16SamplesToBytes(new Int16Array(640)),
|
|
614
|
-
sampleRateHz: 16000,
|
|
615
|
-
});
|
|
616
|
-
session.bus.push(Route.Main, {
|
|
617
|
-
kind: "tts.end",
|
|
618
|
-
contextId: "smartpbx-call-test",
|
|
619
|
-
timestampMs: Date.now(),
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
await secondMedia;
|
|
623
|
-
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
624
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
625
|
-
contextId: "smartpbx-call-test",
|
|
626
|
-
name: "smartpbx.playout_drained",
|
|
627
|
-
value: "1",
|
|
628
|
-
}));
|
|
629
|
-
expect(metrics).not.toContainEqual(expect.objectContaining({
|
|
630
|
-
name: "smartpbx.stop_playout_cleared_ms",
|
|
631
|
-
}));
|
|
632
|
-
|
|
633
|
-
client.close();
|
|
634
|
-
await server.close();
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
it("cancels unsent assistant audio locally without inventing a provider playback-clear command", async () => {
|
|
638
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
639
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
640
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
641
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
642
|
-
});
|
|
643
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
644
|
-
port: 0,
|
|
645
|
-
outputSampleRateHz: 16000,
|
|
646
|
-
outboundFrameDurationMs: 20,
|
|
647
|
-
createSession: () => session,
|
|
648
|
-
}));
|
|
649
|
-
const address = server.address();
|
|
650
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
651
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
652
|
-
const sent: any[] = [];
|
|
653
|
-
client.on("message", (data) => sent.push(JSON.parse(data.toString())));
|
|
654
|
-
|
|
655
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
656
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
657
|
-
session.bus.push(Route.Main, {
|
|
658
|
-
kind: "tts.audio",
|
|
659
|
-
contextId: "smartpbx-call-test",
|
|
660
|
-
timestampMs: Date.now(),
|
|
661
|
-
audio: pcm16SamplesToBytes(new Int16Array(1280)),
|
|
662
|
-
sampleRateHz: 16000,
|
|
663
|
-
});
|
|
664
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
665
|
-
expect(sent.filter((message) => message.event === "media")).toHaveLength(1);
|
|
666
|
-
session.bus.push(Route.Main, {
|
|
667
|
-
kind: "interrupt.tts",
|
|
668
|
-
contextId: "smartpbx-call-test",
|
|
669
|
-
timestampMs: Date.now(),
|
|
670
|
-
reason: "barge_in",
|
|
671
|
-
});
|
|
672
|
-
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
673
|
-
|
|
674
|
-
expect(sent.filter((message) => message.event === "media")).toHaveLength(1);
|
|
675
|
-
expect(sent.some((message) => message.event === "clear")).toBe(false);
|
|
676
|
-
expect(metrics).toEqual([
|
|
677
|
-
expect.objectContaining({ name: "smartpbx.interrupt_no_playback_clear" }),
|
|
678
|
-
expect.objectContaining({ name: "smartpbx.interrupt_onset_to_media_silent_ms" }),
|
|
679
|
-
]);
|
|
680
|
-
|
|
681
|
-
session.bus.push(Route.Main, {
|
|
682
|
-
kind: "tts.audio",
|
|
683
|
-
contextId: "smartpbx-call-test",
|
|
684
|
-
timestampMs: Date.now(),
|
|
685
|
-
audio: pcm16SamplesToBytes(new Int16Array(320)),
|
|
686
|
-
sampleRateHz: 16000,
|
|
687
|
-
});
|
|
688
|
-
session.bus.push(Route.Main, {
|
|
689
|
-
kind: "tts.end",
|
|
690
|
-
contextId: "smartpbx-call-test",
|
|
691
|
-
timestampMs: Date.now(),
|
|
692
|
-
});
|
|
693
|
-
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
694
|
-
|
|
695
|
-
expect(sent.filter((message) => message.event === "media")).toHaveLength(1);
|
|
696
|
-
expect(metrics).toEqual([
|
|
697
|
-
expect.objectContaining({ name: "smartpbx.interrupt_no_playback_clear" }),
|
|
698
|
-
expect.objectContaining({ name: "smartpbx.interrupt_onset_to_media_silent_ms" }),
|
|
699
|
-
]);
|
|
700
|
-
|
|
701
|
-
client.close();
|
|
702
|
-
await server.close();
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
it("sends heartbeat pings to SmartPBX websocket peers", async () => {
|
|
706
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
707
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
708
|
-
port: 0,
|
|
709
|
-
heartbeatIntervalMs: 5,
|
|
710
|
-
createSession: () => session,
|
|
711
|
-
}));
|
|
712
|
-
const address = server.address();
|
|
713
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
714
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
715
|
-
let pinged = false;
|
|
716
|
-
client.once("ping", () => {
|
|
717
|
-
pinged = true;
|
|
718
|
-
});
|
|
719
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
720
|
-
expect(pinged).toBe(true);
|
|
721
|
-
|
|
722
|
-
client.close();
|
|
723
|
-
await server.close();
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
it("closes SmartPBX websocket sessions that exceed maxSessionDurationMs", async () => {
|
|
727
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
728
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
729
|
-
port: 0,
|
|
730
|
-
maxSessionDurationMs: 10,
|
|
731
|
-
createSession: () => session,
|
|
732
|
-
}));
|
|
733
|
-
const address = server.address();
|
|
734
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
735
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
736
|
-
const closed = new Promise<{ code: number; reason: string }>((resolve) => {
|
|
737
|
-
client.once("close", (code, reason) => {
|
|
738
|
-
resolve({ code, reason: reason.toString() });
|
|
739
|
-
});
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
await expect(closed).resolves.toEqual({
|
|
743
|
-
code: 1000,
|
|
744
|
-
reason: "websocket max session duration exceeded",
|
|
745
|
-
});
|
|
746
|
-
await server.close();
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
it("closes oversized SmartPBX inbound messages before parsing", async () => {
|
|
750
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
751
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
752
|
-
port: 0,
|
|
753
|
-
maxInboundMessageBytes: 64,
|
|
754
|
-
createSession: () => session,
|
|
755
|
-
}));
|
|
756
|
-
const address = server.address();
|
|
757
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
758
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
759
|
-
const closed = new Promise<number>((resolve) => client.once("close", (code) => resolve(code)));
|
|
760
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
761
|
-
await expect(closed).resolves.toBe(1009);
|
|
762
|
-
await server.close();
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
it("closes slow SmartPBX consumers before outbound buffers grow unbounded", async () => {
|
|
766
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
767
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
768
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
769
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
770
|
-
});
|
|
771
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
772
|
-
port: 0,
|
|
773
|
-
maxBufferedAmountBytes: 1,
|
|
774
|
-
createSession: () => session,
|
|
775
|
-
}));
|
|
776
|
-
const address = server.address();
|
|
777
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
778
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
779
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
780
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
781
|
-
const serverSocket = [...server.wsServer.clients][0]!;
|
|
782
|
-
Object.defineProperty(serverSocket, "bufferedAmount", { value: 2, configurable: true });
|
|
783
|
-
const closed = new Promise<number>((resolve) => client.once("close", (code) => resolve(code)));
|
|
784
|
-
session.bus.push(Route.Main, {
|
|
785
|
-
kind: "tts.audio",
|
|
786
|
-
contextId: "smartpbx-call-test",
|
|
787
|
-
timestampMs: Date.now(),
|
|
788
|
-
audio: pcm16SamplesToBytes(new Int16Array(320)),
|
|
789
|
-
sampleRateHz: 16000,
|
|
790
|
-
});
|
|
791
|
-
await expect(closed).resolves.toBe(1013);
|
|
792
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
793
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
794
|
-
name: "smartpbx.send_buffer_playout_cleared_ms",
|
|
795
|
-
value: "20",
|
|
796
|
-
}));
|
|
797
|
-
await server.close();
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
it("drops SmartPBX DTMF before start with a metric and no dtmf.received packet", async () => {
|
|
801
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
802
|
-
const dtmfReceived: unknown[] = [];
|
|
803
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
804
|
-
session.bus.on("dtmf.received", (pkt) => { dtmfReceived.push(pkt); });
|
|
805
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
806
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
810
|
-
port: 0,
|
|
811
|
-
createSession: () => session,
|
|
812
|
-
}));
|
|
813
|
-
const address = server.address();
|
|
814
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
815
|
-
|
|
816
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
817
|
-
client.send(JSON.stringify({
|
|
818
|
-
event: "dtmf",
|
|
819
|
-
dtmf: { digit: "5" },
|
|
820
|
-
}));
|
|
821
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
822
|
-
|
|
823
|
-
expect(dtmfReceived).toEqual([]);
|
|
824
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
825
|
-
name: "smartpbx.dtmf.before_start",
|
|
826
|
-
value: "5",
|
|
827
|
-
}));
|
|
828
|
-
|
|
829
|
-
client.close();
|
|
830
|
-
await server.close();
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
it("test:dtmf_typed_per_carrier emits dtmf.received for SmartPBX DTMF", async () => {
|
|
834
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
835
|
-
const dtmfReceived: Array<{ kind: string; digit: string; provider: string; rawDigit: string; contextId: string }> = [];
|
|
836
|
-
session.bus.on("dtmf.received", (pkt) => {
|
|
837
|
-
dtmfReceived.push(pkt as unknown as { kind: string; digit: string; provider: string; rawDigit: string; contextId: string });
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
841
|
-
port: 0,
|
|
842
|
-
createSession: () => session,
|
|
843
|
-
contextId: () => "smartpbx-call-test",
|
|
844
|
-
}));
|
|
845
|
-
const address = server.address();
|
|
846
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
847
|
-
|
|
848
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
849
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
850
|
-
client.send(JSON.stringify({
|
|
851
|
-
event: "dtmf",
|
|
852
|
-
dtmf: { digit: "5" },
|
|
853
|
-
}));
|
|
854
|
-
await waitForCondition(() => dtmfReceived.length === 1);
|
|
855
|
-
|
|
856
|
-
expect(dtmfReceived).toEqual([
|
|
857
|
-
expect.objectContaining({
|
|
858
|
-
kind: "dtmf.received",
|
|
859
|
-
digit: "5",
|
|
860
|
-
provider: "smartpbx",
|
|
861
|
-
rawDigit: "5",
|
|
862
|
-
contextId: "smartpbx-call-test",
|
|
863
|
-
}),
|
|
864
|
-
]);
|
|
865
|
-
|
|
866
|
-
client.close();
|
|
867
|
-
await server.close();
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
it("test:dtmf_bypasses_speech_path: SmartPBX DTMF during playout emits no audio, VAD, STT, or interrupt packets", async () => {
|
|
871
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
872
|
-
const dtmfReceived: unknown[] = [];
|
|
873
|
-
const speechPath: unknown[] = [];
|
|
874
|
-
const speechKinds = [
|
|
875
|
-
"user.audio_received",
|
|
876
|
-
"stt.audio",
|
|
877
|
-
"vad.audio",
|
|
878
|
-
"vad.speech_started",
|
|
879
|
-
"vad.speech_ended",
|
|
880
|
-
"vad.speech_activity",
|
|
881
|
-
"interrupt.detected",
|
|
882
|
-
"interrupt.tts",
|
|
883
|
-
"interrupt.llm",
|
|
884
|
-
"interrupt.stt",
|
|
885
|
-
] as const;
|
|
886
|
-
session.bus.on("dtmf.received", (pkt) => { dtmfReceived.push(pkt); });
|
|
887
|
-
for (const kind of speechKinds) {
|
|
888
|
-
session.bus.on(kind, (pkt) => { speechPath.push(pkt); });
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
892
|
-
port: 0,
|
|
893
|
-
outputSampleRateHz: 16000,
|
|
894
|
-
createSession: () => session,
|
|
895
|
-
contextId: () => "smartpbx-call-test",
|
|
896
|
-
}));
|
|
897
|
-
const address = server.address();
|
|
898
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
899
|
-
|
|
900
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
901
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
902
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
903
|
-
|
|
904
|
-
session.bus.push(Route.Main, {
|
|
905
|
-
kind: "tts.audio",
|
|
906
|
-
contextId: "smartpbx-call-test",
|
|
907
|
-
timestampMs: Date.now(),
|
|
908
|
-
audio: pcm16SamplesToBytes(new Int16Array(320)),
|
|
909
|
-
sampleRateHz: 16000,
|
|
910
|
-
});
|
|
911
|
-
client.send(JSON.stringify({
|
|
912
|
-
event: "dtmf",
|
|
913
|
-
digit: "9",
|
|
914
|
-
}));
|
|
915
|
-
await waitForCondition(() => dtmfReceived.length === 1);
|
|
916
|
-
|
|
917
|
-
expect(dtmfReceived).toEqual([
|
|
918
|
-
expect.objectContaining({
|
|
919
|
-
kind: "dtmf.received",
|
|
920
|
-
digit: "9",
|
|
921
|
-
provider: "smartpbx",
|
|
922
|
-
rawDigit: "9",
|
|
923
|
-
}),
|
|
924
|
-
]);
|
|
925
|
-
expect(speechPath).toEqual([]);
|
|
926
|
-
|
|
927
|
-
client.close();
|
|
928
|
-
await server.close();
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
it("emits dtmf.unparsed for invalid SmartPBX DTMF digits", async () => {
|
|
932
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
933
|
-
const metrics: ConversationMetricPacket[] = [];
|
|
934
|
-
const dtmfReceived: unknown[] = [];
|
|
935
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
936
|
-
metrics.push(pkt as ConversationMetricPacket);
|
|
937
|
-
});
|
|
938
|
-
session.bus.on("dtmf.received", (pkt) => { dtmfReceived.push(pkt); });
|
|
939
|
-
|
|
940
|
-
const server = registerServer(await createSmartPbxMediaStreamServer({
|
|
941
|
-
port: 0,
|
|
942
|
-
createSession: () => session,
|
|
943
|
-
contextId: () => "smartpbx-call-test",
|
|
944
|
-
}));
|
|
945
|
-
const address = server.address();
|
|
946
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
947
|
-
|
|
948
|
-
const client = await openSmartPbxSocket(smartPbxUrl(address.port));
|
|
949
|
-
client.send(JSON.stringify(smartPbxStart()));
|
|
950
|
-
client.send(JSON.stringify({
|
|
951
|
-
event: "dtmf",
|
|
952
|
-
dtmf: { digit: "??" },
|
|
953
|
-
}));
|
|
954
|
-
await waitForCondition(() => metrics.some((metric) => metric.name === "dtmf.unparsed"));
|
|
955
|
-
|
|
956
|
-
expect(metrics).toContainEqual(expect.objectContaining({
|
|
957
|
-
kind: "metric.conversation",
|
|
958
|
-
name: "dtmf.unparsed",
|
|
959
|
-
value: "??",
|
|
960
|
-
contextId: "smartpbx-call-test",
|
|
961
|
-
}));
|
|
962
|
-
expect(dtmfReceived).toEqual([]);
|
|
963
|
-
|
|
964
|
-
client.close();
|
|
965
|
-
await server.close();
|
|
966
|
-
});
|
|
967
|
-
});
|