@kuralle-syrinx/server-websocket 2.1.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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/package.json +31 -0
  3. package/src/admission-control.test.ts +247 -0
  4. package/src/browser-opus.test.ts +41 -0
  5. package/src/browser-opus.ts +59 -0
  6. package/src/browser-pacing.test.ts +440 -0
  7. package/src/edge-twilio.test.ts +215 -0
  8. package/src/edge-twilio.ts +285 -0
  9. package/src/edge.test.ts +272 -0
  10. package/src/edge.ts +652 -0
  11. package/src/graceful-drain.test.ts +306 -0
  12. package/src/inbound-audio.ts +102 -0
  13. package/src/index.test.ts +2071 -0
  14. package/src/index.ts +891 -0
  15. package/src/json-message.ts +39 -0
  16. package/src/outbound-playout-pipeline.test.ts +208 -0
  17. package/src/outbound-playout-pipeline.ts +167 -0
  18. package/src/paced-playout.test.ts +247 -0
  19. package/src/paced-playout.ts +161 -0
  20. package/src/playout-progress.test.ts +56 -0
  21. package/src/playout-progress.ts +67 -0
  22. package/src/session-store.test.ts +274 -0
  23. package/src/session-store.ts +123 -0
  24. package/src/smartpbx.test.ts +967 -0
  25. package/src/smartpbx.ts +531 -0
  26. package/src/telnyx.test.ts +1413 -0
  27. package/src/telnyx.ts +708 -0
  28. package/src/test-helpers.ts +264 -0
  29. package/src/transport-helpers.ts +78 -0
  30. package/src/transport-host.test.ts +51 -0
  31. package/src/transport-host.ts +213 -0
  32. package/src/turn-metrics.test.ts +327 -0
  33. package/src/turn-metrics.ts +193 -0
  34. package/src/twilio.test.ts +1275 -0
  35. package/src/twilio.ts +599 -0
  36. package/src/websocket-close.test.ts +63 -0
  37. package/src/websocket-close.ts +68 -0
  38. package/src/websocket-lifecycle.test.ts +49 -0
  39. package/src/websocket-lifecycle.ts +105 -0
  40. package/src/websocket-upgrade.ts +102 -0
  41. package/tsconfig.json +22 -0
  42. package/vitest.config.ts +7 -0
