@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
@@ -0,0 +1,531 @@
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 { Decoder as OpusDecoder, Encoder as OpusEncoder } from "@evan/opus";
7
+ import { Route, type VoiceAgentSession } from "@kuralle-syrinx/core";
8
+ import {
9
+ decodeMuLawToPcm16,
10
+ encodePcm16ToMuLaw,
11
+ pcm16BytesToSamples,
12
+ pcm16SamplesToBytes,
13
+ resamplePcm16Streaming,
14
+ type StreamingPcm16Resampler,
15
+ } from "@kuralle-syrinx/core/audio";
16
+ import { sendJsonCapped } from "./websocket-close.js";
17
+ import {
18
+ optionalRecord,
19
+ optionalString,
20
+ optionalStringOrNumber,
21
+ parseJsonRecord,
22
+ requiredString,
23
+ } from "./json-message.js";
24
+ import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
25
+ import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
26
+ import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
27
+ import {
28
+ decodeStrictBase64,
29
+ nonNegativeInteger,
30
+ numberFromString,
31
+ positiveInteger,
32
+ rawDataToText,
33
+ } from "./transport-helpers.js";
34
+
35
+ export interface SmartPbxMediaStreamServerOptions {
36
+ readonly server?: HttpServer;
37
+ readonly port?: number;
38
+ readonly host?: string;
39
+ readonly path?: string;
40
+ readonly createSession: (request: IncomingMessage) => VoiceAgentSession | Promise<VoiceAgentSession>;
41
+ readonly contextId?: (start: SmartPbxStartPayload) => string;
42
+ readonly inputSampleRateHz?: number;
43
+ readonly outputSampleRateHz?: number;
44
+ readonly outboundFrameDurationMs?: number;
45
+ readonly maxQueuedOutputAudioMs?: number;
46
+ readonly heartbeatIntervalMs?: number;
47
+ readonly startupTimeoutMs?: number;
48
+ readonly maxSessionDurationMs?: number;
49
+ readonly maxBufferedAmountBytes?: number;
50
+ readonly maxInboundMessageBytes?: number;
51
+ readonly maxConcurrentSessions?: number;
52
+ readonly maxConcurrentSessionsScope?: "path" | "server";
53
+ readonly onTransportMetric?: (name: string) => void;
54
+ }
55
+
56
+ export interface SmartPbxMediaStreamServer {
57
+ readonly httpServer: HttpServer;
58
+ readonly wsServer: WebSocketServer;
59
+ address(): ReturnType<HttpServer["address"]>;
60
+ close(opts?: GracefulCloseOptions): Promise<void>;
61
+ }
62
+
63
+ export interface SmartPbxStartPayload {
64
+ readonly callId?: string;
65
+ readonly otherLegCallId?: string;
66
+ readonly callerIdNumber?: string;
67
+ readonly calleeIdNumber?: string;
68
+ readonly accountId?: string;
69
+ readonly mediaFormat?: {
70
+ readonly encoding?: string;
71
+ readonly sampleRate?: number | string;
72
+ };
73
+ }
74
+
75
+ interface SmartPbxMessage {
76
+ readonly event?: string;
77
+ readonly start?: SmartPbxStartPayload;
78
+ readonly media?: { readonly payload?: string };
79
+ readonly dtmf?: { readonly digit?: string };
80
+ readonly digit?: string;
81
+ }
82
+
83
+ type SmartPbxCodec = "g711_ulaw" | "pcm16" | "opus";
84
+
85
+ interface SmartPbxConnectionState {
86
+ callId: string;
87
+ accountId: string;
88
+ contextId: string;
89
+ codec: SmartPbxCodec;
90
+ wireSampleRateHz: number;
91
+ opusDecoder: OpusDecoder | null;
92
+ opusEncoder: OpusEncoder | null;
93
+ opusEncodeRemainder: Int16Array;
94
+ started: boolean;
95
+ stopped: boolean;
96
+ clearPlayout: (reason: string) => void;
97
+ readonly streamingResamplers: Map<string, StreamingPcm16Resampler>;
98
+ }
99
+
100
+ const DEFAULT_ENGINE_SAMPLE_RATE_HZ = 16000;
101
+ const DEFAULT_OUTBOUND_FRAME_DURATION_MS = 20;
102
+ // Bounds queue memory + sanity only. TTS providers legitimately burst entire
103
+ // replies ahead of realtime (Deepgram); the pacer drains at wire rate and
104
+ // barge-in clear is O(1), so a generous cap is safe. A tiny cap turns every
105
+ // long reply into dropped audio.
106
+ const DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS = 60_000;
107
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
108
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
109
+ const DEFAULT_MAX_SESSION_DURATION_MS = 30 * 60_000;
110
+ const DEFAULT_MAX_BUFFERED_AMOUNT_BYTES = 8 * 1024 * 1024;
111
+ const DEFAULT_MAX_INBOUND_MESSAGE_BYTES = 256 * 1024;
112
+
113
+ export async function createSmartPbxMediaStreamServer(
114
+ options: SmartPbxMediaStreamServerOptions,
115
+ ): Promise<SmartPbxMediaStreamServer> {
116
+ const ownsHttpServer = !options.server;
117
+ const httpServer = options.server ?? createServer();
118
+ const routedWebSocket = createRoutedWebSocketServer(httpServer, options.path ?? "/media-stream", {
119
+ maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
120
+ maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
121
+ onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
122
+ });
123
+ const wsServer = routedWebSocket.wsServer;
124
+ const sessions = new Set<VoiceAgentSession>();
125
+ const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? DEFAULT_ENGINE_SAMPLE_RATE_HZ;
126
+ const outboundFrameDurationMs = positiveInteger(options.outboundFrameDurationMs) ?? DEFAULT_OUTBOUND_FRAME_DURATION_MS;
127
+ const maxQueuedOutputAudioMs = positiveInteger(options.maxQueuedOutputAudioMs) ?? DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS;
128
+ const hostConfig: TransportHostConfig = {
129
+ heartbeatIntervalMs: nonNegativeInteger(options.heartbeatIntervalMs) ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
130
+ startupTimeoutMs: nonNegativeInteger(options.startupTimeoutMs) ?? DEFAULT_STARTUP_TIMEOUT_MS,
131
+ maxSessionDurationMs: nonNegativeInteger(options.maxSessionDurationMs) ?? DEFAULT_MAX_SESSION_DURATION_MS,
132
+ maxBufferedAmountBytes: positiveInteger(options.maxBufferedAmountBytes) ?? DEFAULT_MAX_BUFFERED_AMOUNT_BYTES,
133
+ maxInboundMessageBytes: positiveInteger(options.maxInboundMessageBytes) ?? DEFAULT_MAX_INBOUND_MESSAGE_BYTES,
134
+ };
135
+ const contextIdFn = options.contextId ?? defaultSmartPbxContextId;
136
+ const gracefulCloseRegistry = new Map<WebSocket, (deadlineMs: number) => Promise<void>>();
137
+
138
+ const adapter: TransportAdapter<SmartPbxConnectionState> = {
139
+ createState: () => ({
140
+ callId: "",
141
+ accountId: "",
142
+ contextId: "",
143
+ codec: "g711_ulaw",
144
+ wireSampleRateHz: 8000,
145
+ opusDecoder: null,
146
+ opusEncoder: null,
147
+ opusEncodeRemainder: new Int16Array(0),
148
+ started: false,
149
+ stopped: false,
150
+ clearPlayout: () => undefined,
151
+ streamingResamplers: new Map(),
152
+ }),
153
+
154
+ async acquireSession({ request, shouldAbort, onSessionCreated }) {
155
+ const sess = await options.createSession(request);
156
+ onSessionCreated(sess);
157
+ if (shouldAbort()) {
158
+ await sess.close().catch(() => undefined);
159
+ throw new Error("SmartPBX websocket session startup aborted");
160
+ }
161
+ sessions.add(sess);
162
+ await sess.start();
163
+ if (shouldAbort()) {
164
+ sessions.delete(sess);
165
+ await sess.close().catch(() => undefined);
166
+ throw new Error("SmartPBX websocket session startup aborted");
167
+ }
168
+ return { session: sess, resumed: false };
169
+ },
170
+
171
+ wireSession(session, socket, state, disposers) {
172
+ const outbound = wireTelephonyOutboundPipeline({
173
+ session,
174
+ socket,
175
+ disposers,
176
+ outboundFrameDurationMs,
177
+ maxQueuedOutputAudioMs,
178
+ callbacks: {
179
+ carrierLabel: "smartpbx",
180
+ getContextId: () => state.contextId,
181
+ isActive: () => !state.stopped && state.started,
182
+ encodeFrames: (audio, sourceSampleRateHz, contextId) => {
183
+ return encodeOutboundFrames(audio, sourceSampleRateHz, state, outboundFrameDurationMs)
184
+ .map((frame) => ({
185
+ contextId,
186
+ send: () => {
187
+ if (state.stopped) return false;
188
+ return sendSmartPbxJson(socket, {
189
+ event: "media",
190
+ callId: state.callId,
191
+ accountId: state.accountId,
192
+ media: { payload: Buffer.from(frame).toString("base64") },
193
+ }, hostConfig.maxBufferedAmountBytes);
194
+ },
195
+ }));
196
+ },
197
+ onInterrupt: (contextId) => {
198
+ state.opusEncodeRemainder = new Int16Array(0);
199
+ session.bus.push(Route.Background, {
200
+ kind: "metric.conversation",
201
+ contextId,
202
+ timestampMs: Date.now(),
203
+ name: "smartpbx.interrupt_no_playback_clear",
204
+ value: "1",
205
+ });
206
+ },
207
+ onDrain: (contextId, playout, progress) => {
208
+ const pendingFrames = encodePendingOpusFrame(state, outboundFrameDurationMs)
209
+ .map((frame) => ({
210
+ contextId,
211
+ send: () => {
212
+ if (state.stopped) return false;
213
+ return sendSmartPbxJson(socket, {
214
+ event: "media",
215
+ callId: state.callId,
216
+ accountId: state.accountId,
217
+ media: { payload: Buffer.from(frame).toString("base64") },
218
+ }, hostConfig.maxBufferedAmountBytes);
219
+ },
220
+ }));
221
+ playout.enqueue(pendingFrames);
222
+ playout.enqueueControl(() => {
223
+ if (state.stopped) return;
224
+ progress.complete(contextId);
225
+ session.bus.push(Route.Background, {
226
+ kind: "metric.conversation",
227
+ contextId,
228
+ timestampMs: Date.now(),
229
+ name: "smartpbx.playout_drained",
230
+ value: "1",
231
+ });
232
+ });
233
+ },
234
+ onStop: () => {
235
+ state.stopped = true;
236
+ state.opusEncodeRemainder = new Int16Array(0);
237
+ },
238
+ onClear: () => { state.opusEncodeRemainder = new Int16Array(0); },
239
+ },
240
+ });
241
+ state.clearPlayout = outbound.clearPlayout;
242
+ gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
243
+ disposers.push(() => gracefulCloseRegistry.delete(socket));
244
+ return (reason) => {
245
+ state.opusEncodeRemainder = new Int16Array(0);
246
+ state.clearPlayout(reason);
247
+ };
248
+ },
249
+
250
+ processMessage(data, isBinary, session, state) {
251
+ if (isBinary) throw new Error("SmartPBX AI Provider messages must be JSON text frames");
252
+ const message = parseSmartPbxMessage(parseJsonRecord(rawDataToText(data), "SmartPBX AI Provider message"));
253
+
254
+ if (message.event === "connected") return;
255
+ if (message.event === "start") {
256
+ if (state.stopped) throw new Error("SmartPBX start event received after stream stop");
257
+ const start = message.start ?? {};
258
+ const format = validateSmartPbxStart(start);
259
+ state.callId = start.callId ?? "";
260
+ state.accountId = start.accountId ?? "";
261
+ if (!state.callId) throw new Error("SmartPBX start event is missing callId");
262
+ if (!state.accountId) throw new Error("SmartPBX start event is missing accountId");
263
+ state.contextId = contextIdFn(start);
264
+ state.codec = format.codec;
265
+ state.wireSampleRateHz = format.sampleRateHz;
266
+ state.opusDecoder = format.codec === "opus" ? new OpusDecoder({ channels: 1, sample_rate: 48000 }) : null;
267
+ state.opusEncoder = format.codec === "opus" ? new OpusEncoder({ channels: 1, sample_rate: 48000, application: "voip" }) : null;
268
+ state.opusEncodeRemainder = new Int16Array(0);
269
+ state.started = true;
270
+ return;
271
+ }
272
+ if (message.event === "media") {
273
+ if (state.stopped) return;
274
+ if (!state.started || !state.contextId) throw new Error("SmartPBX media event received before a valid start event");
275
+ const payload = message.media?.payload;
276
+ if (!payload) throw new Error("SmartPBX media event is missing media.payload");
277
+ const wire = decodeStrictBase64(payload, "media.payload");
278
+ const decoded = decodeSmartPbxWireAudio(wire, state);
279
+ const resampled = resamplePcm16Streaming(state.streamingResamplers, decoded, state.wireSampleRateHz, inputSampleRateHz);
280
+ session.bus.push(Route.Main, {
281
+ kind: "user.audio_received",
282
+ contextId: state.contextId,
283
+ timestampMs: Date.now(),
284
+ audio: pcm16SamplesToBytes(resampled),
285
+ });
286
+ return;
287
+ }
288
+ if (message.event === "hangup" || message.event === "stop") {
289
+ state.stopped = true;
290
+ state.started = false;
291
+ state.opusEncodeRemainder = new Int16Array(0);
292
+ state.clearPlayout("stop");
293
+ session.close().catch(() => undefined);
294
+ return;
295
+ }
296
+ if (message.event === "dtmf") {
297
+ if (state.stopped) return;
298
+ if (!state.started || !state.contextId) {
299
+ session.bus.push(Route.Background, {
300
+ kind: "metric.conversation",
301
+ contextId: "",
302
+ timestampMs: Date.now(),
303
+ name: "smartpbx.dtmf.before_start",
304
+ value: message.dtmf?.digit ?? message.digit ?? "",
305
+ });
306
+ return;
307
+ }
308
+ const rawDigit = message.dtmf?.digit ?? message.digit ?? "";
309
+ const digit = parseDtmfDigit(rawDigit);
310
+ if (!digit) {
311
+ session.bus.push(Route.Background, {
312
+ kind: "metric.conversation",
313
+ contextId: state.contextId,
314
+ timestampMs: Date.now(),
315
+ name: "dtmf.unparsed",
316
+ value: rawDigit,
317
+ });
318
+ return;
319
+ }
320
+ session.bus.push(Route.Critical, {
321
+ kind: "dtmf.received",
322
+ contextId: state.contextId,
323
+ timestampMs: Date.now(),
324
+ digit,
325
+ provider: "smartpbx",
326
+ rawDigit,
327
+ });
328
+ return;
329
+ }
330
+ throw new Error(`Unsupported SmartPBX AI Provider event: ${String(message.event)}`);
331
+ },
332
+
333
+ onDisconnect(session) {
334
+ sessions.delete(session);
335
+ void session.close().catch(() => undefined);
336
+ },
337
+
338
+ onStartupTimeout(_state, session) {
339
+ sessions.delete(session);
340
+ void session.close().catch(() => undefined);
341
+ },
342
+
343
+ sendError(socket, state, message) {
344
+ sendSmartPbxError(socket, state, message, hostConfig.maxBufferedAmountBytes);
345
+ },
346
+
347
+ sendStartupError(socket, state, err) {
348
+ sendSmartPbxError(socket, state, err instanceof Error ? err.message : String(err), hostConfig.maxBufferedAmountBytes);
349
+ },
350
+ };
351
+
352
+ wsServer.on("connection", (socket, request) => {
353
+ void runWebSocketConnection(socket, request, hostConfig, adapter);
354
+ });
355
+
356
+ if (ownsHttpServer || typeof options.port === "number") {
357
+ await new Promise<void>((resolveListen, reject) => {
358
+ httpServer.once("error", reject);
359
+ httpServer.listen(options.port ?? 0, options.host, () => {
360
+ httpServer.off("error", reject);
361
+ resolveListen();
362
+ });
363
+ });
364
+ }
365
+
366
+ let closing = false;
367
+ return {
368
+ httpServer,
369
+ wsServer,
370
+ address: () => httpServer.address(),
371
+ close: async (opts) => {
372
+ if (closing) return;
373
+ closing = true;
374
+ if (opts?.graceful === true && gracefulCloseRegistry.size > 0) {
375
+ const deadline = Date.now() + (opts.drainDeadlineMs ?? 10_000);
376
+ await Promise.allSettled(
377
+ [...gracefulCloseRegistry.values()].map((fn) => fn(deadline)),
378
+ );
379
+ for (const client of wsServer.clients) {
380
+ if (client.readyState === WebSocket.OPEN) client.terminate();
381
+ }
382
+ } else {
383
+ for (const client of wsServer.clients) client.terminate();
384
+ }
385
+ gracefulCloseRegistry.clear();
386
+ for (const session of sessions) await session.close().catch(() => undefined);
387
+ await new Promise<void>((resolveClose) => { wsServer.close(() => resolveClose()); });
388
+ routedWebSocket.detach();
389
+ if (ownsHttpServer || typeof options.port === "number") {
390
+ await new Promise<void>((resolveClose) => { httpServer.close(() => resolveClose()); });
391
+ }
392
+ },
393
+ };
394
+ }
395
+
396
+ type SmartPbxDtmfDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "*" | "#";
397
+
398
+ const DTMF_DIGITS = new Set<SmartPbxDtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
399
+
400
+ function parseDtmfDigit(raw: string): SmartPbxDtmfDigit | null {
401
+ const trimmed = raw.trim();
402
+ if (trimmed.length !== 1 || !DTMF_DIGITS.has(trimmed as SmartPbxDtmfDigit)) return null;
403
+ return trimmed as SmartPbxDtmfDigit;
404
+ }
405
+
406
+ function parseSmartPbxMessage(value: Record<string, unknown>): SmartPbxMessage {
407
+ const start = optionalRecord(value.start, "SmartPBX start");
408
+ const media = optionalRecord(value.media, "SmartPBX media");
409
+ const dtmf = optionalRecord(value.dtmf, "SmartPBX dtmf");
410
+ const mediaFormat = optionalRecord(start?.mediaFormat, "SmartPBX start.mediaFormat");
411
+ return {
412
+ event: requiredString(value.event, "SmartPBX event"),
413
+ digit: optionalString(value.digit, "SmartPBX digit"),
414
+ start: start
415
+ ? {
416
+ callId: optionalString(start.callId, "SmartPBX start.callId"),
417
+ otherLegCallId: optionalString(start.otherLegCallId, "SmartPBX start.otherLegCallId"),
418
+ callerIdNumber: optionalString(start.callerIdNumber, "SmartPBX start.callerIdNumber"),
419
+ calleeIdNumber: optionalString(start.calleeIdNumber, "SmartPBX start.calleeIdNumber"),
420
+ accountId: optionalString(start.accountId, "SmartPBX start.accountId"),
421
+ mediaFormat: mediaFormat
422
+ ? {
423
+ encoding: optionalString(mediaFormat.encoding, "SmartPBX start.mediaFormat.encoding"),
424
+ sampleRate: optionalStringOrNumber(mediaFormat.sampleRate, "SmartPBX start.mediaFormat.sampleRate"),
425
+ }
426
+ : undefined,
427
+ }
428
+ : undefined,
429
+ media: media ? { payload: optionalString(media.payload, "SmartPBX media.payload") } : undefined,
430
+ dtmf: dtmf ? { digit: optionalString(dtmf.digit, "SmartPBX dtmf.digit") } : undefined,
431
+ };
432
+ }
433
+
434
+ function validateSmartPbxStart(start: SmartPbxStartPayload): { readonly codec: SmartPbxCodec; readonly sampleRateHz: number } {
435
+ const format = start.mediaFormat;
436
+ if (!format) throw new Error("SmartPBX start event is missing mediaFormat");
437
+ const encoding = format.encoding?.trim().toLowerCase();
438
+ const sampleRateHz = numberFromString(format.sampleRate);
439
+ if (encoding !== "g711_ulaw" && encoding !== "pcm16" && encoding !== "opus") {
440
+ throw new Error(`Unsupported SmartPBX media encoding: ${format.encoding ?? "unknown"}`);
441
+ }
442
+ if (encoding === "g711_ulaw" && sampleRateHz !== 8000) throw new Error(`Unsupported SmartPBX g711_ulaw sample rate: ${String(format.sampleRate)}`);
443
+ if (encoding === "pcm16" && sampleRateHz !== 24000) throw new Error(`Unsupported SmartPBX pcm16 sample rate: ${String(format.sampleRate)}`);
444
+ if (encoding === "opus" && sampleRateHz !== 48000) throw new Error(`Unsupported SmartPBX opus sample rate: ${String(format.sampleRate)}`);
445
+ return { codec: encoding, sampleRateHz: sampleRateHz! };
446
+ }
447
+
448
+ function decodeSmartPbxWireAudio(wire: Uint8Array, state: SmartPbxConnectionState): Int16Array {
449
+ if (state.codec === "g711_ulaw") return decodeMuLawToPcm16(wire);
450
+ if (state.codec === "pcm16") return pcm16BytesToSamples(wire);
451
+ if (!state.opusDecoder) throw new Error("SmartPBX opus decoder is not initialized");
452
+ return pcm16BytesToSamples(state.opusDecoder.decode(wire));
453
+ }
454
+
455
+ function encodeOutboundFrames(
456
+ audio: Uint8Array,
457
+ sourceSampleRateHz: number,
458
+ state: SmartPbxConnectionState,
459
+ frameDurationMs: number,
460
+ ): Uint8Array[] {
461
+ const samples = pcm16BytesToSamples(audio);
462
+ const resampled = resamplePcm16Streaming(state.streamingResamplers, samples, sourceSampleRateHz, state.wireSampleRateHz);
463
+ if (state.codec === "opus") return encodeOpusFrames(resampled, state, frameDurationMs, false);
464
+ const encoded = state.codec === "g711_ulaw" ? encodePcm16ToMuLaw(resampled) : pcm16SamplesToBytes(resampled);
465
+ const bytesPerSample = state.codec === "g711_ulaw" ? 1 : 2;
466
+ const frameBytes = Math.max(1, Math.round((state.wireSampleRateHz * frameDurationMs) / 1000) * bytesPerSample);
467
+ const frames: Uint8Array[] = [];
468
+ for (let offset = 0; offset < encoded.byteLength; offset += frameBytes) {
469
+ frames.push(encoded.subarray(offset, Math.min(encoded.byteLength, offset + frameBytes)));
470
+ }
471
+ return frames;
472
+ }
473
+
474
+ function encodePendingOpusFrame(state: SmartPbxConnectionState, frameDurationMs: number): Uint8Array[] {
475
+ if (state.codec !== "opus" || state.opusEncodeRemainder.length === 0) return [];
476
+ return encodeOpusFrames(new Int16Array(0), state, frameDurationMs, true);
477
+ }
478
+
479
+ function encodeOpusFrames(
480
+ samples: Int16Array,
481
+ state: SmartPbxConnectionState,
482
+ frameDurationMs: number,
483
+ flush: boolean,
484
+ ): Uint8Array[] {
485
+ if (!state.opusEncoder) throw new Error("SmartPBX opus encoder is not initialized");
486
+ const frameSamples = Math.round((state.wireSampleRateHz * frameDurationMs) / 1000);
487
+ const pending = new Int16Array(state.opusEncodeRemainder.length + samples.length);
488
+ pending.set(state.opusEncodeRemainder);
489
+ pending.set(samples, state.opusEncodeRemainder.length);
490
+ const completeFrames = Math.floor(pending.length / frameSamples);
491
+ const frames: Uint8Array[] = [];
492
+ for (let index = 0; index < completeFrames; index += 1) {
493
+ const frame = pending.subarray(index * frameSamples, (index + 1) * frameSamples);
494
+ frames.push(state.opusEncoder.encode(pcm16SamplesToBytes(frame)));
495
+ }
496
+ const consumed = completeFrames * frameSamples;
497
+ const remainder = pending.subarray(consumed);
498
+ if (flush && remainder.length > 0) {
499
+ const padded = new Int16Array(frameSamples);
500
+ padded.set(remainder);
501
+ frames.push(state.opusEncoder.encode(pcm16SamplesToBytes(padded)));
502
+ state.opusEncodeRemainder = new Int16Array(0);
503
+ } else {
504
+ state.opusEncodeRemainder = new Int16Array(remainder);
505
+ }
506
+ return frames;
507
+ }
508
+
509
+ function defaultSmartPbxContextId(start: SmartPbxStartPayload): string {
510
+ const callId = start.callId?.trim();
511
+ if (callId) return `smartpbx-${callId}`;
512
+ return `smartpbx-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
513
+ }
514
+
515
+ function sendSmartPbxError(
516
+ socket: WebSocket,
517
+ state: SmartPbxConnectionState,
518
+ message: string,
519
+ maxBufferedAmountBytes: number,
520
+ ): void {
521
+ sendSmartPbxJson(socket, {
522
+ event: "syrinx_error",
523
+ callId: state.callId || undefined,
524
+ accountId: state.accountId || undefined,
525
+ error: { component: "transport", category: "invalid_input", message },
526
+ }, maxBufferedAmountBytes);
527
+ }
528
+
529
+ function sendSmartPbxJson(socket: WebSocket, value: unknown, maxBufferedAmountBytes: number): boolean {
530
+ return sendJsonCapped(socket, value, maxBufferedAmountBytes);
531
+ }