@kuralle-syrinx/server-websocket 4.4.1 → 4.5.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 (132) hide show
  1. package/dist/backchannel-cue-fixtures.d.ts +4 -0
  2. package/dist/backchannel-cue-fixtures.d.ts.map +1 -0
  3. package/dist/backchannel-cue-fixtures.js +25 -0
  4. package/dist/backchannel-cue-fixtures.js.map +1 -0
  5. package/dist/background-audio.d.ts +59 -0
  6. package/dist/background-audio.d.ts.map +1 -0
  7. package/dist/background-audio.js +248 -0
  8. package/dist/background-audio.js.map +1 -0
  9. package/dist/browser-opus.d.ts +12 -0
  10. package/dist/browser-opus.d.ts.map +1 -0
  11. package/dist/browser-opus.js +48 -0
  12. package/dist/browser-opus.js.map +1 -0
  13. package/dist/carrier-commands.d.ts +124 -0
  14. package/dist/carrier-commands.d.ts.map +1 -0
  15. package/dist/carrier-commands.js +193 -0
  16. package/dist/carrier-commands.js.map +1 -0
  17. package/dist/edge-telnyx.d.ts +37 -0
  18. package/dist/edge-telnyx.d.ts.map +1 -0
  19. package/dist/edge-telnyx.js +320 -0
  20. package/dist/edge-telnyx.js.map +1 -0
  21. package/dist/edge-twilio.d.ts +31 -0
  22. package/dist/edge-twilio.d.ts.map +1 -0
  23. package/dist/edge-twilio.js +286 -0
  24. package/dist/edge-twilio.js.map +1 -0
  25. package/dist/edge.d.ts +71 -0
  26. package/dist/edge.d.ts.map +1 -0
  27. package/dist/edge.js +560 -0
  28. package/dist/edge.js.map +1 -0
  29. package/dist/inbound-audio.d.ts +13 -0
  30. package/dist/inbound-audio.d.ts.map +1 -0
  31. package/dist/inbound-audio.js +66 -0
  32. package/dist/inbound-audio.js.map +1 -0
  33. package/dist/index.d.ts +81 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +718 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/json-message.d.ts +7 -0
  38. package/dist/json-message.d.ts.map +1 -0
  39. package/dist/json-message.js +41 -0
  40. package/dist/json-message.js.map +1 -0
  41. package/dist/outbound-playout-pipeline.d.ts +53 -0
  42. package/dist/outbound-playout-pipeline.d.ts.map +1 -0
  43. package/dist/outbound-playout-pipeline.js +148 -0
  44. package/dist/outbound-playout-pipeline.js.map +1 -0
  45. package/dist/paced-playout.d.ts +42 -0
  46. package/dist/paced-playout.d.ts.map +1 -0
  47. package/dist/paced-playout.js +151 -0
  48. package/dist/paced-playout.js.map +1 -0
  49. package/dist/playout-progress.d.ts +26 -0
  50. package/dist/playout-progress.d.ts.map +1 -0
  51. package/dist/playout-progress.js +64 -0
  52. package/dist/playout-progress.js.map +1 -0
  53. package/dist/session-store.d.ts +39 -0
  54. package/dist/session-store.d.ts.map +1 -0
  55. package/dist/session-store.js +80 -0
  56. package/dist/session-store.js.map +1 -0
  57. package/dist/smartpbx.d.ts +47 -0
  58. package/dist/smartpbx.d.ts.map +1 -0
  59. package/dist/smartpbx.js +437 -0
  60. package/dist/smartpbx.js.map +1 -0
  61. package/dist/telnyx-codec.d.ts +41 -0
  62. package/dist/telnyx-codec.d.ts.map +1 -0
  63. package/dist/telnyx-codec.js +114 -0
  64. package/dist/telnyx-codec.js.map +1 -0
  65. package/dist/telnyx.d.ts +52 -0
  66. package/dist/telnyx.d.ts.map +1 -0
  67. package/dist/telnyx.js +550 -0
  68. package/dist/telnyx.js.map +1 -0
  69. package/dist/test-helpers.d.ts +20 -0
  70. package/dist/test-helpers.d.ts.map +1 -0
  71. package/dist/test-helpers.js +242 -0
  72. package/dist/test-helpers.js.map +1 -0
  73. package/dist/transport-helpers.d.ts +12 -0
  74. package/dist/transport-helpers.d.ts.map +1 -0
  75. package/dist/transport-helpers.js +90 -0
  76. package/dist/transport-helpers.js.map +1 -0
  77. package/dist/transport-host.d.ts +56 -0
  78. package/dist/transport-host.d.ts.map +1 -0
  79. package/dist/transport-host.js +110 -0
  80. package/dist/transport-host.js.map +1 -0
  81. package/dist/turn-metrics.d.ts +79 -0
  82. package/dist/turn-metrics.d.ts.map +1 -0
  83. package/dist/turn-metrics.js +208 -0
  84. package/dist/turn-metrics.js.map +1 -0
  85. package/dist/twilio-auth.d.ts +12 -0
  86. package/dist/twilio-auth.d.ts.map +1 -0
  87. package/dist/twilio-auth.js +44 -0
  88. package/dist/twilio-auth.js.map +1 -0
  89. package/dist/twilio.d.ts +56 -0
  90. package/dist/twilio.d.ts.map +1 -0
  91. package/dist/twilio.js +511 -0
  92. package/dist/twilio.js.map +1 -0
  93. package/dist/websocket-close.d.ts +12 -0
  94. package/dist/websocket-close.d.ts.map +1 -0
  95. package/dist/websocket-close.js +58 -0
  96. package/dist/websocket-close.js.map +1 -0
  97. package/dist/websocket-lifecycle.d.ts +29 -0
  98. package/dist/websocket-lifecycle.d.ts.map +1 -0
  99. package/dist/websocket-lifecycle.js +87 -0
  100. package/dist/websocket-lifecycle.js.map +1 -0
  101. package/dist/websocket-upgrade.d.ts +9 -0
  102. package/dist/websocket-upgrade.d.ts.map +1 -0
  103. package/dist/websocket-upgrade.js +91 -0
  104. package/dist/websocket-upgrade.js.map +1 -0
  105. package/dist/wire-carrier-control.d.ts +27 -0
  106. package/dist/wire-carrier-control.d.ts.map +1 -0
  107. package/dist/wire-carrier-control.js +116 -0
  108. package/dist/wire-carrier-control.js.map +1 -0
  109. package/package.json +32 -11
  110. package/src/admission-control.test.ts +270 -0
  111. package/src/background-audio.test.ts +224 -0
  112. package/src/browser-opus.test.ts +41 -0
  113. package/src/browser-pacing.test.ts +440 -0
  114. package/src/carrier-commands.test.ts +198 -0
  115. package/src/edge-telnyx.test.ts +333 -0
  116. package/src/edge-twilio.test.ts +293 -0
  117. package/src/edge.test.ts +727 -0
  118. package/src/graceful-drain.test.ts +306 -0
  119. package/src/inbound-audio.test.ts +58 -0
  120. package/src/index.test.ts +2078 -0
  121. package/src/outbound-playout-pipeline.test.ts +314 -0
  122. package/src/paced-playout.test.ts +247 -0
  123. package/src/playout-progress.test.ts +56 -0
  124. package/src/session-store.test.ts +274 -0
  125. package/src/smartpbx.test.ts +986 -0
  126. package/src/telnyx.test.ts +1633 -0
  127. package/src/transport-host.test.ts +51 -0
  128. package/src/turn-metrics.test.ts +503 -0
  129. package/src/twilio-auth.test.ts +36 -0
  130. package/src/twilio.test.ts +1274 -0
  131. package/src/websocket-close.test.ts +63 -0
  132. package/src/websocket-lifecycle.test.ts +49 -0
