@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.
- package/LICENSE +22 -0
- package/package.json +31 -0
- package/src/admission-control.test.ts +247 -0
- package/src/browser-opus.test.ts +41 -0
- package/src/browser-opus.ts +59 -0
- package/src/browser-pacing.test.ts +440 -0
- package/src/edge-twilio.test.ts +215 -0
- package/src/edge-twilio.ts +285 -0
- package/src/edge.test.ts +272 -0
- package/src/edge.ts +652 -0
- package/src/graceful-drain.test.ts +306 -0
- package/src/inbound-audio.ts +102 -0
- package/src/index.test.ts +2071 -0
- package/src/index.ts +891 -0
- package/src/json-message.ts +39 -0
- package/src/outbound-playout-pipeline.test.ts +208 -0
- package/src/outbound-playout-pipeline.ts +167 -0
- package/src/paced-playout.test.ts +247 -0
- package/src/paced-playout.ts +161 -0
- package/src/playout-progress.test.ts +56 -0
- package/src/playout-progress.ts +67 -0
- package/src/session-store.test.ts +274 -0
- package/src/session-store.ts +123 -0
- package/src/smartpbx.test.ts +967 -0
- package/src/smartpbx.ts +531 -0
- package/src/telnyx.test.ts +1413 -0
- package/src/telnyx.ts +708 -0
- package/src/test-helpers.ts +264 -0
- package/src/transport-helpers.ts +78 -0
- package/src/transport-host.test.ts +51 -0
- package/src/transport-host.ts +213 -0
- package/src/turn-metrics.test.ts +327 -0
- package/src/turn-metrics.ts +193 -0
- package/src/twilio.test.ts +1275 -0
- package/src/twilio.ts +599 -0
- package/src/websocket-close.test.ts +63 -0
- package/src/websocket-close.ts +68 -0
- package/src/websocket-lifecycle.test.ts +49 -0
- package/src/websocket-lifecycle.ts +105 -0
- package/src/websocket-upgrade.ts +102 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +7 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
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 { WebSocketServer, WebSocket, type RawData } from "ws";
|
|
6
|
+
import {
|
|
7
|
+
Route,
|
|
8
|
+
SYRINX_AUDIO_ENVELOPE_NAME,
|
|
9
|
+
encodeSyrinxAudioEnvelope,
|
|
10
|
+
type InterruptTtsPacket,
|
|
11
|
+
type TextToSpeechAudioPacket,
|
|
12
|
+
type TextToSpeechEndPacket,
|
|
13
|
+
type UserAudioReceivedPacket,
|
|
14
|
+
type UserTextReceivedPacket,
|
|
15
|
+
type VoiceAgentSession,
|
|
16
|
+
type VoiceAgentSessionEvents,
|
|
17
|
+
} from "@kuralle-syrinx/core";
|
|
18
|
+
import {
|
|
19
|
+
pcm16BytesToSamples,
|
|
20
|
+
pcm16SamplesToBytes,
|
|
21
|
+
resamplePcm16Streaming,
|
|
22
|
+
type StreamingPcm16Resampler,
|
|
23
|
+
} from "@kuralle-syrinx/core/audio";
|
|
24
|
+
import {
|
|
25
|
+
BROWSER_OPUS_FRAME_DURATION_MS,
|
|
26
|
+
BROWSER_OPUS_SAMPLE_RATE_HZ,
|
|
27
|
+
createBrowserOpusCodec,
|
|
28
|
+
decodeBrowserOpusToPcm16Bytes,
|
|
29
|
+
type BrowserOpusCodec,
|
|
30
|
+
} from "./browser-opus.js";
|
|
31
|
+
import { closeWebSocketWithFallback, waitForWebSocketClose } from "./websocket-close.js";
|
|
32
|
+
import { isRecord, parseJsonRecord, optionalString, requiredString } from "./json-message.js";
|
|
33
|
+
import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
|
|
34
|
+
import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
|
|
35
|
+
import { wireTelephonyOutboundPipeline, type TelephonyOutboundCallbacks, type TelephonyOutboundHandle } from "./outbound-playout-pipeline.js";
|
|
36
|
+
import { TurnMetricsTracker, type TurnTimestampState } from "./turn-metrics.js";
|
|
37
|
+
import { type PacedPlayoutFrame } from "./paced-playout.js";
|
|
38
|
+
import {
|
|
39
|
+
InMemorySessionStore,
|
|
40
|
+
type AudioSequenceState,
|
|
41
|
+
type ManagedSession,
|
|
42
|
+
type SessionStore,
|
|
43
|
+
} from "./session-store.js";
|
|
44
|
+
import {
|
|
45
|
+
decodeStrictBase64,
|
|
46
|
+
nonNegativeInteger,
|
|
47
|
+
positiveInteger,
|
|
48
|
+
rawDataToText,
|
|
49
|
+
} from "./transport-helpers.js";
|
|
50
|
+
import {
|
|
51
|
+
decodeInboundBinaryAudio,
|
|
52
|
+
rememberContextSampleRate,
|
|
53
|
+
resampleAudioBytes,
|
|
54
|
+
type OpusIngressDecoder,
|
|
55
|
+
} from "./inbound-audio.js";
|
|
56
|
+
|
|
57
|
+
export * from "./twilio.js";
|
|
58
|
+
export * from "./telnyx.js";
|
|
59
|
+
export * from "./smartpbx.js";
|
|
60
|
+
export * from "./session-store.js";
|
|
61
|
+
export { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
|
|
62
|
+
|
|
63
|
+
export interface VoiceWebSocketServerOptions {
|
|
64
|
+
readonly server?: HttpServer;
|
|
65
|
+
readonly port?: number;
|
|
66
|
+
readonly host?: string;
|
|
67
|
+
readonly path?: string;
|
|
68
|
+
readonly createSession: (request: IncomingMessage) => VoiceAgentSession | Promise<VoiceAgentSession>;
|
|
69
|
+
readonly sessionId?: (request: IncomingMessage) => string;
|
|
70
|
+
readonly contextId?: () => string;
|
|
71
|
+
readonly inputSampleRateHz?: number;
|
|
72
|
+
readonly outputSampleRateHz?: number;
|
|
73
|
+
readonly heartbeatIntervalMs?: number;
|
|
74
|
+
readonly startupTimeoutMs?: number;
|
|
75
|
+
readonly maxSessionDurationMs?: number;
|
|
76
|
+
readonly maxBufferedAmountBytes?: number;
|
|
77
|
+
readonly maxInboundMessageBytes?: number;
|
|
78
|
+
readonly resumeWindowMs?: number;
|
|
79
|
+
readonly outboundFrameDurationMs?: number;
|
|
80
|
+
readonly maxQueuedOutputAudioMs?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Raw binary inbound PCM16 lacks turn, sample-rate, sequence, and duration
|
|
83
|
+
* metadata. Keep disabled for production clients; use JSON audio frames or
|
|
84
|
+
* the Syrinx binary envelope instead. Set true only for explicitly managed
|
|
85
|
+
* legacy/embedded clients that send PCM at `inputSampleRateHz`.
|
|
86
|
+
*/
|
|
87
|
+
readonly rawBinaryInput?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Assistant audio binary frames are wrapped in the Syrinx binary audio
|
|
90
|
+
* envelope by default. Set false only for raw-PCM websocket clients.
|
|
91
|
+
* Inbound binary frames may use the envelope regardless.
|
|
92
|
+
*/
|
|
93
|
+
readonly binaryAudioEnvelope?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* When true (default), outbound browser assistant audio is Opus inside the Syrinx
|
|
96
|
+
* envelope. Set false only for tests or legacy clients that require PCM envelopes.
|
|
97
|
+
*/
|
|
98
|
+
readonly browserOpusDownlink?: boolean;
|
|
99
|
+
readonly sessionStore?: SessionStore;
|
|
100
|
+
readonly maxConcurrentSessions?: number;
|
|
101
|
+
readonly maxConcurrentSessionsScope?: "path" | "server";
|
|
102
|
+
readonly onTransportMetric?: (name: string) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type { GracefulCloseOptions } from "./transport-host.js";
|
|
106
|
+
|
|
107
|
+
export interface VoiceWebSocketServer {
|
|
108
|
+
readonly httpServer: HttpServer;
|
|
109
|
+
readonly wsServer: WebSocketServer;
|
|
110
|
+
address(): ReturnType<HttpServer["address"]>;
|
|
111
|
+
close(opts?: GracefulCloseOptions): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type ClientMessage =
|
|
115
|
+
| { readonly type: "text"; readonly text: string; readonly contextId?: string }
|
|
116
|
+
| {
|
|
117
|
+
readonly type: "client_interrupt";
|
|
118
|
+
readonly assistantContextId?: string;
|
|
119
|
+
readonly contextId?: string;
|
|
120
|
+
readonly reason?: string;
|
|
121
|
+
}
|
|
122
|
+
| {
|
|
123
|
+
readonly type: "audio";
|
|
124
|
+
readonly audio: string;
|
|
125
|
+
readonly contextId?: string;
|
|
126
|
+
readonly sampleRateHz: number;
|
|
127
|
+
readonly sequence?: number;
|
|
128
|
+
}
|
|
129
|
+
| { readonly type: "codec_capability"; readonly downlinkEncoding: "pcm_s16le" | "opus" }
|
|
130
|
+
| { readonly type: "ping" };
|
|
131
|
+
|
|
132
|
+
interface BrowserConnectionState {
|
|
133
|
+
managed: ManagedSession | null;
|
|
134
|
+
readonly initialContextId: string;
|
|
135
|
+
outboundHandle: TelephonyOutboundHandle | null;
|
|
136
|
+
opusCodec: BrowserOpusCodec | null;
|
|
137
|
+
browserOpusDownlink: boolean;
|
|
138
|
+
readonly streamingResamplers: Map<string, StreamingPcm16Resampler>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
142
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
|
|
143
|
+
const DEFAULT_MAX_SESSION_DURATION_MS = 30 * 60_000;
|
|
144
|
+
const DEFAULT_MAX_BUFFERED_AMOUNT_BYTES = 8 * 1024 * 1024;
|
|
145
|
+
const DEFAULT_MAX_INBOUND_MESSAGE_BYTES = 2 * 1024 * 1024;
|
|
146
|
+
const DEFAULT_RESUME_WINDOW_MS = 15_000;
|
|
147
|
+
const DEFAULT_OUTBOUND_FRAME_DURATION_MS = 20;
|
|
148
|
+
// Bounds queue memory + sanity only — interrupt/clear keeps barge-in instant
|
|
149
|
+
// regardless of queue depth, and TTS providers burst whole replies ahead of
|
|
150
|
+
// realtime. A tiny cap silently drops the tail of every long reply.
|
|
151
|
+
const DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS = 60_000;
|
|
152
|
+
|
|
153
|
+
export async function createVoiceWebSocketServer(
|
|
154
|
+
options: VoiceWebSocketServerOptions,
|
|
155
|
+
): Promise<VoiceWebSocketServer> {
|
|
156
|
+
const ownsHttpServer = !options.server;
|
|
157
|
+
const httpServer = options.server ?? createServer();
|
|
158
|
+
const routedWebSocket = createRoutedWebSocketServer(httpServer, options.path ?? "/ws", {
|
|
159
|
+
maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
|
|
160
|
+
maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
|
|
161
|
+
onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
|
|
162
|
+
});
|
|
163
|
+
const wsServer = routedWebSocket.wsServer;
|
|
164
|
+
const sessionStore = options.sessionStore ?? new InMemorySessionStore();
|
|
165
|
+
const sessionIdFn = options.sessionId ?? defaultSessionId;
|
|
166
|
+
const contextIdFn = options.contextId ?? defaultContextId;
|
|
167
|
+
const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? 16000;
|
|
168
|
+
const outputSampleRateHz = positiveInteger(options.outputSampleRateHz) ?? 16000;
|
|
169
|
+
const rawBinaryInput = options.rawBinaryInput ?? false;
|
|
170
|
+
const binaryAudioEnvelope = options.binaryAudioEnvelope ?? true;
|
|
171
|
+
const browserOpusDownlink = options.browserOpusDownlink ?? true;
|
|
172
|
+
const resumeWindowMs = nonNegativeInteger(options.resumeWindowMs) ?? DEFAULT_RESUME_WINDOW_MS;
|
|
173
|
+
const outboundFrameDurationMs = positiveInteger(options.outboundFrameDurationMs) ?? DEFAULT_OUTBOUND_FRAME_DURATION_MS;
|
|
174
|
+
const maxQueuedOutputAudioMs = positiveInteger(options.maxQueuedOutputAudioMs) ?? DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS;
|
|
175
|
+
const hostConfig: TransportHostConfig = {
|
|
176
|
+
heartbeatIntervalMs: nonNegativeInteger(options.heartbeatIntervalMs) ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
177
|
+
startupTimeoutMs: nonNegativeInteger(options.startupTimeoutMs) ?? DEFAULT_STARTUP_TIMEOUT_MS,
|
|
178
|
+
maxSessionDurationMs: nonNegativeInteger(options.maxSessionDurationMs) ?? DEFAULT_MAX_SESSION_DURATION_MS,
|
|
179
|
+
maxBufferedAmountBytes: positiveInteger(options.maxBufferedAmountBytes) ?? DEFAULT_MAX_BUFFERED_AMOUNT_BYTES,
|
|
180
|
+
maxInboundMessageBytes: positiveInteger(options.maxInboundMessageBytes) ?? DEFAULT_MAX_INBOUND_MESSAGE_BYTES,
|
|
181
|
+
};
|
|
182
|
+
const gracefulCloseRegistry = new Map<WebSocket, (deadlineMs: number) => Promise<void>>();
|
|
183
|
+
|
|
184
|
+
const adapter: TransportAdapter<BrowserConnectionState> = {
|
|
185
|
+
createState: () => ({
|
|
186
|
+
managed: null,
|
|
187
|
+
initialContextId: contextIdFn(),
|
|
188
|
+
outboundHandle: null,
|
|
189
|
+
opusCodec: null,
|
|
190
|
+
browserOpusDownlink: browserOpusDownlink,
|
|
191
|
+
streamingResamplers: new Map(),
|
|
192
|
+
}),
|
|
193
|
+
|
|
194
|
+
async acquireSession({ request, state, shouldAbort, onSessionCreated }) {
|
|
195
|
+
const requestedSessionId = sanitizeSessionId(sessionIdFromRequest(request) ?? sessionIdFn(request));
|
|
196
|
+
const leased = await sessionStore.lease(requestedSessionId, async () => {
|
|
197
|
+
const sess = await options.createSession(request);
|
|
198
|
+
onSessionCreated(sess);
|
|
199
|
+
if (shouldAbort()) {
|
|
200
|
+
await sess.close().catch(() => undefined);
|
|
201
|
+
throw new Error("websocket session startup aborted");
|
|
202
|
+
}
|
|
203
|
+
await sess.start();
|
|
204
|
+
if (shouldAbort()) {
|
|
205
|
+
await sess.close().catch(() => undefined);
|
|
206
|
+
throw new Error("websocket session startup aborted");
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
id: requestedSessionId,
|
|
210
|
+
session: sess,
|
|
211
|
+
currentContextId: state.initialContextId,
|
|
212
|
+
contextSampleRates: new Map(),
|
|
213
|
+
inputSequence: { lastSequence: null },
|
|
214
|
+
turnMetricsTurns: new Map(),
|
|
215
|
+
closeTimer: null,
|
|
216
|
+
connectionCount: 1,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
state.managed = leased.managed;
|
|
220
|
+
return { session: leased.managed.session, resumed: leased.resumed };
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
wireSession(session, socket, state, disposers) {
|
|
224
|
+
const managed = state.managed;
|
|
225
|
+
if (!managed) throw new Error("websocket session missing managed state");
|
|
226
|
+
state.opusCodec = createBrowserOpusCodec(BROWSER_OPUS_SAMPLE_RATE_HZ);
|
|
227
|
+
state.outboundHandle = wireBrowserSessionEvents(
|
|
228
|
+
session,
|
|
229
|
+
socket,
|
|
230
|
+
disposers,
|
|
231
|
+
outputSampleRateHz,
|
|
232
|
+
binaryAudioEnvelope,
|
|
233
|
+
hostConfig.maxBufferedAmountBytes,
|
|
234
|
+
outboundFrameDurationMs,
|
|
235
|
+
maxQueuedOutputAudioMs,
|
|
236
|
+
state.opusCodec,
|
|
237
|
+
inputSampleRateHz,
|
|
238
|
+
() => state.browserOpusDownlink,
|
|
239
|
+
managed.turnMetricsTurns,
|
|
240
|
+
state.streamingResamplers,
|
|
241
|
+
);
|
|
242
|
+
gracefulCloseRegistry.set(socket, (deadlineMs) => {
|
|
243
|
+
if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) {
|
|
244
|
+
return Promise.resolve();
|
|
245
|
+
}
|
|
246
|
+
if (state.outboundHandle) {
|
|
247
|
+
return state.outboundHandle.drainAndClose(socket, deadlineMs);
|
|
248
|
+
}
|
|
249
|
+
return new Promise<void>((resolve) => {
|
|
250
|
+
let settled = false;
|
|
251
|
+
const settle = () => { if (!settled) { settled = true; resolve(); } };
|
|
252
|
+
const deadlineTimer = setTimeout(() => {
|
|
253
|
+
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
|
|
254
|
+
socket.terminate();
|
|
255
|
+
}
|
|
256
|
+
settle();
|
|
257
|
+
}, Math.max(0, deadlineMs - Date.now()));
|
|
258
|
+
(deadlineTimer as NodeJS.Timeout).unref?.();
|
|
259
|
+
socket.once("close", () => { clearTimeout(deadlineTimer); settle(); });
|
|
260
|
+
closeWebSocketWithFallback(socket, 1001, "server going away");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
disposers.push(() => gracefulCloseRegistry.delete(socket));
|
|
264
|
+
return () => undefined;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
processMessage(data, isBinary, session, state) {
|
|
268
|
+
if (!state.managed) return;
|
|
269
|
+
const managed = state.managed;
|
|
270
|
+
sessionStore.update(managed.id, (stored) => {
|
|
271
|
+
stored.currentContextId = handleClientMessage(
|
|
272
|
+
session,
|
|
273
|
+
data,
|
|
274
|
+
isBinary,
|
|
275
|
+
stored.currentContextId,
|
|
276
|
+
contextIdFn,
|
|
277
|
+
inputSampleRateHz,
|
|
278
|
+
rawBinaryInput,
|
|
279
|
+
stored.contextSampleRates,
|
|
280
|
+
stored.inputSequence,
|
|
281
|
+
state.opusCodec,
|
|
282
|
+
inputSampleRateHz,
|
|
283
|
+
state,
|
|
284
|
+
state.streamingResamplers,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
onDisconnect(_session, state, { maxSessionTimedOut }) {
|
|
290
|
+
if (state.managed) {
|
|
291
|
+
state.managed.connectionCount = Math.max(0, state.managed.connectionCount - 1);
|
|
292
|
+
void sessionStore.release(state.managed.id, maxSessionTimedOut ? 0 : resumeWindowMs);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
onStartupTimeout(_state, session) {
|
|
297
|
+
void session.close().catch(() => undefined);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
sendReady(session, socket, state, resumed, config) {
|
|
301
|
+
const managed = state.managed;
|
|
302
|
+
if (!managed) return;
|
|
303
|
+
sendJson(socket, {
|
|
304
|
+
type: "ready",
|
|
305
|
+
sessionId: managed.id,
|
|
306
|
+
turnId: managed.currentContextId,
|
|
307
|
+
resumed,
|
|
308
|
+
resumeWindowMs,
|
|
309
|
+
maxSessionDurationMs: config.maxSessionDurationMs,
|
|
310
|
+
audio: {
|
|
311
|
+
inputSampleRateHz,
|
|
312
|
+
outputSampleRateHz,
|
|
313
|
+
encoding: "opus",
|
|
314
|
+
supportedInputCodecs: ["pcm_s16le", "opus"],
|
|
315
|
+
channels: 1,
|
|
316
|
+
targetFrameDurationMs: outboundFrameDurationMs,
|
|
317
|
+
binaryEnvelope: binaryAudioEnvelope ? SYRINX_AUDIO_ENVELOPE_NAME : undefined,
|
|
318
|
+
rawBinaryInput,
|
|
319
|
+
maxInboundMessageBytes: config.maxInboundMessageBytes,
|
|
320
|
+
},
|
|
321
|
+
}, config.maxBufferedAmountBytes);
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
sendError(socket, _state, message) {
|
|
325
|
+
sendJson(socket, {
|
|
326
|
+
type: "error",
|
|
327
|
+
component: "transport",
|
|
328
|
+
category: "invalid_input",
|
|
329
|
+
message,
|
|
330
|
+
}, hostConfig.maxBufferedAmountBytes);
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
sendStartupError(socket, _state, err, isTimeout) {
|
|
334
|
+
sendJson(socket, {
|
|
335
|
+
type: "error",
|
|
336
|
+
component: "session",
|
|
337
|
+
category: isTimeout ? "startup_timeout" : "initialization",
|
|
338
|
+
message: err instanceof Error ? err.message : String(err),
|
|
339
|
+
}, hostConfig.maxBufferedAmountBytes);
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
onMaxSessionTimeout(socket, _state) {
|
|
343
|
+
sendJson(socket, {
|
|
344
|
+
type: "error",
|
|
345
|
+
component: "transport",
|
|
346
|
+
category: "session_timeout",
|
|
347
|
+
message: "Websocket max session duration exceeded",
|
|
348
|
+
}, 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
|
+
await Promise.allSettled(
|
|
380
|
+
[...wsServer.clients].map((client) => waitForWebSocketClose(client, 250)),
|
|
381
|
+
);
|
|
382
|
+
} else {
|
|
383
|
+
for (const client of wsServer.clients) client.terminate();
|
|
384
|
+
}
|
|
385
|
+
gracefulCloseRegistry.clear();
|
|
386
|
+
for (const managed of await sessionStore.listAll()) {
|
|
387
|
+
if (managed.closeTimer) clearTimeout(managed.closeTimer);
|
|
388
|
+
await managed.session.close().catch(() => undefined);
|
|
389
|
+
}
|
|
390
|
+
await sessionStore.clear();
|
|
391
|
+
// Force-terminate any remaining sockets before wsServer.close() so the
|
|
392
|
+
// close event fires promptly even if a graceful handshake stalled.
|
|
393
|
+
for (const client of wsServer.clients) client.terminate();
|
|
394
|
+
// Yield one macrotask tick so pending net.Socket close events (from terminate()
|
|
395
|
+
// or WS close handshake) are processed before wsServer.close() checks clients.
|
|
396
|
+
await new Promise<void>((res) => setTimeout(res, 0));
|
|
397
|
+
await new Promise<void>((resolveClose) => { wsServer.close(() => resolveClose()); });
|
|
398
|
+
routedWebSocket.detach();
|
|
399
|
+
if (ownsHttpServer || typeof options.port === "number") {
|
|
400
|
+
await new Promise<void>((resolveClose) => { httpServer.close(() => resolveClose()); });
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function wireBrowserSessionEvents(
|
|
407
|
+
session: VoiceAgentSession,
|
|
408
|
+
socket: WebSocket,
|
|
409
|
+
disposers: Array<() => void>,
|
|
410
|
+
outputSampleRateHz: number,
|
|
411
|
+
binaryAudioEnvelope: boolean,
|
|
412
|
+
maxBufferedAmountBytes: number,
|
|
413
|
+
outboundFrameDurationMs: number,
|
|
414
|
+
maxQueuedOutputAudioMs: number,
|
|
415
|
+
opusCodec: BrowserOpusCodec | null,
|
|
416
|
+
engineInputSampleRateHz: number,
|
|
417
|
+
getBrowserOpusDownlink: () => boolean,
|
|
418
|
+
turnMetricsTurns: Map<string, TurnTimestampState>,
|
|
419
|
+
streamingResamplers: Map<string, StreamingPcm16Resampler>,
|
|
420
|
+
): TelephonyOutboundHandle {
|
|
421
|
+
const ttsSequences = new Map<string, number>();
|
|
422
|
+
const speechEndedSent = new Set<string>();
|
|
423
|
+
let currentContextId = "";
|
|
424
|
+
|
|
425
|
+
const onSession = <K extends keyof VoiceAgentSessionEvents>(
|
|
426
|
+
event: K,
|
|
427
|
+
handler: VoiceAgentSessionEvents[K],
|
|
428
|
+
): void => {
|
|
429
|
+
session.on(event, handler);
|
|
430
|
+
disposers.push(() => session.off(event, handler));
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
onSession("user_started_speaking", (event) => {
|
|
434
|
+
sendJson(socket, { type: "speech_started", turnId: event.turnId }, maxBufferedAmountBytes);
|
|
435
|
+
});
|
|
436
|
+
onSession("user_stopped_speaking", (event) => {
|
|
437
|
+
speechEndedSent.add(event.turnId);
|
|
438
|
+
sendJson(socket, { type: "speech_ended", turnId: event.turnId }, maxBufferedAmountBytes);
|
|
439
|
+
});
|
|
440
|
+
onSession("user_input_partial", (event) => {
|
|
441
|
+
sendJson(socket, { type: "stt_chunk", turnId: event.turnId, transcript: event.text }, maxBufferedAmountBytes);
|
|
442
|
+
});
|
|
443
|
+
onSession("user_input_final", (event) => {
|
|
444
|
+
if (!speechEndedSent.has(event.turnId)) {
|
|
445
|
+
speechEndedSent.add(event.turnId);
|
|
446
|
+
sendJson(socket, { type: "speech_ended", turnId: event.turnId }, maxBufferedAmountBytes);
|
|
447
|
+
}
|
|
448
|
+
sendJson(socket, { type: "stt_output", turnId: event.turnId, transcript: event.text, confidence: event.confidence }, maxBufferedAmountBytes);
|
|
449
|
+
});
|
|
450
|
+
disposers.push(
|
|
451
|
+
session.bus.on("eos.turn_complete", (pkt) => {
|
|
452
|
+
const turn = pkt as { contextId: string; text?: string };
|
|
453
|
+
sendJson(socket, { type: "turn_complete", turnId: turn.contextId, transcript: turn.text ?? "" }, maxBufferedAmountBytes);
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
onSession("agent_text_delta", (event) => {
|
|
457
|
+
sendJson(socket, { type: "agent_chunk", turnId: event.turnId, text: event.delta }, maxBufferedAmountBytes);
|
|
458
|
+
});
|
|
459
|
+
onSession("agent_tool_call", (event) => {
|
|
460
|
+
sendJson(socket, { type: "agent_tool_call", turnId: event.turnId, id: event.id, name: event.name, args: event.args }, maxBufferedAmountBytes);
|
|
461
|
+
});
|
|
462
|
+
onSession("agent_tool_result", (event) => {
|
|
463
|
+
sendJson(socket, { type: "agent_tool_result", turnId: event.turnId, id: event.id, result: event.result }, maxBufferedAmountBytes);
|
|
464
|
+
});
|
|
465
|
+
onSession("agent_finished", (event) => {
|
|
466
|
+
sendJson(socket, { type: "agent_end", turnId: event.turnId }, maxBufferedAmountBytes);
|
|
467
|
+
});
|
|
468
|
+
onSession("error", (event) => {
|
|
469
|
+
sendJson(socket, {
|
|
470
|
+
type: "error",
|
|
471
|
+
component: event.stage,
|
|
472
|
+
category: event.category,
|
|
473
|
+
message: event.message,
|
|
474
|
+
}, maxBufferedAmountBytes);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const callbacks: TelephonyOutboundCallbacks = {
|
|
478
|
+
carrierLabel: "browser",
|
|
479
|
+
getContextId: () => currentContextId,
|
|
480
|
+
isActive: () => true, // Always active for metrics and processing, socket state is checked in encodeFrames
|
|
481
|
+
|
|
482
|
+
encodeFrames: (audio, sourceSampleRateHz, contextId) => {
|
|
483
|
+
const resampled = resampleAudioBytes(audio, sourceSampleRateHz, outputSampleRateHz, streamingResamplers);
|
|
484
|
+
const frames: PacedPlayoutFrame[] = [];
|
|
485
|
+
const frameSampleCount = Math.max(1, Math.round((outputSampleRateHz * outboundFrameDurationMs) / 1000));
|
|
486
|
+
const frameBytesCount = frameSampleCount * 2;
|
|
487
|
+
|
|
488
|
+
const pushWireFrame = (
|
|
489
|
+
wireAudio: Uint8Array,
|
|
490
|
+
wireEncoding: "pcm_s16le" | "opus",
|
|
491
|
+
durationMs: number,
|
|
492
|
+
): void => {
|
|
493
|
+
const sequence = (ttsSequences.get(contextId) ?? 0) + 1;
|
|
494
|
+
ttsSequences.set(contextId, sequence);
|
|
495
|
+
frames.push({
|
|
496
|
+
contextId,
|
|
497
|
+
send: () => {
|
|
498
|
+
if (socket.readyState !== WebSocket.OPEN) return false;
|
|
499
|
+
const jsonSuccess = sendJsonWithResult(socket, {
|
|
500
|
+
type: "tts_chunk",
|
|
501
|
+
turnId: contextId,
|
|
502
|
+
sequence,
|
|
503
|
+
sampleRateHz: outputSampleRateHz,
|
|
504
|
+
encoding: wireEncoding,
|
|
505
|
+
channels: 1,
|
|
506
|
+
byteLength: wireAudio.byteLength,
|
|
507
|
+
durationMs,
|
|
508
|
+
}, maxBufferedAmountBytes);
|
|
509
|
+
if (!jsonSuccess) return false;
|
|
510
|
+
const binaryData = binaryAudioEnvelope
|
|
511
|
+
? Buffer.from(encodeSyrinxAudioEnvelope({
|
|
512
|
+
type: "audio",
|
|
513
|
+
contextId,
|
|
514
|
+
sequence,
|
|
515
|
+
sampleRateHz: outputSampleRateHz,
|
|
516
|
+
encoding: wireEncoding,
|
|
517
|
+
channels: 1,
|
|
518
|
+
byteLength: wireAudio.byteLength,
|
|
519
|
+
durationMs,
|
|
520
|
+
}, wireAudio))
|
|
521
|
+
: Buffer.from(wireAudio);
|
|
522
|
+
return sendSocketDataWithResult(socket, binaryData, maxBufferedAmountBytes);
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
if (binaryAudioEnvelope && opusCodec && getBrowserOpusDownlink()) {
|
|
528
|
+
for (let offset = 0; offset < resampled.byteLength; offset += frameBytesCount) {
|
|
529
|
+
const framePcm = resampled.subarray(offset, Math.min(resampled.byteLength, offset + frameBytesCount));
|
|
530
|
+
let samples = pcm16BytesToSamples(framePcm);
|
|
531
|
+
if (outputSampleRateHz !== opusCodec.sampleRateHz) {
|
|
532
|
+
samples = resamplePcm16Streaming(streamingResamplers, samples, outputSampleRateHz, opusCodec.sampleRateHz);
|
|
533
|
+
}
|
|
534
|
+
for (const opus of opusCodec.encodePcm16Frame(samples, false)) {
|
|
535
|
+
pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return frames;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
for (let offset = 0; offset < resampled.byteLength; offset += frameBytesCount) {
|
|
542
|
+
const frameAudio = resampled.subarray(offset, Math.min(resampled.byteLength, offset + frameBytesCount));
|
|
543
|
+
pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz));
|
|
544
|
+
}
|
|
545
|
+
return frames;
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
onInterrupt: (contextId) => {
|
|
549
|
+
ttsSequences.delete(contextId);
|
|
550
|
+
opusCodec?.reset();
|
|
551
|
+
sendJson(socket, { type: "audio_clear", turnId: contextId, reason: "barge_in" }, maxBufferedAmountBytes);
|
|
552
|
+
sendJson(socket, { type: "agent_interrupted", turnId: contextId, reason: "barge_in" }, maxBufferedAmountBytes);
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
onDrain: (contextId, playout, progress) => {
|
|
556
|
+
if (binaryAudioEnvelope && opusCodec && getBrowserOpusDownlink()) {
|
|
557
|
+
const pendingFrames = opusCodec.encodePcm16Frame(new Int16Array(0), true).map((opus) => ({
|
|
558
|
+
contextId,
|
|
559
|
+
send: () => {
|
|
560
|
+
if (socket.readyState !== WebSocket.OPEN) return false;
|
|
561
|
+
const sequence = (ttsSequences.get(contextId) ?? 0) + 1;
|
|
562
|
+
ttsSequences.set(contextId, sequence);
|
|
563
|
+
const jsonSuccess = sendJsonWithResult(socket, {
|
|
564
|
+
type: "tts_chunk",
|
|
565
|
+
turnId: contextId,
|
|
566
|
+
sequence,
|
|
567
|
+
sampleRateHz: outputSampleRateHz,
|
|
568
|
+
encoding: "opus",
|
|
569
|
+
channels: 1,
|
|
570
|
+
byteLength: opus.byteLength,
|
|
571
|
+
durationMs: BROWSER_OPUS_FRAME_DURATION_MS,
|
|
572
|
+
}, maxBufferedAmountBytes);
|
|
573
|
+
if (!jsonSuccess) return false;
|
|
574
|
+
const binaryData = Buffer.from(encodeSyrinxAudioEnvelope({
|
|
575
|
+
type: "audio",
|
|
576
|
+
contextId,
|
|
577
|
+
sequence,
|
|
578
|
+
sampleRateHz: outputSampleRateHz,
|
|
579
|
+
encoding: "opus",
|
|
580
|
+
channels: 1,
|
|
581
|
+
byteLength: opus.byteLength,
|
|
582
|
+
durationMs: BROWSER_OPUS_FRAME_DURATION_MS,
|
|
583
|
+
}, opus));
|
|
584
|
+
return sendSocketDataWithResult(socket, binaryData, maxBufferedAmountBytes);
|
|
585
|
+
},
|
|
586
|
+
}));
|
|
587
|
+
playout.enqueue(pendingFrames);
|
|
588
|
+
}
|
|
589
|
+
playout.enqueueControl(() => {
|
|
590
|
+
ttsSequences.delete(contextId);
|
|
591
|
+
progress.complete(contextId);
|
|
592
|
+
sendJson(socket, { type: "tts_end", ...(contextId ? { turnId: contextId } : {}) }, maxBufferedAmountBytes);
|
|
593
|
+
});
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
onStop: (reason) => {
|
|
597
|
+
session.bus.push(Route.Background, {
|
|
598
|
+
kind: "metric.conversation",
|
|
599
|
+
contextId: currentContextId,
|
|
600
|
+
timestampMs: Date.now(),
|
|
601
|
+
name: `browser.${reason}_playout_stopped`,
|
|
602
|
+
value: "1",
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
onClear: () => {
|
|
607
|
+
ttsSequences.clear();
|
|
608
|
+
opusCodec?.reset();
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
disposers.push(
|
|
613
|
+
session.bus.on("turn.change", (pkt) => {
|
|
614
|
+
currentContextId = (pkt as { contextId: string }).contextId;
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const turnMetrics = new TurnMetricsTracker(session.bus, (message) => {
|
|
619
|
+
sendJson(socket, message, maxBufferedAmountBytes);
|
|
620
|
+
}, turnMetricsTurns);
|
|
621
|
+
turnMetrics.wire(disposers);
|
|
622
|
+
|
|
623
|
+
return wireTelephonyOutboundPipeline({
|
|
624
|
+
session,
|
|
625
|
+
socket,
|
|
626
|
+
disposers,
|
|
627
|
+
outboundFrameDurationMs,
|
|
628
|
+
maxQueuedOutputAudioMs,
|
|
629
|
+
callbacks,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function handleClientMessage(
|
|
634
|
+
session: VoiceAgentSession,
|
|
635
|
+
data: RawData,
|
|
636
|
+
isBinary: boolean,
|
|
637
|
+
currentContextId: string,
|
|
638
|
+
contextId: () => string,
|
|
639
|
+
inputSampleRateHz: number,
|
|
640
|
+
rawBinaryInput: boolean,
|
|
641
|
+
contextSampleRates: Map<string, number>,
|
|
642
|
+
inputSequence: AudioSequenceState,
|
|
643
|
+
opusCodec: BrowserOpusCodec | null,
|
|
644
|
+
engineInputSampleRateHz: number,
|
|
645
|
+
state: BrowserConnectionState,
|
|
646
|
+
streamingResamplers: Map<string, StreamingPcm16Resampler>,
|
|
647
|
+
): string {
|
|
648
|
+
if (isBinary) {
|
|
649
|
+
const binaryAudio = decodeInboundBinaryAudio(
|
|
650
|
+
rawDataToBytes(data),
|
|
651
|
+
inputSampleRateHz,
|
|
652
|
+
rawBinaryInput,
|
|
653
|
+
engineInputSampleRateHz,
|
|
654
|
+
streamingResamplers,
|
|
655
|
+
createOpusIngressDecoder(opusCodec),
|
|
656
|
+
);
|
|
657
|
+
const nextContextId = binaryAudio.contextId ?? currentContextId;
|
|
658
|
+
if (nextContextId !== currentContextId) {
|
|
659
|
+
pushTurnChange(session, nextContextId, currentContextId, "websocket_binary_audio_turn");
|
|
660
|
+
}
|
|
661
|
+
rememberContextSampleRate(contextSampleRates, nextContextId, binaryAudio.sampleRateHz);
|
|
662
|
+
rememberInputSequence(session, inputSequence, nextContextId, binaryAudio.sequence);
|
|
663
|
+
const audio = resampleAudioBytes(binaryAudio.audio, binaryAudio.sampleRateHz, inputSampleRateHz, streamingResamplers);
|
|
664
|
+
session.bus.push(Route.Main, {
|
|
665
|
+
kind: "user.audio_received",
|
|
666
|
+
contextId: nextContextId,
|
|
667
|
+
timestampMs: Date.now(),
|
|
668
|
+
audio,
|
|
669
|
+
} satisfies UserAudioReceivedPacket);
|
|
670
|
+
return nextContextId;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const message = parseClientMessage(parseJsonRecord(rawDataToText(data), "Websocket JSON message"));
|
|
674
|
+
if (message.type === "ping") return currentContextId;
|
|
675
|
+
if (message.type === "codec_capability") {
|
|
676
|
+
state.browserOpusDownlink = message.downlinkEncoding === "opus";
|
|
677
|
+
return currentContextId;
|
|
678
|
+
}
|
|
679
|
+
if (message.type === "client_interrupt") {
|
|
680
|
+
const interruptedContextId = message.assistantContextId ?? message.contextId ?? currentContextId;
|
|
681
|
+
if (interruptedContextId) {
|
|
682
|
+
session.requestClientInterrupt(interruptedContextId);
|
|
683
|
+
}
|
|
684
|
+
return currentContextId;
|
|
685
|
+
}
|
|
686
|
+
if (message.type === "text") {
|
|
687
|
+
const nextContextId = message.contextId ?? contextId();
|
|
688
|
+
if (nextContextId !== currentContextId) {
|
|
689
|
+
pushTurnChange(session, nextContextId, currentContextId, "websocket_text_turn");
|
|
690
|
+
}
|
|
691
|
+
session.bus.push(Route.Main, {
|
|
692
|
+
kind: "user.text_received",
|
|
693
|
+
contextId: nextContextId,
|
|
694
|
+
timestampMs: Date.now(),
|
|
695
|
+
text: message.text,
|
|
696
|
+
} satisfies UserTextReceivedPacket);
|
|
697
|
+
return nextContextId;
|
|
698
|
+
}
|
|
699
|
+
if (message.type === "audio") {
|
|
700
|
+
const nextContextId = message.contextId ?? currentContextId;
|
|
701
|
+
if (nextContextId !== currentContextId) {
|
|
702
|
+
pushTurnChange(session, nextContextId, currentContextId, "websocket_audio_turn");
|
|
703
|
+
}
|
|
704
|
+
const sourceSampleRateHz = requiredJsonAudioSampleRate(message.sampleRateHz);
|
|
705
|
+
rememberContextSampleRate(contextSampleRates, nextContextId, sourceSampleRateHz);
|
|
706
|
+
rememberInputSequence(session, inputSequence, nextContextId, optionalSequence(message.sequence));
|
|
707
|
+
session.bus.push(Route.Main, {
|
|
708
|
+
kind: "user.audio_received",
|
|
709
|
+
contextId: nextContextId,
|
|
710
|
+
timestampMs: Date.now(),
|
|
711
|
+
audio: resampleAudioBytes(decodeStrictBase64(message.audio, "audio"), sourceSampleRateHz, inputSampleRateHz, streamingResamplers),
|
|
712
|
+
} satisfies UserAudioReceivedPacket);
|
|
713
|
+
return nextContextId;
|
|
714
|
+
}
|
|
715
|
+
throw new Error("Unsupported client message type");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function parseClientMessage(value: unknown): ClientMessage {
|
|
719
|
+
if (!isRecord(value)) throw new Error("Websocket JSON message must be an object");
|
|
720
|
+
const type = value.type;
|
|
721
|
+
if (type === "ping") return { type };
|
|
722
|
+
if (type === "text") {
|
|
723
|
+
return {
|
|
724
|
+
type,
|
|
725
|
+
text: requiredString(value.text, "Websocket JSON text"),
|
|
726
|
+
contextId: optionalContextId(value.contextId),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (type === "client_interrupt") {
|
|
730
|
+
return {
|
|
731
|
+
type,
|
|
732
|
+
assistantContextId: optionalContextId(value.assistantContextId),
|
|
733
|
+
contextId: optionalContextId(value.contextId),
|
|
734
|
+
reason: optionalString(value.reason, "client_interrupt.reason"),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
if (type === "audio") {
|
|
738
|
+
return {
|
|
739
|
+
type,
|
|
740
|
+
audio: requiredString(value.audio, "Websocket JSON audio"),
|
|
741
|
+
contextId: optionalContextId(value.contextId),
|
|
742
|
+
sampleRateHz: requiredJsonAudioSampleRate(value.sampleRateHz),
|
|
743
|
+
sequence: optionalSequence(value.sequence),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
if (type === "codec_capability") {
|
|
747
|
+
const downlinkEncoding = value.downlinkEncoding;
|
|
748
|
+
if (downlinkEncoding !== "pcm_s16le" && downlinkEncoding !== "opus") {
|
|
749
|
+
throw new Error("codec_capability.downlinkEncoding must be pcm_s16le or opus");
|
|
750
|
+
}
|
|
751
|
+
return { type, downlinkEncoding };
|
|
752
|
+
}
|
|
753
|
+
throw new Error("Unsupported client message type");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function optionalContextId(value: unknown): string | undefined {
|
|
757
|
+
return optionalString(value, "Websocket JSON contextId");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function rememberInputSequence(
|
|
761
|
+
session: VoiceAgentSession,
|
|
762
|
+
state: AudioSequenceState,
|
|
763
|
+
contextId: string,
|
|
764
|
+
sequence: number | undefined,
|
|
765
|
+
): void {
|
|
766
|
+
if (sequence === undefined) return;
|
|
767
|
+
const previous = state.lastSequence;
|
|
768
|
+
if (previous !== null && sequence <= previous) {
|
|
769
|
+
throw new Error(`Websocket audio sequence must increase monotonically: ${String(previous)} -> ${String(sequence)}`);
|
|
770
|
+
}
|
|
771
|
+
if (previous !== null && sequence > previous + 1) {
|
|
772
|
+
session.bus.push(Route.Background, {
|
|
773
|
+
kind: "metric.conversation",
|
|
774
|
+
contextId,
|
|
775
|
+
timestampMs: Date.now(),
|
|
776
|
+
name: "websocket.audio_sequence_gap",
|
|
777
|
+
value: JSON.stringify({ expected: previous + 1, actual: sequence, missed: sequence - previous - 1 }),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
state.lastSequence = sequence;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function pushTurnChange(
|
|
784
|
+
session: VoiceAgentSession,
|
|
785
|
+
contextId: string,
|
|
786
|
+
previousContextId: string,
|
|
787
|
+
reason: string,
|
|
788
|
+
): void {
|
|
789
|
+
session.bus.push(Route.Main, {
|
|
790
|
+
kind: "turn.change",
|
|
791
|
+
contextId,
|
|
792
|
+
previousContextId,
|
|
793
|
+
reason,
|
|
794
|
+
timestampMs: Date.now(),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function rawDataToBytes(data: RawData): Uint8Array {
|
|
799
|
+
if (Buffer.isBuffer(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
800
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
801
|
+
if (Array.isArray(data)) return Uint8Array.from(Buffer.concat(data));
|
|
802
|
+
throw new Error("Unsupported binary message payload");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function createOpusIngressDecoder(opusCodec: BrowserOpusCodec | null): OpusIngressDecoder | null {
|
|
806
|
+
if (!opusCodec) return null;
|
|
807
|
+
return (wire, sampleRateHz) => {
|
|
808
|
+
if (opusCodec.sampleRateHz !== sampleRateHz) {
|
|
809
|
+
throw new Error(`Browser websocket opus sample rate mismatch: ${sampleRateHz} != ${opusCodec.sampleRateHz}`);
|
|
810
|
+
}
|
|
811
|
+
return decodeBrowserOpusToPcm16Bytes(wire, opusCodec);
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function pcm16DurationMs(audio: Uint8Array, sampleRateHz: number): number {
|
|
816
|
+
if (sampleRateHz <= 0) return 0;
|
|
817
|
+
return Math.round((audio.byteLength / 2 / sampleRateHz) * 1000);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function requireTtsAudioSampleRate(value: unknown): number {
|
|
821
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
822
|
+
throw new Error("tts.audio sampleRateHz must be a positive integer");
|
|
823
|
+
}
|
|
824
|
+
return value;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function optionalSequence(value: unknown): number | undefined {
|
|
828
|
+
if (value === undefined) return undefined;
|
|
829
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
830
|
+
throw new Error("Websocket audio sequence must be a non-negative integer");
|
|
831
|
+
}
|
|
832
|
+
return value;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function requiredJsonAudioSampleRate(value: unknown): number {
|
|
836
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
837
|
+
throw new Error("JSON websocket audio sampleRateHz must be a positive integer");
|
|
838
|
+
}
|
|
839
|
+
return value;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function sessionIdFromRequest(request: IncomingMessage): string | null {
|
|
843
|
+
const url = request.url;
|
|
844
|
+
if (!url) return null;
|
|
845
|
+
try {
|
|
846
|
+
const parsed = new URL(url, "http://localhost");
|
|
847
|
+
return parsed.searchParams.get("sessionId") ?? parsed.searchParams.get("session_id");
|
|
848
|
+
} catch {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function sanitizeSessionId(value: string): string {
|
|
854
|
+
const trimmed = value.trim();
|
|
855
|
+
if (/^[A-Za-z0-9_.:-]{1,128}$/.test(trimmed)) return trimmed;
|
|
856
|
+
return defaultSessionId();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function sendJson(socket: WebSocket, value: unknown, maxBufferedAmountBytes: number): void {
|
|
860
|
+
sendSocketData(socket, JSON.stringify(value), maxBufferedAmountBytes);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function sendJsonWithResult(socket: WebSocket, value: unknown, maxBufferedAmountBytes: number): boolean {
|
|
864
|
+
return sendSocketDataWithResult(socket, JSON.stringify(value), maxBufferedAmountBytes);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function sendSocketData(socket: WebSocket, data: string | Buffer, maxBufferedAmountBytes: number): void {
|
|
868
|
+
sendSocketDataWithResult(socket, data, maxBufferedAmountBytes);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function sendSocketDataWithResult(socket: WebSocket, data: string | Buffer, maxBufferedAmountBytes: number): boolean {
|
|
872
|
+
if (socket.readyState !== WebSocket.OPEN) return false;
|
|
873
|
+
if (socket.bufferedAmount + outboundMessageByteLength(data) > maxBufferedAmountBytes) {
|
|
874
|
+
closeWebSocketWithFallback(socket, 1013, "websocket send buffer exceeded");
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
socket.send(data);
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function outboundMessageByteLength(data: string | Buffer): number {
|
|
882
|
+
return typeof data === "string" ? Buffer.byteLength(data, "utf8") : data.byteLength;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function defaultContextId(): string {
|
|
886
|
+
return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function defaultSessionId(): string {
|
|
890
|
+
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
891
|
+
}
|