package/src/telnyx.ts ADDED
@@ -0,0 +1,708 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { IncomingMessage } from "node:http";
4
+ import { createServer, type Server as HttpServer } from "node:http";
5
+ import { WebSocket, WebSocketServer, type RawData } from "ws";
6
+ import { Route, type VoiceAgentSession } from "@kuralle-syrinx/core";
7
+ import {
8
+ bigEndianPcm16BytesToSamples,
9
+ decodeMuLawToPcm16,
10
+ encodePcm16ToMuLaw,
11
+ pcm16BytesToSamples,
12
+ pcm16SamplesToBytes,
13
+ pcm16SamplesToBigEndianBytes,
14
+ resamplePcm16Streaming,
15
+ type StreamingPcm16Resampler,
16
+ } from "@kuralle-syrinx/core/audio";
17
+ import type { PacedPlayoutFrame } from "./paced-playout.js";
18
+ import { sendJsonCapped } from "./websocket-close.js";
19
+ import {
20
+ optionalRecord,
21
+ optionalString,
22
+ optionalStringOrNumber,
23
+ parseJsonRecord,
24
+ requiredString,
25
+ } from "./json-message.js";
26
+ import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
27
+ import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
28
+ import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
29
+ import {
30
+ decodeStrictBase64,
31
+ nonNegativeInteger,
32
+ numberFromString,
33
+ optionalNonNegativeIntegerString,
34
+ optionalPositiveIntegerString,
35
+ positiveInteger,
36
+ rawDataToText,
37
+ } from "./transport-helpers.js";
38
+
39
+ export interface TelnyxMediaStreamServerOptions {
40
+ readonly server?: HttpServer;
41
+ readonly port?: number;
42
+ readonly host?: string;
43
+ readonly path?: string;
44
+ readonly createSession: (request: IncomingMessage) => VoiceAgentSession | Promise<VoiceAgentSession>;
45
+ readonly contextId?: (start: TelnyxStartPayload) => string;
46
+ readonly inputSampleRateHz?: number;
47
+ readonly outputSampleRateHz?: number;
48
+ /** Must match the `stream_bidirectional_codec` selected when starting the Telnyx call stream. */
49
+ readonly bidirectionalCodec?: "PCMU" | "L16";
50
+ readonly outboundFrameDurationMs?: number;
51
+ readonly maxQueuedOutputAudioMs?: number;
52
+ readonly maxInboundReorderFrames?: number;
53
+ readonly heartbeatIntervalMs?: number;
54
+ readonly startupTimeoutMs?: number;
55
+ readonly maxSessionDurationMs?: number;
56
+ readonly maxBufferedAmountBytes?: number;
57
+ readonly maxInboundMessageBytes?: number;
58
+ readonly maxConcurrentSessions?: number;
59
+ readonly maxConcurrentSessionsScope?: "path" | "server";
60
+ readonly onTransportMetric?: (name: string) => void;
61
+ }
62
+
63
+ export interface TelnyxMediaStreamServer {
64
+ readonly httpServer: HttpServer;
65
+ readonly wsServer: WebSocketServer;
66
+ address(): ReturnType<HttpServer["address"]>;
67
+ close(opts?: GracefulCloseOptions): Promise<void>;
68
+ }
69
+
70
+ export interface TelnyxStartPayload {
71
+ readonly stream_id?: string;
72
+ readonly call_control_id?: string;
73
+ readonly call_session_id?: string;
74
+ readonly media_format?: {
75
+ readonly encoding?: string;
76
+ readonly sample_rate?: number | string;
77
+ readonly channels?: number | string;
78
+ };
79
+ }
80
+
81
+ interface TelnyxMediaMessage {
82
+ readonly event?: string;
83
+ readonly stream_id?: string;
84
+ readonly sequence_number?: string;
85
+ readonly start?: TelnyxStartPayload;
86
+ readonly media?: {
87
+ readonly payload?: string;
88
+ readonly track?: string;
89
+ readonly chunk?: string;
90
+ readonly timestamp?: string;
91
+ };
92
+ readonly mark?: { readonly name?: string };
93
+ readonly dtmf?: { readonly digit?: string };
94
+ }
95
+
96
+ interface PendingTelnyxMediaFrame {
97
+ readonly chunk: number;
98
+ readonly timestamp?: string;
99
+ readonly pcm: Int16Array;
100
+ }
101
+
102
+ type TelnyxCodec = "PCMU" | "L16";
103
+
104
+ interface TelnyxConnectionState {
105
+ streamId: string;
106
+ contextId: string;
107
+ inboundCodec: TelnyxCodec;
108
+ inboundSampleRateHz: number;
109
+ readonly outboundCodec: TelnyxCodec;
110
+ readonly outboundSampleRateHz: number;
111
+ started: boolean;
112
+ stopped: boolean;
113
+ lastInboundSequenceNumber: number | null;
114
+ nextInboundMediaChunk: number;
115
+ readonly inboundMediaReorderBuffer: Map<number, PendingTelnyxMediaFrame>;
116
+ lastInboundMediaTimestampMs: number | null;
117
+ outboundSequence: number;
118
+ pendingMarks: Set<string>;
119
+ pendingEndMarkName: string;
120
+ onPlaybackMarkReceived?: () => void;
121
+ clearPlayout: (reason: string) => void;
122
+ readonly streamingResamplers: Map<string, StreamingPcm16Resampler>;
123
+ }
124
+
125
+ const DEFAULT_ENGINE_SAMPLE_RATE_HZ = 16000;
126
+ const DEFAULT_OUTBOUND_FRAME_DURATION_MS = 20;
127
+ // Bounds queue memory + sanity only. TTS providers legitimately burst entire
128
+ // replies ahead of realtime (Deepgram); the pacer drains at wire rate and
129
+ // barge-in clear is O(1), so a generous cap is safe. A tiny cap turns every
130
+ // long reply into dropped audio.
131
+ const DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS = 60_000;
132
+ const DEFAULT_MAX_INBOUND_REORDER_FRAMES = 4;
133
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
134
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
135
+ const DEFAULT_MAX_SESSION_DURATION_MS = 30 * 60_000;
136
+ const DEFAULT_MAX_BUFFERED_AMOUNT_BYTES = 8 * 1024 * 1024;
137
+ const DEFAULT_MAX_INBOUND_MESSAGE_BYTES = 256 * 1024;
138
+
139
+ export async function createTelnyxMediaStreamServer(
140
+ options: TelnyxMediaStreamServerOptions,
141
+ ): Promise<TelnyxMediaStreamServer> {
142
+ const ownsHttpServer = !options.server;
143
+ const httpServer = options.server ?? createServer();
144
+ const routedWebSocket = createRoutedWebSocketServer(httpServer, options.path ?? "/telnyx", {
145
+ maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
146
+ maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
147
+ onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
148
+ });
149
+ const wsServer = routedWebSocket.wsServer;
150
+ const sessions = new Set<VoiceAgentSession>();
151
+ const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? DEFAULT_ENGINE_SAMPLE_RATE_HZ;
152
+ const bidirectionalCodec: TelnyxCodec = options.bidirectionalCodec ?? "PCMU";
153
+ const outboundSampleRateHz = bidirectionalCodec === "L16" ? 16000 : 8000;
154
+ const outboundFrameDurationMs = positiveInteger(options.outboundFrameDurationMs) ?? DEFAULT_OUTBOUND_FRAME_DURATION_MS;
155
+ const maxQueuedOutputAudioMs = positiveInteger(options.maxQueuedOutputAudioMs) ?? DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS;
156
+ const maxInboundReorderFrames = positiveInteger(options.maxInboundReorderFrames) ?? DEFAULT_MAX_INBOUND_REORDER_FRAMES;
157
+ const hostConfig: TransportHostConfig = {
158
+ heartbeatIntervalMs: nonNegativeInteger(options.heartbeatIntervalMs) ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
159
+ startupTimeoutMs: nonNegativeInteger(options.startupTimeoutMs) ?? DEFAULT_STARTUP_TIMEOUT_MS,
160
+ maxSessionDurationMs: nonNegativeInteger(options.maxSessionDurationMs) ?? DEFAULT_MAX_SESSION_DURATION_MS,
161
+ maxBufferedAmountBytes: positiveInteger(options.maxBufferedAmountBytes) ?? DEFAULT_MAX_BUFFERED_AMOUNT_BYTES,
162
+ maxInboundMessageBytes: positiveInteger(options.maxInboundMessageBytes) ?? DEFAULT_MAX_INBOUND_MESSAGE_BYTES,
163
+ };
164
+ const contextIdFn = options.contextId ?? defaultTelnyxContextId;
165
+ const gracefulCloseRegistry = new Map<WebSocket, (deadlineMs: number) => Promise<void>>();
166
+
167
+ const adapter: TransportAdapter<TelnyxConnectionState> = {
168
+ createState: () => ({
169
+ streamId: "",
170
+ contextId: "",
171
+ inboundCodec: "PCMU",
172
+ inboundSampleRateHz: 8000,
173
+ outboundCodec: bidirectionalCodec,
174
+ outboundSampleRateHz,
175
+ started: false,
176
+ stopped: false,
177
+ lastInboundSequenceNumber: null,
178
+ nextInboundMediaChunk: 1,
179
+ inboundMediaReorderBuffer: new Map(),
180
+ lastInboundMediaTimestampMs: null,
181
+ outboundSequence: 0,
182
+ pendingMarks: new Set(),
183
+ pendingEndMarkName: "",
184
+ clearPlayout: () => undefined,
185
+ streamingResamplers: new Map(),
186
+ }),
187
+
188
+ async acquireSession({ request, shouldAbort, onSessionCreated }) {
189
+ const sess = await options.createSession(request);
190
+ onSessionCreated(sess);
191
+ if (shouldAbort()) {
192
+ await sess.close().catch(() => undefined);
193
+ throw new Error("Telnyx websocket session startup aborted");
194
+ }
195
+ sessions.add(sess);
196
+ await sess.start();
197
+ if (shouldAbort()) {
198
+ sessions.delete(sess);
199
+ await sess.close().catch(() => undefined);
200
+ throw new Error("Telnyx websocket session startup aborted");
201
+ }
202
+ return { session: sess, resumed: false };
203
+ },
204
+
205
+ wireSession(session, socket, state, disposers) {
206
+ const sendPendingEndMark = (): void => {
207
+ if (state.stopped || !state.streamId || !state.pendingEndMarkName || state.pendingMarks.size > 0) return;
208
+ const markName = state.pendingEndMarkName;
209
+ const sent = sendTelnyxJson(socket, {
210
+ event: "mark",
211
+ mark: { name: markName },
212
+ }, hostConfig.maxBufferedAmountBytes);
213
+ if (sent) state.pendingEndMarkName = "";
214
+ };
215
+ state.onPlaybackMarkReceived = sendPendingEndMark;
216
+
217
+ const outbound = wireTelephonyOutboundPipeline({
218
+ session,
219
+ socket,
220
+ disposers,
221
+ outboundFrameDurationMs,
222
+ maxQueuedOutputAudioMs,
223
+ callbacks: {
224
+ carrierLabel: "telnyx",
225
+ getContextId: () => state.contextId,
226
+ isActive: () => !state.stopped && !!state.streamId,
227
+ encodeFrames: (audio, sourceSampleRateHz, contextId) => {
228
+ const frames: PacedPlayoutFrame[] = encodeOutboundPayload(audio, sourceSampleRateHz, state, outboundFrameDurationMs)
229
+ .map((frame) => ({
230
+ contextId,
231
+ send: () => {
232
+ if (state.stopped) return false;
233
+ return sendTelnyxJson(socket, {
234
+ event: "media",
235
+ media: { payload: Buffer.from(frame).toString("base64") },
236
+ }, hostConfig.maxBufferedAmountBytes);
237
+ },
238
+ }));
239
+ state.outboundSequence += 1;
240
+ const markName = `${contextId}:${String(state.outboundSequence)}`;
241
+ const finalFrame = frames.at(-1);
242
+ if (finalFrame) {
243
+ frames[frames.length - 1] = {
244
+ send: finalFrame.send,
245
+ afterSend: () => {
246
+ if (state.stopped) return;
247
+ const sent = sendTelnyxJson(socket, {
248
+ event: "mark",
249
+ mark: { name: markName },
250
+ }, hostConfig.maxBufferedAmountBytes);
251
+ if (sent) {
252
+ state.pendingMarks.add(markName);
253
+ session.bus.push(Route.Background, {
254
+ kind: "metric.conversation",
255
+ contextId,
256
+ timestampMs: Date.now(),
257
+ name: "telnyx.mark_sent",
258
+ value: markName,
259
+ });
260
+ }
261
+ },
262
+ };
263
+ }
264
+ return frames;
265
+ },
266
+ onInterrupt: (contextId) => {
267
+ state.pendingMarks.clear();
268
+ state.pendingEndMarkName = "";
269
+ const sent = !state.stopped && !!state.streamId && sendTelnyxJson(socket, { event: "clear" }, hostConfig.maxBufferedAmountBytes);
270
+ if (sent) {
271
+ session.bus.push(Route.Background, {
272
+ kind: "metric.conversation",
273
+ contextId,
274
+ timestampMs: Date.now(),
275
+ name: "telnyx.clear_sent",
276
+ value: "1",
277
+ });
278
+ }
279
+ },
280
+ onDrain: (contextId, playout, progress) => {
281
+ playout.enqueueControl(() => {
282
+ if (state.stopped || !state.streamId) return;
283
+ progress.complete(contextId);
284
+ state.pendingEndMarkName = `${contextId}:end`;
285
+ sendPendingEndMark();
286
+ });
287
+ },
288
+ onStop: () => { state.stopped = true; },
289
+ },
290
+ });
291
+ state.clearPlayout = outbound.clearPlayout;
292
+ gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
293
+ disposers.push(() => gracefulCloseRegistry.delete(socket));
294
+ return (reason) => state.clearPlayout(reason);
295
+ },
296
+
297
+ onSocketClose(state, session) {
298
+ if (session && state.started && !state.stopped) {
299
+ flushTelnyxMediaReorderBuffer(session, state, inputSampleRateHz, maxInboundReorderFrames, true);
300
+ }
301
+ },
302
+
303
+ processMessage(data, isBinary, session, state) {
304
+ if (isBinary) throw new Error("Telnyx Media Streaming messages must be JSON text frames");
305
+ const message = parseTelnyxMessage(parseJsonRecord(rawDataToText(data), "Telnyx Media Streaming message"));
306
+ const event = message.event;
307
+ rememberTelnyxSequenceNumber(session, state, message.sequence_number);
308
+
309
+ if (event === "connected") return;
310
+ if (event === "start") {
311
+ if (state.stopped) throw new Error("Telnyx start event received after stream stop");
312
+ const start = message.start ?? {};
313
+ const format = validateTelnyxStart(start);
314
+ state.streamId = message.stream_id ?? start.stream_id ?? "";
315
+ if (!state.streamId) throw new Error("Telnyx start event is missing stream_id");
316
+ state.contextId = contextIdFn(start);
317
+ state.inboundCodec = format.codec;
318
+ state.inboundSampleRateHz = format.sampleRateHz;
319
+ state.started = true;
320
+ state.nextInboundMediaChunk = 1;
321
+ state.inboundMediaReorderBuffer.clear();
322
+ return;
323
+ }
324
+ if (event === "media") {
325
+ if (state.stopped) return;
326
+ if (!state.started || !state.contextId) throw new Error("Telnyx media event received before a valid start event");
327
+ const payload = message.media?.payload;
328
+ if (!payload) throw new Error("Telnyx media event is missing media.payload");
329
+ const encoded = decodeStrictBase64(payload, "media.payload");
330
+ const pcm = decodeInboundPayload(encoded, state.inboundCodec);
331
+ const chunk = optionalPositiveIntegerString(message.media?.chunk, "Telnyx media.chunk");
332
+ if (chunk === undefined) {
333
+ emitTelnyxMediaFrame(session, state, { chunk: 0, timestamp: message.media?.timestamp, pcm }, inputSampleRateHz);
334
+ } else {
335
+ rememberTelnyxMediaChunk(session, state, { chunk, timestamp: message.media?.timestamp, pcm }, inputSampleRateHz, maxInboundReorderFrames);
336
+ }
337
+ return;
338
+ }
339
+ if (event === "stop") {
340
+ flushTelnyxMediaReorderBuffer(session, state, inputSampleRateHz, maxInboundReorderFrames, true);
341
+ state.stopped = true;
342
+ state.started = false;
343
+ state.pendingMarks.clear();
344
+ state.clearPlayout("stop");
345
+ session.close().catch(() => undefined);
346
+ return;
347
+ }
348
+ if (event === "mark") {
349
+ if (state.stopped) return;
350
+ const markName = message.mark?.name ?? "";
351
+ if (markName) state.pendingMarks.delete(markName);
352
+ state.onPlaybackMarkReceived?.();
353
+ session.bus.push(Route.Background, {
354
+ kind: "metric.conversation",
355
+ contextId: state.contextId,
356
+ timestampMs: Date.now(),
357
+ name: "telnyx.mark_received",
358
+ value: markName,
359
+ });
360
+ return;
361
+ }
362
+ if (event === "dtmf") {
363
+ if (state.stopped) return;
364
+ if (!state.started || !state.contextId) {
365
+ session.bus.push(Route.Background, {
366
+ kind: "metric.conversation",
367
+ contextId: "",
368
+ timestampMs: Date.now(),
369
+ name: "telnyx.dtmf.before_start",
370
+ value: message.dtmf?.digit ?? "",
371
+ });
372
+ return;
373
+ }
374
+ const rawDigit = message.dtmf?.digit ?? "";
375
+ const digit = parseDtmfDigit(rawDigit);
376
+ if (!digit) {
377
+ session.bus.push(Route.Background, {
378
+ kind: "metric.conversation",
379
+ contextId: state.contextId,
380
+ timestampMs: Date.now(),
381
+ name: "dtmf.unparsed",
382
+ value: rawDigit,
383
+ });
384
+ return;
385
+ }
386
+ session.bus.push(Route.Critical, {
387
+ kind: "dtmf.received",
388
+ contextId: state.contextId,
389
+ timestampMs: Date.now(),
390
+ digit,
391
+ provider: "telnyx",
392
+ rawDigit,
393
+ });
394
+ return;
395
+ }
396
+ throw new Error(`Unsupported Telnyx Media Streaming event: ${String(event)}`);
397
+ },
398
+
399
+ onDisconnect(session) {
400
+ sessions.delete(session);
401
+ void session.close().catch(() => undefined);
402
+ },
403
+
404
+ onStartupTimeout(_state, session) {
405
+ sessions.delete(session);
406
+ void session.close().catch(() => undefined);
407
+ },
408
+
409
+ sendError(socket, state, message) {
410
+ sendTelnyxError(socket, state.streamId, message, hostConfig.maxBufferedAmountBytes);
411
+ },
412
+
413
+ sendStartupError(socket, state, err) {
414
+ sendTelnyxError(socket, state.streamId, err instanceof Error ? err.message : String(err), hostConfig.maxBufferedAmountBytes);
415
+ },
416
+ };
417
+
418
+ wsServer.on("connection", (socket, request) => {
419
+ void runWebSocketConnection(socket, request, hostConfig, adapter);
420
+ });
421
+
422
+ if (ownsHttpServer || typeof options.port === "number") {
423
+ await new Promise<void>((resolveListen, reject) => {
424
+ httpServer.once("error", reject);
425
+ httpServer.listen(options.port ?? 0, options.host, () => {
426
+ httpServer.off("error", reject);
427
+ resolveListen();
428
+ });
429
+ });
430
+ }
431
+
432
+ let closing = false;
433
+ return {
434
+ httpServer,
435
+ wsServer,
436
+ address: () => httpServer.address(),
437
+ close: async (opts) => {
438
+ if (closing) return;
439
+ closing = true;
440
+ if (opts?.graceful === true && gracefulCloseRegistry.size > 0) {
441
+ const deadline = Date.now() + (opts.drainDeadlineMs ?? 10_000);
442
+ await Promise.allSettled(
443
+ [...gracefulCloseRegistry.values()].map((fn) => fn(deadline)),
444
+ );
445
+ for (const client of wsServer.clients) {
446
+ if (client.readyState === WebSocket.OPEN) client.terminate();
447
+ }
448
+ } else {
449
+ for (const client of wsServer.clients) client.terminate();
450
+ }
451
+ gracefulCloseRegistry.clear();
452
+ for (const session of sessions) await session.close().catch(() => undefined);
453
+ await new Promise<void>((resolveClose) => { wsServer.close(() => resolveClose()); });
454
+ routedWebSocket.detach();
455
+ if (ownsHttpServer || typeof options.port === "number") {
456
+ await new Promise<void>((resolveClose) => { httpServer.close(() => resolveClose()); });
457
+ }
458
+ },
459
+ };
460
+ }
461
+
462
+ type TelnyxDtmfDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "*" | "#";
463
+
464
+ const DTMF_DIGITS = new Set<TelnyxDtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
465
+
466
+ function parseDtmfDigit(raw: string): TelnyxDtmfDigit | null {
467
+ const trimmed = raw.trim();
468
+ if (trimmed.length !== 1 || !DTMF_DIGITS.has(trimmed as TelnyxDtmfDigit)) return null;
469
+ return trimmed as TelnyxDtmfDigit;
470
+ }
471
+
472
+ function parseTelnyxMessage(value: Record<string, unknown>): TelnyxMediaMessage {
473
+ const start = optionalRecord(value.start, "Telnyx start");
474
+ const media = optionalRecord(value.media, "Telnyx media");
475
+ const mark = optionalRecord(value.mark, "Telnyx mark");
476
+ const dtmf = optionalRecord(value.dtmf, "Telnyx dtmf");
477
+ const mediaFormat = optionalRecord(start?.media_format, "Telnyx start.media_format");
478
+ return {
479
+ event: requiredString(value.event, "Telnyx event"),
480
+ stream_id: optionalString(value.stream_id, "Telnyx stream_id"),
481
+ sequence_number: optionalString(value.sequence_number, "Telnyx sequence_number"),
482
+ start: start
483
+ ? {
484
+ stream_id: optionalString(start.stream_id, "Telnyx start.stream_id"),
485
+ call_control_id: optionalString(start.call_control_id, "Telnyx start.call_control_id"),
486
+ call_session_id: optionalString(start.call_session_id, "Telnyx start.call_session_id"),
487
+ media_format: mediaFormat
488
+ ? {
489
+ encoding: optionalString(mediaFormat.encoding, "Telnyx start.media_format.encoding"),
490
+ sample_rate: optionalStringOrNumber(mediaFormat.sample_rate, "Telnyx start.media_format.sample_rate"),
491
+ channels: optionalStringOrNumber(mediaFormat.channels, "Telnyx start.media_format.channels"),
492
+ }
493
+ : undefined,
494
+ }
495
+ : undefined,
496
+ media: media
497
+ ? {
498
+ payload: optionalString(media.payload, "Telnyx media.payload"),
499
+ track: optionalString(media.track, "Telnyx media.track"),
500
+ chunk: optionalString(media.chunk, "Telnyx media.chunk"),
501
+ timestamp: optionalString(media.timestamp, "Telnyx media.timestamp"),
502
+ }
503
+ : undefined,
504
+ mark: mark ? { name: optionalString(mark.name, "Telnyx mark.name") } : undefined,
505
+ dtmf: dtmf ? { digit: optionalString(dtmf.digit, "Telnyx dtmf.digit") } : undefined,
506
+ };
507
+ }
508
+
509
+ function rememberTelnyxSequenceNumber(
510
+ session: VoiceAgentSession,
511
+ state: TelnyxConnectionState,
512
+ sequenceValue: string | undefined,
513
+ ): void {
514
+ const sequence = optionalPositiveIntegerString(sequenceValue, "Telnyx sequence_number");
515
+ if (sequence === undefined) return;
516
+ const previous = state.lastInboundSequenceNumber;
517
+ if (previous !== null && sequence <= previous) {
518
+ session.bus.push(Route.Background, {
519
+ kind: "metric.conversation",
520
+ contextId: state.contextId,
521
+ timestampMs: Date.now(),
522
+ name: "telnyx.sequence_regression",
523
+ value: JSON.stringify({ previous, actual: sequence }),
524
+ });
525
+ return;
526
+ }
527
+ if (previous !== null && sequence > previous + 1) {
528
+ session.bus.push(Route.Background, {
529
+ kind: "metric.conversation",
530
+ contextId: state.contextId,
531
+ timestampMs: Date.now(),
532
+ name: "telnyx.sequence_gap",
533
+ value: JSON.stringify({ expected: previous + 1, actual: sequence, missed: sequence - previous - 1 }),
534
+ });
535
+ }
536
+ state.lastInboundSequenceNumber = sequence;
537
+ }
538
+
539
+ function rememberTelnyxMediaChunk(
540
+ session: VoiceAgentSession,
541
+ state: TelnyxConnectionState,
542
+ frame: PendingTelnyxMediaFrame,
543
+ inputSampleRateHz: number,
544
+ maxInboundReorderFrames: number,
545
+ ): void {
546
+ if (frame.chunk < state.nextInboundMediaChunk || state.inboundMediaReorderBuffer.has(frame.chunk)) {
547
+ session.bus.push(Route.Background, {
548
+ kind: "metric.conversation",
549
+ contextId: state.contextId,
550
+ timestampMs: Date.now(),
551
+ name: "telnyx.media_chunk_duplicate",
552
+ value: JSON.stringify({
553
+ expected: state.nextInboundMediaChunk,
554
+ received: frame.chunk,
555
+ }),
556
+ });
557
+ return;
558
+ }
559
+ state.inboundMediaReorderBuffer.set(frame.chunk, frame);
560
+ flushTelnyxMediaReorderBuffer(session, state, inputSampleRateHz, maxInboundReorderFrames, false);
561
+ }
562
+
563
+ function flushTelnyxMediaReorderBuffer(
564
+ session: VoiceAgentSession,
565
+ state: TelnyxConnectionState,
566
+ inputSampleRateHz: number,
567
+ maxInboundReorderFrames: number,
568
+ force: boolean,
569
+ ): void {
570
+ while (state.inboundMediaReorderBuffer.size > 0) {
571
+ const next = state.inboundMediaReorderBuffer.get(state.nextInboundMediaChunk);
572
+ if (next) {
573
+ state.inboundMediaReorderBuffer.delete(state.nextInboundMediaChunk);
574
+ state.nextInboundMediaChunk += 1;
575
+ emitTelnyxMediaFrame(session, state, next, inputSampleRateHz);
576
+ continue;
577
+ }
578
+ if (!force && state.inboundMediaReorderBuffer.size <= maxInboundReorderFrames) break;
579
+ const lowestBufferedChunk = Math.min(...state.inboundMediaReorderBuffer.keys());
580
+ if (lowestBufferedChunk > state.nextInboundMediaChunk) {
581
+ session.bus.push(force ? Route.Critical : Route.Background, {
582
+ kind: "metric.conversation",
583
+ contextId: state.contextId,
584
+ timestampMs: Date.now(),
585
+ name: "telnyx.media_chunk_gap",
586
+ value: JSON.stringify({
587
+ expected: state.nextInboundMediaChunk,
588
+ actual: lowestBufferedChunk,
589
+ missed: lowestBufferedChunk - state.nextInboundMediaChunk,
590
+ }),
591
+ });
592
+ state.nextInboundMediaChunk = lowestBufferedChunk;
593
+ continue;
594
+ }
595
+ break;
596
+ }
597
+ }
598
+
599
+ function emitTelnyxMediaFrame(
600
+ session: VoiceAgentSession,
601
+ state: TelnyxConnectionState,
602
+ frame: PendingTelnyxMediaFrame,
603
+ inputSampleRateHz: number,
604
+ ): void {
605
+ rememberTelnyxMediaTimestamp(session, state, frame.timestamp, frame.pcm.length, state.inboundSampleRateHz);
606
+ const resampled = resamplePcm16Streaming(state.streamingResamplers, frame.pcm, state.inboundSampleRateHz, inputSampleRateHz);
607
+ session.bus.push(Route.Main, {
608
+ kind: "user.audio_received",
609
+ contextId: state.contextId,
610
+ timestampMs: Date.now(),
611
+ audio: pcm16SamplesToBytes(resampled),
612
+ });
613
+ }
614
+
615
+ function rememberTelnyxMediaTimestamp(
616
+ session: VoiceAgentSession,
617
+ state: TelnyxConnectionState,
618
+ timestampValue: string | undefined,
619
+ sampleCount: number,
620
+ sampleRateHz: number,
621
+ ): void {
622
+ const timestampMs = optionalNonNegativeIntegerString(timestampValue, "Telnyx media.timestamp");
623
+ if (timestampMs === undefined) return;
624
+ const previous = state.lastInboundMediaTimestampMs;
625
+ if (previous !== null && timestampMs < previous) {
626
+ session.bus.push(Route.Background, {
627
+ kind: "metric.conversation",
628
+ contextId: state.contextId,
629
+ timestampMs: Date.now(),
630
+ name: "telnyx.media_timestamp_regression",
631
+ value: JSON.stringify({ previous, actual: timestampMs }),
632
+ });
633
+ } else if (previous !== null) {
634
+ const expected = previous + Math.round((sampleCount / sampleRateHz) * 1000);
635
+ if (timestampMs > expected) {
636
+ session.bus.push(Route.Background, {
637
+ kind: "metric.conversation",
638
+ contextId: state.contextId,
639
+ timestampMs: Date.now(),
640
+ name: "telnyx.media_timestamp_gap",
641
+ value: JSON.stringify({ expected, actual: timestampMs, missedMs: timestampMs - expected }),
642
+ });
643
+ }
644
+ }
645
+ state.lastInboundMediaTimestampMs = timestampMs;
646
+ }
647
+
648
+ function validateTelnyxStart(start: TelnyxStartPayload): { readonly codec: TelnyxCodec; readonly sampleRateHz: number } {
649
+ const format = start.media_format;
650
+ if (!format) throw new Error("Telnyx start event is missing media_format");
651
+ const encoding = format.encoding?.trim().toUpperCase();
652
+ if (encoding !== "PCMU" && encoding !== "L16") {
653
+ throw new Error(`Unsupported Telnyx media encoding: ${format.encoding ?? "unknown"}`);
654
+ }
655
+ const sampleRateHz = numberFromString(format.sample_rate);
656
+ if (encoding === "PCMU" && sampleRateHz !== 8000) throw new Error(`Unsupported Telnyx PCMU sample rate: ${String(format.sample_rate)}`);
657
+ if (encoding === "L16" && sampleRateHz !== 16000) throw new Error(`Unsupported Telnyx L16 sample rate: ${String(format.sample_rate)}`);
658
+ if (sampleRateHz === null) throw new Error(`Unsupported Telnyx sample rate: ${String(format.sample_rate)}`);
659
+ const channels = numberFromString(format.channels);
660
+ if (channels !== 1) throw new Error(`Unsupported Telnyx channel count: ${String(format.channels)}`);
661
+ return { codec: encoding, sampleRateHz };
662
+ }
663
+
664
+ function decodeInboundPayload(input: Uint8Array, codec: TelnyxCodec): Int16Array {
665
+ if (codec === "PCMU") return decodeMuLawToPcm16(input);
666
+ return bigEndianPcm16BytesToSamples(input);
667
+ }
668
+
669
+ function encodeOutboundPayload(
670
+ audio: Uint8Array,
671
+ sourceSampleRateHz: number,
672
+ state: TelnyxConnectionState,
673
+ frameDurationMs: number,
674
+ ): Uint8Array[] {
675
+ const samples = pcm16BytesToSamples(audio);
676
+ const resampled = resamplePcm16Streaming(state.streamingResamplers, samples, sourceSampleRateHz, state.outboundSampleRateHz);
677
+ const encoded = state.outboundCodec === "PCMU"
678
+ ? encodePcm16ToMuLaw(resampled)
679
+ : pcm16SamplesToBigEndianBytes(resampled);
680
+ const frameBytes = Math.max(1, Math.round((state.outboundSampleRateHz * frameDurationMs) / 1000) * (state.outboundCodec === "L16" ? 2 : 1));
681
+ const frames: Uint8Array[] = [];
682
+ for (let offset = 0; offset < encoded.byteLength; offset += frameBytes) {
683
+ frames.push(encoded.subarray(offset, Math.min(encoded.byteLength, offset + frameBytes)));
684
+ }
685
+ return frames;
686
+ }
687
+
688
+ function defaultTelnyxContextId(start: TelnyxStartPayload): string {
689
+ const callControlId = start.call_control_id?.trim();
690
+ if (callControlId) return `telnyx-${callControlId}`;
691
+ const callSessionId = start.call_session_id?.trim();
692
+ if (callSessionId) return `telnyx-${callSessionId}`;
693
+ const streamId = start.stream_id?.trim();
694
+ if (streamId) return `telnyx-${streamId}`;
695
+ return `telnyx-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
696
+ }
697
+
698
+ function sendTelnyxError(socket: WebSocket, streamId: string, message: string, maxBufferedAmountBytes: number): void {
699
+ sendTelnyxJson(socket, {
700
+ event: "error",
701
+ stream_id: streamId || undefined,
702
+ payload: { code: 100003, title: "syrinx_transport_error", detail: message },
703
+ }, maxBufferedAmountBytes);
704
+ }
705
+
706
+ function sendTelnyxJson(socket: WebSocket, value: unknown, maxBufferedAmountBytes: number): boolean {
707
+ return sendJsonCapped(socket, value, maxBufferedAmountBytes);
708
+ }