package/dist/index.js ADDED
@@ -0,0 +1,718 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { createServer } from "node:http";
3
+ import { WebSocket } from "ws";
4
+ import { Route, SYRINX_AUDIO_ENVELOPE_NAME, encodeSyrinxAudioEnvelope, } from "@kuralle-syrinx/core";
5
+ import { pcm16BytesToSamples, resamplePcm16Streaming, } from "@kuralle-syrinx/core/audio";
6
+ import { BROWSER_OPUS_FRAME_DURATION_MS, BROWSER_OPUS_SAMPLE_RATE_HZ, createBrowserOpusCodec, decodeBrowserOpusToPcm16Bytes, } from "./browser-opus.js";
7
+ import { closeWebSocketWithFallback, waitForWebSocketClose } from "./websocket-close.js";
8
+ import { isRecord, parseJsonRecord, optionalString, requiredString } from "./json-message.js";
9
+ import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
10
+ import { runWebSocketConnection, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
11
+ import { BackgroundAudioMixer } from "./background-audio.js";
12
+ import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
13
+ import { TurnMetricsTracker } from "./turn-metrics.js";
14
+ import { InMemorySessionStore, } from "./session-store.js";
15
+ import { decodeStrictBase64, nonNegativeInteger, positiveInteger, rawDataToText, } from "./transport-helpers.js";
16
+ import { decodeInboundBinaryAudio, rememberContextSampleRate, resampleAudioBytes, } from "./inbound-audio.js";
17
+ export * from "./twilio.js";
18
+ export * from "./telnyx.js";
19
+ export * from "./smartpbx.js";
20
+ export * from "./session-store.js";
21
+ export { validateTwilioSignature } from "./twilio-auth.js";
22
+ export { buildTwilioSendDigits, buildTelnyxSendDtmf, buildTwilioTransfer, buildTelnyxTransfer, dispatchTwilioCommand, dispatchTelnyxCommand, resolveTransferSummary, } from "./carrier-commands.js";
23
+ export { wireCarrierControl } from "./wire-carrier-control.js";
24
+ export { BackgroundAudioMixer, wireBackgroundThinking, wireBackgroundBackchannel, wireBackgroundAudio, } from "./background-audio.js";
25
+ export { buildPlaceholderBackchannelCues } from "./backchannel-cue-fixtures.js";
26
+ export { installGracefulShutdown } from "./websocket-lifecycle.js";
27
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
28
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
29
+ const DEFAULT_MAX_SESSION_DURATION_MS = 30 * 60_000;
30
+ const DEFAULT_MAX_BUFFERED_AMOUNT_BYTES = 8 * 1024 * 1024;
31
+ const DEFAULT_MAX_INBOUND_MESSAGE_BYTES = 2 * 1024 * 1024;
32
+ const DEFAULT_RESUME_WINDOW_MS = 15_000;
33
+ const DEFAULT_OUTBOUND_FRAME_DURATION_MS = 20;
34
+ // Bounds queue memory + sanity only — interrupt/clear keeps barge-in instant
35
+ // regardless of queue depth, and TTS providers burst whole replies ahead of
36
+ // realtime. A tiny cap silently drops the tail of every long reply.
37
+ const DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS = 60_000;
38
+ export async function createVoiceWebSocketServer(options) {
39
+ const ownsHttpServer = !options.server;
40
+ const httpServer = options.server ?? createServer();
41
+ const routedWebSocket = createRoutedWebSocketServer(httpServer, options.path ?? "/ws", {
42
+ maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
43
+ maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
44
+ onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
45
+ authorize: options.authorize,
46
+ });
47
+ const wsServer = routedWebSocket.wsServer;
48
+ const sessionStore = options.sessionStore ?? new InMemorySessionStore();
49
+ const contextIdFn = options.contextId ?? defaultContextId;
50
+ const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? 16000;
51
+ const outputSampleRateHz = positiveInteger(options.outputSampleRateHz) ?? 16000;
52
+ const rawBinaryInput = options.rawBinaryInput ?? false;
53
+ const binaryAudioEnvelope = options.binaryAudioEnvelope ?? true;
54
+ const browserOpusDownlink = options.browserOpusDownlink ?? true;
55
+ const resumeWindowMs = nonNegativeInteger(options.resumeWindowMs) ?? DEFAULT_RESUME_WINDOW_MS;
56
+ const outboundFrameDurationMs = positiveInteger(options.outboundFrameDurationMs) ?? DEFAULT_OUTBOUND_FRAME_DURATION_MS;
57
+ const maxQueuedOutputAudioMs = positiveInteger(options.maxQueuedOutputAudioMs) ?? DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS;
58
+ const hostConfig = {
59
+ heartbeatIntervalMs: nonNegativeInteger(options.heartbeatIntervalMs) ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
60
+ startupTimeoutMs: nonNegativeInteger(options.startupTimeoutMs) ?? DEFAULT_STARTUP_TIMEOUT_MS,
61
+ maxSessionDurationMs: nonNegativeInteger(options.maxSessionDurationMs) ?? DEFAULT_MAX_SESSION_DURATION_MS,
62
+ maxBufferedAmountBytes: positiveInteger(options.maxBufferedAmountBytes) ?? DEFAULT_MAX_BUFFERED_AMOUNT_BYTES,
63
+ maxInboundMessageBytes: positiveInteger(options.maxInboundMessageBytes) ?? DEFAULT_MAX_INBOUND_MESSAGE_BYTES,
64
+ };
65
+ const gracefulCloseRegistry = new Map();
66
+ const adapter = {
67
+ createState: () => ({
68
+ managed: null,
69
+ initialContextId: contextIdFn(),
70
+ outboundHandle: null,
71
+ opusCodec: null,
72
+ browserOpusDownlink: browserOpusDownlink,
73
+ streamingResamplers: new Map(),
74
+ }),
75
+ async acquireSession({ request, state, shouldAbort, onSessionCreated }) {
76
+ // An explicit resolver is authoritative. Hosts such as cf-agents resolve the
77
+ // id before creating the session so the reasoner, session store, and transport
78
+ // all use the same identity even when the request carries a different wire id.
79
+ const requestedSessionId = sanitizeSessionId(options.sessionId?.(request) ?? sessionIdFromRequest(request) ?? defaultSessionId());
80
+ const leased = await sessionStore.lease(requestedSessionId, async () => {
81
+ const sess = await options.createSession(request);
82
+ onSessionCreated(sess);
83
+ if (shouldAbort()) {
84
+ await sess.close().catch(() => undefined);
85
+ throw new Error("websocket session startup aborted");
86
+ }
87
+ await sess.start();
88
+ if (shouldAbort()) {
89
+ await sess.close().catch(() => undefined);
90
+ throw new Error("websocket session startup aborted");
91
+ }
92
+ return {
93
+ id: requestedSessionId,
94
+ session: sess,
95
+ currentContextId: state.initialContextId,
96
+ contextSampleRates: new Map(),
97
+ inputSequence: { lastSequence: null },
98
+ turnMetricsTurns: new Map(),
99
+ closeTimer: null,
100
+ connectionCount: 1,
101
+ };
102
+ });
103
+ state.managed = leased.managed;
104
+ return { session: leased.managed.session, resumed: leased.resumed };
105
+ },
106
+ wireSession(session, socket, state, disposers) {
107
+ const managed = state.managed;
108
+ if (!managed)
109
+ throw new Error("websocket session missing managed state");
110
+ state.opusCodec = createBrowserOpusCodec(BROWSER_OPUS_SAMPLE_RATE_HZ);
111
+ state.outboundHandle = wireBrowserSessionEvents(session, socket, disposers, outputSampleRateHz, binaryAudioEnvelope, hostConfig.maxBufferedAmountBytes, outboundFrameDurationMs, maxQueuedOutputAudioMs, state.opusCodec, inputSampleRateHz, () => state.browserOpusDownlink, managed.turnMetricsTurns, state.streamingResamplers, options.backgroundAudio ? new BackgroundAudioMixer(options.backgroundAudio) : undefined);
112
+ gracefulCloseRegistry.set(socket, (deadlineMs) => {
113
+ if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) {
114
+ return Promise.resolve();
115
+ }
116
+ if (state.outboundHandle) {
117
+ return state.outboundHandle.drainAndClose(socket, deadlineMs);
118
+ }
119
+ return new Promise((resolve) => {
120
+ let settled = false;
121
+ const settle = () => { if (!settled) {
122
+ settled = true;
123
+ resolve();
124
+ } };
125
+ const deadlineTimer = setTimeout(() => {
126
+ if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
127
+ socket.terminate();
128
+ }
129
+ settle();
130
+ }, Math.max(0, deadlineMs - Date.now()));
131
+ deadlineTimer.unref?.();
132
+ socket.once("close", () => { clearTimeout(deadlineTimer); settle(); });
133
+ closeWebSocketWithFallback(socket, 1001, "server going away");
134
+ });
135
+ });
136
+ disposers.push(() => gracefulCloseRegistry.delete(socket));
137
+ return () => undefined;
138
+ },
139
+ processMessage(data, isBinary, session, state) {
140
+ if (!state.managed)
141
+ return;
142
+ const managed = state.managed;
143
+ sessionStore.update(managed.id, (stored) => {
144
+ stored.currentContextId = handleClientMessage(session, data, isBinary, stored.currentContextId, contextIdFn, inputSampleRateHz, rawBinaryInput, stored.contextSampleRates, stored.inputSequence, state.opusCodec, inputSampleRateHz, state, state.streamingResamplers);
145
+ });
146
+ },
147
+ onDisconnect(_session, state, { maxSessionTimedOut }) {
148
+ if (state.managed) {
149
+ state.managed.connectionCount = Math.max(0, state.managed.connectionCount - 1);
150
+ void sessionStore.release(state.managed.id, maxSessionTimedOut ? 0 : resumeWindowMs);
151
+ }
152
+ },
153
+ onStartupTimeout(_state, session) {
154
+ void session.close().catch(() => undefined);
155
+ },
156
+ sendReady(session, socket, state, resumed, config) {
157
+ const managed = state.managed;
158
+ if (!managed)
159
+ return;
160
+ sendJson(socket, {
161
+ type: "ready",
162
+ sessionId: managed.id,
163
+ turnId: managed.currentContextId,
164
+ resumed,
165
+ resumeWindowMs,
166
+ maxSessionDurationMs: config.maxSessionDurationMs,
167
+ audio: {
168
+ inputSampleRateHz,
169
+ outputSampleRateHz,
170
+ encoding: "opus",
171
+ supportedInputCodecs: ["pcm_s16le", "opus"],
172
+ channels: 1,
173
+ targetFrameDurationMs: outboundFrameDurationMs,
174
+ binaryEnvelope: binaryAudioEnvelope ? SYRINX_AUDIO_ENVELOPE_NAME : undefined,
175
+ rawBinaryInput,
176
+ maxInboundMessageBytes: config.maxInboundMessageBytes,
177
+ },
178
+ }, config.maxBufferedAmountBytes);
179
+ },
180
+ sendError(socket, _state, message) {
181
+ sendJson(socket, {
182
+ type: "error",
183
+ component: "transport",
184
+ category: "invalid_input",
185
+ message,
186
+ }, hostConfig.maxBufferedAmountBytes);
187
+ },
188
+ sendStartupError(socket, _state, err, isTimeout) {
189
+ sendJson(socket, {
190
+ type: "error",
191
+ component: "session",
192
+ category: isTimeout ? "startup_timeout" : "initialization",
193
+ message: err instanceof Error ? err.message : String(err),
194
+ }, hostConfig.maxBufferedAmountBytes);
195
+ },
196
+ onMaxSessionTimeout(socket, _state) {
197
+ sendJson(socket, {
198
+ type: "error",
199
+ component: "transport",
200
+ category: "session_timeout",
201
+ message: "Websocket max session duration exceeded",
202
+ }, hostConfig.maxBufferedAmountBytes);
203
+ },
204
+ };
205
+ wsServer.on("connection", (socket, request) => {
206
+ void runWebSocketConnection(socket, request, hostConfig, adapter);
207
+ });
208
+ if (ownsHttpServer || typeof options.port === "number") {
209
+ await new Promise((resolveListen, reject) => {
210
+ httpServer.once("error", reject);
211
+ httpServer.listen(options.port ?? 0, options.host, () => {
212
+ httpServer.off("error", reject);
213
+ resolveListen();
214
+ });
215
+ });
216
+ }
217
+ let closing = false;
218
+ return {
219
+ httpServer,
220
+ wsServer,
221
+ address: () => httpServer.address(),
222
+ close: async (opts) => {
223
+ if (closing)
224
+ return;
225
+ closing = true;
226
+ if (opts?.graceful === true && gracefulCloseRegistry.size > 0) {
227
+ const deadline = Date.now() + (opts.drainDeadlineMs ?? 10_000);
228
+ await Promise.allSettled([...gracefulCloseRegistry.values()].map((fn) => fn(deadline)));
229
+ await Promise.allSettled([...wsServer.clients].map((client) => waitForWebSocketClose(client, 250)));
230
+ }
231
+ else {
232
+ for (const client of wsServer.clients)
233
+ client.terminate();
234
+ }
235
+ gracefulCloseRegistry.clear();
236
+ for (const managed of await sessionStore.listAll()) {
237
+ if (managed.closeTimer)
238
+ clearTimeout(managed.closeTimer);
239
+ await managed.session.close().catch(() => undefined);
240
+ }
241
+ await sessionStore.clear();
242
+ // Force-terminate any remaining sockets before wsServer.close() so the
243
+ // close event fires promptly even if a graceful handshake stalled.
244
+ for (const client of wsServer.clients)
245
+ client.terminate();
246
+ // Yield one macrotask tick so pending net.Socket close events (from terminate()
247
+ // or WS close handshake) are processed before wsServer.close() checks clients.
248
+ await new Promise((res) => setTimeout(res, 0));
249
+ await new Promise((resolveClose) => { wsServer.close(() => resolveClose()); });
250
+ routedWebSocket.detach();
251
+ if (ownsHttpServer || typeof options.port === "number") {
252
+ await new Promise((resolveClose) => { httpServer.close(() => resolveClose()); });
253
+ }
254
+ },
255
+ };
256
+ }
257
+ function wireBrowserSessionEvents(session, socket, disposers, outputSampleRateHz, binaryAudioEnvelope, maxBufferedAmountBytes, outboundFrameDurationMs, maxQueuedOutputAudioMs, opusCodec, engineInputSampleRateHz, getBrowserOpusDownlink, turnMetricsTurns, streamingResamplers, backgroundAudio) {
258
+ const ttsSequences = new Map();
259
+ const speechEndedSent = new Set();
260
+ let currentContextId = "";
261
+ const onSession = (event, handler) => {
262
+ session.on(event, handler);
263
+ disposers.push(() => session.off(event, handler));
264
+ };
265
+ onSession("user_started_speaking", (event) => {
266
+ sendJson(socket, { type: "speech_started", turnId: event.turnId }, maxBufferedAmountBytes);
267
+ });
268
+ onSession("user_stopped_speaking", (event) => {
269
+ speechEndedSent.add(event.turnId);
270
+ sendJson(socket, { type: "speech_ended", turnId: event.turnId }, maxBufferedAmountBytes);
271
+ });
272
+ onSession("user_input_partial", (event) => {
273
+ sendJson(socket, { type: "stt_chunk", turnId: event.turnId, transcript: event.text }, maxBufferedAmountBytes);
274
+ });
275
+ onSession("user_input_final", (event) => {
276
+ if (!speechEndedSent.has(event.turnId)) {
277
+ speechEndedSent.add(event.turnId);
278
+ sendJson(socket, { type: "speech_ended", turnId: event.turnId }, maxBufferedAmountBytes);
279
+ }
280
+ sendJson(socket, { type: "stt_output", turnId: event.turnId, transcript: event.text, confidence: event.confidence }, maxBufferedAmountBytes);
281
+ });
282
+ disposers.push(session.bus.on("eos.turn_complete", (pkt) => {
283
+ const turn = pkt;
284
+ sendJson(socket, { type: "turn_complete", turnId: turn.contextId, transcript: turn.text ?? "" }, maxBufferedAmountBytes);
285
+ }));
286
+ onSession("agent_text_delta", (event) => {
287
+ sendJson(socket, { type: "agent_chunk", turnId: event.turnId, text: event.delta }, maxBufferedAmountBytes);
288
+ });
289
+ onSession("agent_tool_call", (event) => {
290
+ sendJson(socket, { type: "agent_tool_call", turnId: event.turnId, id: event.id, name: event.name, args: event.args }, maxBufferedAmountBytes);
291
+ });
292
+ onSession("agent_tool_result", (event) => {
293
+ sendJson(socket, { type: "agent_tool_result", turnId: event.turnId, id: event.id, result: event.result }, maxBufferedAmountBytes);
294
+ });
295
+ // G3: typed preamble/filler lifecycle — same wire shape as the edge transport.
296
+ onSession("tool_call_cue", (event) => {
297
+ sendJson(socket, {
298
+ type: `tool_call_${event.phase}`,
299
+ turnId: event.turnId,
300
+ toolId: event.toolId,
301
+ toolName: event.toolName,
302
+ ...(event.afterMs !== undefined ? { afterMs: event.afterMs } : {}),
303
+ }, maxBufferedAmountBytes);
304
+ });
305
+ onSession("agent_finished", (event) => {
306
+ sendJson(socket, { type: "agent_end", turnId: event.turnId }, maxBufferedAmountBytes);
307
+ });
308
+ onSession("error", (event) => {
309
+ sendJson(socket, {
310
+ type: "error",
311
+ component: event.stage,
312
+ category: event.category,
313
+ message: event.message,
314
+ }, maxBufferedAmountBytes);
315
+ });
316
+ const callbacks = {
317
+ carrierLabel: "browser",
318
+ getContextId: () => currentContextId,
319
+ isActive: () => true, // Always active for metrics and processing, socket state is checked in encodeFrames
320
+ encodeFrames: (audio, sourceSampleRateHz, contextId) => {
321
+ const resampled = resampleAudioBytes(audio, sourceSampleRateHz, outputSampleRateHz, streamingResamplers);
322
+ const frames = [];
323
+ const frameSampleCount = Math.max(1, Math.round((outputSampleRateHz * outboundFrameDurationMs) / 1000));
324
+ const frameBytesCount = frameSampleCount * 2;
325
+ const pushWireFrame = (wireAudio, wireEncoding, durationMs,
326
+ // The sample rate of THIS frame's payload. Opus frames are encoded at the
327
+ // codec rate (48 kHz), not outputSampleRateHz — mislabeling them 16 kHz made
328
+ // the client skip its 48→16 kHz downsample and play assistant audio ~3× slow.
329
+ wireSampleRateHz) => {
330
+ const sequence = (ttsSequences.get(contextId) ?? 0) + 1;
331
+ ttsSequences.set(contextId, sequence);
332
+ frames.push({
333
+ contextId,
334
+ send: () => {
335
+ if (socket.readyState !== WebSocket.OPEN)
336
+ return false;
337
+ const jsonSuccess = sendJsonWithResult(socket, {
338
+ type: "tts_chunk",
339
+ turnId: contextId,
340
+ sequence,
341
+ sampleRateHz: wireSampleRateHz,
342
+ encoding: wireEncoding,
343
+ channels: 1,
344
+ byteLength: wireAudio.byteLength,
345
+ durationMs,
346
+ }, maxBufferedAmountBytes);
347
+ if (!jsonSuccess)
348
+ return false;
349
+ const binaryData = binaryAudioEnvelope
350
+ ? Buffer.from(encodeSyrinxAudioEnvelope({
351
+ type: "audio",
352
+ contextId,
353
+ sequence,
354
+ sampleRateHz: wireSampleRateHz,
355
+ encoding: wireEncoding,
356
+ channels: 1,
357
+ byteLength: wireAudio.byteLength,
358
+ durationMs,
359
+ }, wireAudio))
360
+ : Buffer.from(wireAudio);
361
+ return sendSocketDataWithResult(socket, binaryData, maxBufferedAmountBytes);
362
+ },
363
+ });
364
+ };
365
+ if (binaryAudioEnvelope && opusCodec && getBrowserOpusDownlink()) {
366
+ for (let offset = 0; offset < resampled.byteLength; offset += frameBytesCount) {
367
+ const framePcm = resampled.subarray(offset, Math.min(resampled.byteLength, offset + frameBytesCount));
368
+ let samples = pcm16BytesToSamples(framePcm);
369
+ if (outputSampleRateHz !== opusCodec.sampleRateHz) {
370
+ samples = resamplePcm16Streaming(streamingResamplers, samples, outputSampleRateHz, opusCodec.sampleRateHz);
371
+ }
372
+ for (const opus of opusCodec.encodePcm16Frame(samples, false)) {
373
+ pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS, opusCodec.sampleRateHz);
374
+ }
375
+ }
376
+ return frames;
377
+ }
378
+ for (let offset = 0; offset < resampled.byteLength; offset += frameBytesCount) {
379
+ const frameAudio = resampled.subarray(offset, Math.min(resampled.byteLength, offset + frameBytesCount));
380
+ pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz), outputSampleRateHz);
381
+ }
382
+ return frames;
383
+ },
384
+ onInterrupt: (contextId) => {
385
+ ttsSequences.delete(contextId);
386
+ opusCodec?.reset();
387
+ sendJson(socket, { type: "audio_clear", turnId: contextId, reason: "barge_in" }, maxBufferedAmountBytes);
388
+ sendJson(socket, { type: "agent_interrupted", turnId: contextId, reason: "barge_in" }, maxBufferedAmountBytes);
389
+ },
390
+ onDrain: (contextId, playout, progress) => {
391
+ if (binaryAudioEnvelope && opusCodec && getBrowserOpusDownlink()) {
392
+ const pendingFrames = opusCodec.encodePcm16Frame(new Int16Array(0), true).map((opus) => ({
393
+ contextId,
394
+ send: () => {
395
+ if (socket.readyState !== WebSocket.OPEN)
396
+ return false;
397
+ const sequence = (ttsSequences.get(contextId) ?? 0) + 1;
398
+ ttsSequences.set(contextId, sequence);
399
+ const jsonSuccess = sendJsonWithResult(socket, {
400
+ type: "tts_chunk",
401
+ turnId: contextId,
402
+ sequence,
403
+ sampleRateHz: opusCodec.sampleRateHz,
404
+ encoding: "opus",
405
+ channels: 1,
406
+ byteLength: opus.byteLength,
407
+ durationMs: BROWSER_OPUS_FRAME_DURATION_MS,
408
+ }, maxBufferedAmountBytes);
409
+ if (!jsonSuccess)
410
+ return false;
411
+ const binaryData = Buffer.from(encodeSyrinxAudioEnvelope({
412
+ type: "audio",
413
+ contextId,
414
+ sequence,
415
+ sampleRateHz: opusCodec.sampleRateHz,
416
+ encoding: "opus",
417
+ channels: 1,
418
+ byteLength: opus.byteLength,
419
+ durationMs: BROWSER_OPUS_FRAME_DURATION_MS,
420
+ }, opus));
421
+ return sendSocketDataWithResult(socket, binaryData, maxBufferedAmountBytes);
422
+ },
423
+ }));
424
+ playout.enqueue(pendingFrames);
425
+ }
426
+ playout.enqueueControl(() => {
427
+ ttsSequences.delete(contextId);
428
+ progress.complete(contextId);
429
+ sendJson(socket, { type: "tts_end", ...(contextId ? { turnId: contextId } : {}) }, maxBufferedAmountBytes);
430
+ });
431
+ },
432
+ onStop: (reason) => {
433
+ session.bus.push(Route.Background, {
434
+ kind: "metric.conversation",
435
+ contextId: currentContextId,
436
+ timestampMs: Date.now(),
437
+ name: `browser.${reason}_playout_stopped`,
438
+ value: "1",
439
+ });
440
+ },
441
+ onClear: () => {
442
+ ttsSequences.clear();
443
+ opusCodec?.reset();
444
+ },
445
+ };
446
+ disposers.push(session.bus.on("turn.change", (pkt) => {
447
+ currentContextId = pkt.contextId;
448
+ }));
449
+ const turnMetrics = new TurnMetricsTracker(session.bus, (message) => {
450
+ sendJson(socket, message, maxBufferedAmountBytes);
451
+ }, turnMetricsTurns);
452
+ turnMetrics.wire(disposers);
453
+ return wireTelephonyOutboundPipeline({
454
+ session,
455
+ socket,
456
+ disposers,
457
+ outboundFrameDurationMs,
458
+ maxQueuedOutputAudioMs,
459
+ callbacks,
460
+ ...(backgroundAudio ? { backgroundAudio } : {}),
461
+ });
462
+ }
463
+ function handleClientMessage(session, data, isBinary, currentContextId, contextId, inputSampleRateHz, rawBinaryInput, contextSampleRates, inputSequence, opusCodec, engineInputSampleRateHz, state, streamingResamplers) {
464
+ if (isBinary) {
465
+ const binaryAudio = decodeInboundBinaryAudio(rawDataToBytes(data), inputSampleRateHz, rawBinaryInput, engineInputSampleRateHz, streamingResamplers, createOpusIngressDecoder(opusCodec));
466
+ const nextContextId = binaryAudio.contextId ?? currentContextId;
467
+ if (nextContextId !== currentContextId) {
468
+ pushTurnChange(session, nextContextId, currentContextId, "websocket_binary_audio_turn");
469
+ }
470
+ rememberContextSampleRate(contextSampleRates, nextContextId, binaryAudio.sampleRateHz);
471
+ rememberInputSequence(session, inputSequence, nextContextId, binaryAudio.sequence);
472
+ const audio = resampleAudioBytes(binaryAudio.audio, binaryAudio.sampleRateHz, inputSampleRateHz, streamingResamplers);
473
+ session.bus.push(Route.Main, {
474
+ kind: "user.audio_received",
475
+ contextId: nextContextId,
476
+ timestampMs: Date.now(),
477
+ audio,
478
+ sampleRateHz: inputSampleRateHz,
479
+ });
480
+ return nextContextId;
481
+ }
482
+ const message = parseClientMessage(parseJsonRecord(rawDataToText(data), "Websocket JSON message"));
483
+ if (message.type === "ping")
484
+ return currentContextId;
485
+ if (message.type === "codec_capability") {
486
+ state.browserOpusDownlink = message.downlinkEncoding === "opus";
487
+ return currentContextId;
488
+ }
489
+ if (message.type === "client_interrupt") {
490
+ const interruptedContextId = message.assistantContextId ?? message.contextId ?? currentContextId;
491
+ if (interruptedContextId) {
492
+ session.requestClientInterrupt(interruptedContextId);
493
+ }
494
+ return currentContextId;
495
+ }
496
+ if (message.type === "playout_progress") {
497
+ // The browser reports how much assistant audio it has actually played out. This
498
+ // is a more accurate "heard" clock than the server pacer (which leads the ear by
499
+ // the client jitter buffer), so barge-in truncates history to what was heard (B2).
500
+ const progressContextId = message.contextId ?? currentContextId;
501
+ if (progressContextId) {
502
+ session.bus.push(Route.Main, {
503
+ kind: "tts.playout_progress",
504
+ contextId: progressContextId,
505
+ timestampMs: Date.now(),
506
+ playedOutMs: message.playedOutMs,
507
+ complete: message.complete ?? false,
508
+ });
509
+ }
510
+ return currentContextId;
511
+ }
512
+ if (message.type === "text") {
513
+ const nextContextId = message.contextId ?? contextId();
514
+ if (nextContextId !== currentContextId) {
515
+ pushTurnChange(session, nextContextId, currentContextId, "websocket_text_turn");
516
+ }
517
+ session.bus.push(Route.Main, {
518
+ kind: "user.text_received",
519
+ contextId: nextContextId,
520
+ timestampMs: Date.now(),
521
+ text: message.text,
522
+ });
523
+ return nextContextId;
524
+ }
525
+ if (message.type === "audio") {
526
+ const nextContextId = message.contextId ?? currentContextId;
527
+ if (nextContextId !== currentContextId) {
528
+ pushTurnChange(session, nextContextId, currentContextId, "websocket_audio_turn");
529
+ }
530
+ const sourceSampleRateHz = requiredJsonAudioSampleRate(message.sampleRateHz);
531
+ rememberContextSampleRate(contextSampleRates, nextContextId, sourceSampleRateHz);
532
+ rememberInputSequence(session, inputSequence, nextContextId, optionalSequence(message.sequence));
533
+ session.bus.push(Route.Main, {
534
+ kind: "user.audio_received",
535
+ contextId: nextContextId,
536
+ timestampMs: Date.now(),
537
+ audio: resampleAudioBytes(decodeStrictBase64(message.audio, "audio"), sourceSampleRateHz, inputSampleRateHz, streamingResamplers),
538
+ sampleRateHz: inputSampleRateHz,
539
+ });
540
+ return nextContextId;
541
+ }
542
+ throw new Error("Unsupported client message type");
543
+ }
544
+ function parseClientMessage(value) {
545
+ if (!isRecord(value))
546
+ throw new Error("Websocket JSON message must be an object");
547
+ const type = value.type;
548
+ if (type === "ping")
549
+ return { type };
550
+ if (type === "text") {
551
+ return {
552
+ type,
553
+ text: requiredString(value.text, "Websocket JSON text"),
554
+ contextId: optionalContextId(value.contextId),
555
+ };
556
+ }
557
+ if (type === "client_interrupt") {
558
+ return {
559
+ type,
560
+ assistantContextId: optionalContextId(value.assistantContextId),
561
+ contextId: optionalContextId(value.contextId),
562
+ reason: optionalString(value.reason, "client_interrupt.reason"),
563
+ };
564
+ }
565
+ if (type === "audio") {
566
+ return {
567
+ type,
568
+ audio: requiredString(value.audio, "Websocket JSON audio"),
569
+ contextId: optionalContextId(value.contextId),
570
+ sampleRateHz: requiredJsonAudioSampleRate(value.sampleRateHz),
571
+ sequence: optionalSequence(value.sequence),
572
+ };
573
+ }
574
+ if (type === "codec_capability") {
575
+ const downlinkEncoding = value.downlinkEncoding;
576
+ if (downlinkEncoding !== "pcm_s16le" && downlinkEncoding !== "opus") {
577
+ throw new Error("codec_capability.downlinkEncoding must be pcm_s16le or opus");
578
+ }
579
+ return { type, downlinkEncoding };
580
+ }
581
+ if (type === "playout_progress") {
582
+ const playedOutMs = value.playedOutMs;
583
+ if (typeof playedOutMs !== "number" || !Number.isFinite(playedOutMs) || playedOutMs < 0) {
584
+ throw new Error("playout_progress.playedOutMs must be a non-negative number");
585
+ }
586
+ return {
587
+ type,
588
+ contextId: optionalContextId(value.contextId),
589
+ playedOutMs,
590
+ complete: value.complete === true ? true : undefined,
591
+ };
592
+ }
593
+ throw new Error("Unsupported client message type");
594
+ }
595
+ function optionalContextId(value) {
596
+ return optionalString(value, "Websocket JSON contextId");
597
+ }
598
+ function rememberInputSequence(session, state, contextId, sequence) {
599
+ if (sequence === undefined)
600
+ return;
601
+ const previous = state.lastSequence;
602
+ if (previous !== null && sequence <= previous) {
603
+ throw new Error(`Websocket audio sequence must increase monotonically: ${String(previous)} -> ${String(sequence)}`);
604
+ }
605
+ if (previous !== null && sequence > previous + 1) {
606
+ session.bus.push(Route.Background, {
607
+ kind: "metric.conversation",
608
+ contextId,
609
+ timestampMs: Date.now(),
610
+ name: "websocket.audio_sequence_gap",
611
+ value: JSON.stringify({ expected: previous + 1, actual: sequence, missed: sequence - previous - 1 }),
612
+ });
613
+ }
614
+ state.lastSequence = sequence;
615
+ }
616
+ function pushTurnChange(session, contextId, previousContextId, reason) {
617
+ session.bus.push(Route.Main, {
618
+ kind: "turn.change",
619
+ contextId,
620
+ previousContextId,
621
+ reason,
622
+ timestampMs: Date.now(),
623
+ });
624
+ }
625
+ function rawDataToBytes(data) {
626
+ if (Buffer.isBuffer(data))
627
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
628
+ if (data instanceof ArrayBuffer)
629
+ return new Uint8Array(data);
630
+ if (Array.isArray(data))
631
+ return Uint8Array.from(Buffer.concat(data));
632
+ throw new Error("Unsupported binary message payload");
633
+ }
634
+ function createOpusIngressDecoder(opusCodec) {
635
+ if (!opusCodec)
636
+ return null;
637
+ return (wire, sampleRateHz) => {
638
+ if (opusCodec.sampleRateHz !== sampleRateHz) {
639
+ throw new Error(`Browser websocket opus sample rate mismatch: ${sampleRateHz} != ${opusCodec.sampleRateHz}`);
640
+ }
641
+ return decodeBrowserOpusToPcm16Bytes(wire, opusCodec);
642
+ };
643
+ }
644
+ function pcm16DurationMs(audio, sampleRateHz) {
645
+ if (sampleRateHz <= 0)
646
+ return 0;
647
+ return Math.round((audio.byteLength / 2 / sampleRateHz) * 1000);
648
+ }
649
+ function requireTtsAudioSampleRate(value) {
650
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
651
+ throw new Error("tts.audio sampleRateHz must be a positive integer");
652
+ }
653
+ return value;
654
+ }
655
+ function optionalSequence(value) {
656
+ if (value === undefined)
657
+ return undefined;
658
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
659
+ throw new Error("Websocket audio sequence must be a non-negative integer");
660
+ }
661
+ return value;
662
+ }
663
+ function requiredJsonAudioSampleRate(value) {
664
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
665
+ throw new Error("JSON websocket audio sampleRateHz must be a positive integer");
666
+ }
667
+ return value;
668
+ }
669
+ function sessionIdFromRequest(request) {
670
+ const url = request.url;
671
+ if (!url)
672
+ return null;
673
+ try {
674
+ const parsed = new URL(url, "http://localhost");
675
+ return parsed.searchParams.get("sessionId") ?? parsed.searchParams.get("session_id");
676
+ }
677
+ catch {
678
+ return null;
679
+ }
680
+ }
681
+ function sanitizeSessionId(value) {
682
+ const trimmed = value.trim();
683
+ if (/^[A-Za-z0-9_.:-]{1,128}$/.test(trimmed))
684
+ return trimmed;
685
+ return defaultSessionId();
686
+ }
687
+ function sendJson(socket, value, maxBufferedAmountBytes) {
688
+ sendSocketData(socket, JSON.stringify(value), maxBufferedAmountBytes);
689
+ }
690
+ function sendJsonWithResult(socket, value, maxBufferedAmountBytes) {
691
+ return sendSocketDataWithResult(socket, JSON.stringify(value), maxBufferedAmountBytes);
692
+ }
693
+ function sendSocketData(socket, data, maxBufferedAmountBytes) {
694
+ sendSocketDataWithResult(socket, data, maxBufferedAmountBytes);
695
+ }
696
+ function sendSocketDataWithResult(socket, data, maxBufferedAmountBytes) {
697
+ if (socket.readyState !== WebSocket.OPEN)
698
+ return false;
699
+ if (socket.bufferedAmount + outboundMessageByteLength(data) > maxBufferedAmountBytes) {
700
+ closeWebSocketWithFallback(socket, 1013, "websocket send buffer exceeded");
701
+ return false;
702
+ }
703
+ socket.send(data);
704
+ return true;
705
+ }
706
+ function outboundMessageByteLength(data) {
707
+ return typeof data === "string" ? Buffer.byteLength(data, "utf8") : data.byteLength;
708
+ }
709
+ function defaultContextId() {
710
+ // crypto-random, not Math.random: ids are guessable resource handles. V8's PRNG
711
+ // is state-recoverable from a few observed outputs, which would let a client
712
+ // predict other sessions' turn/session ids and attach to a live conversation.
713
+ return `turn-${globalThis.crypto.randomUUID()}`;
714
+ }
715
+ function defaultSessionId() {
716
+ return `session-${globalThis.crypto.randomUUID()}`;
717
+ }
718
+ //# sourceMappingURL=index.js.map