@kuralle-syrinx/server-websocket 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/package.json +31 -0
  3. package/src/admission-control.test.ts +247 -0
  4. package/src/browser-opus.test.ts +41 -0
  5. package/src/browser-opus.ts +59 -0
  6. package/src/browser-pacing.test.ts +440 -0
  7. package/src/edge-twilio.test.ts +215 -0
  8. package/src/edge-twilio.ts +285 -0
  9. package/src/edge.test.ts +272 -0
  10. package/src/edge.ts +652 -0
  11. package/src/graceful-drain.test.ts +306 -0
  12. package/src/inbound-audio.ts +102 -0
  13. package/src/index.test.ts +2071 -0
  14. package/src/index.ts +891 -0
  15. package/src/json-message.ts +39 -0
  16. package/src/outbound-playout-pipeline.test.ts +208 -0
  17. package/src/outbound-playout-pipeline.ts +167 -0
  18. package/src/paced-playout.test.ts +247 -0
  19. package/src/paced-playout.ts +161 -0
  20. package/src/playout-progress.test.ts +56 -0
  21. package/src/playout-progress.ts +67 -0
  22. package/src/session-store.test.ts +274 -0
  23. package/src/session-store.ts +123 -0
  24. package/src/smartpbx.test.ts +967 -0
  25. package/src/smartpbx.ts +531 -0
  26. package/src/telnyx.test.ts +1413 -0
  27. package/src/telnyx.ts +708 -0
  28. package/src/test-helpers.ts +264 -0
  29. package/src/transport-helpers.ts +78 -0
  30. package/src/transport-host.test.ts +51 -0
  31. package/src/transport-host.ts +213 -0
  32. package/src/turn-metrics.test.ts +327 -0
  33. package/src/turn-metrics.ts +193 -0
  34. package/src/twilio.test.ts +1275 -0
  35. package/src/twilio.ts +599 -0
  36. package/src/websocket-close.test.ts +63 -0
  37. package/src/websocket-close.ts +68 -0
  38. package/src/websocket-lifecycle.test.ts +49 -0
  39. package/src/websocket-lifecycle.ts +105 -0
  40. package/src/websocket-upgrade.ts +102 -0
  41. package/tsconfig.json +22 -0
  42. package/vitest.config.ts +7 -0
package/src/edge.ts ADDED
@@ -0,0 +1,652 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import {
4
+ Route,
5
+ SYRINX_AUDIO_ENVELOPE_NAME,
6
+ encodeSyrinxAudioEnvelope,
7
+ type TextToSpeechAudioPacket,
8
+ type TextToSpeechEndPacket,
9
+ type UserAudioReceivedPacket,
10
+ type UserTextReceivedPacket,
11
+ type VoiceAgentSession,
12
+ type VoiceAgentSessionEvents,
13
+ } from "@kuralle-syrinx/core";
14
+ import { TimerScheduler, type Scheduler } from "@kuralle-syrinx/core";
15
+ import { type StreamingPcm16Resampler } from "@kuralle-syrinx/core/audio";
16
+ import {
17
+ createWorkersInboundSocket,
18
+ type WorkersDurableObjectWebSocketContext,
19
+ type WorkersInboundSocketController,
20
+ } from "@kuralle-syrinx/ws/workers";
21
+ import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
22
+ import {
23
+ type AudioSequenceState,
24
+ type ManagedSession,
25
+ type SessionStore,
26
+ } from "./session-store.js";
27
+ import {
28
+ decodeInboundBinaryAudio,
29
+ rememberContextSampleRate,
30
+ resampleAudioBytes,
31
+ socketDataToBytes,
32
+ } from "./inbound-audio.js";
33
+
34
+ /**
35
+ * Sink for per-conversation audio recording. The edge taps inbound caller audio
36
+ * and outbound TTS audio and hands raw PCM16 frames to this sink; the concrete
37
+ * implementation (e.g. an R2-backed recorder in the Workers host) decides where
38
+ * and how to persist. Kept runtime-agnostic here — no storage types leak into
39
+ * the transport layer.
40
+ */
41
+ export interface EdgeRecorder {
42
+ onUserAudio(contextId: string, audio: Uint8Array, sampleRateHz: number): void;
43
+ onAssistantAudio(contextId: string, audio: Uint8Array, sampleRateHz: number): void;
44
+ finalize(meta: { sessionId: string; closedAtMs: number }): void | Promise<void>;
45
+ }
46
+
47
+ export interface VoiceEdgeWebSocketOptions {
48
+ readonly createSession: (request: Request) => VoiceAgentSession | Promise<VoiceAgentSession>;
49
+ readonly recorder?: EdgeRecorder;
50
+ readonly sessionId?: (request: Request) => string;
51
+ readonly contextId?: () => string;
52
+ readonly inputSampleRateHz?: number;
53
+ readonly outputSampleRateHz?: number;
54
+ readonly startupTimeoutMs?: number;
55
+ readonly maxSessionDurationMs?: number;
56
+ readonly maxInboundMessageBytes?: number;
57
+ readonly resumeWindowMs?: number;
58
+ /**
59
+ * Heartbeat cadence. While a connection is open the edge re-arms a scheduler
60
+ * task at this interval; on the Workers DO scheduler that alarm keeps the
61
+ * Durable Object from being evicted mid-call (the equivalent of Cloudflare
62
+ * agents' `keepAlive()` lease), and it is where idle/stale connections are
63
+ * detected. 0 disables the heartbeat.
64
+ */
65
+ readonly keepAliveIntervalMs?: number;
66
+ /**
67
+ * Close a connection that has sent no message within this window. Catches
68
+ * half-open clients (network dropped with no close frame) that the standard
69
+ * WebSocket cannot detect via a ping frame. 0 disables idle close.
70
+ */
71
+ readonly idleTimeoutMs?: number;
72
+ /**
73
+ * Raw binary inbound PCM16 lacks turn, sample-rate, sequence, and duration
74
+ * metadata. Keep disabled for production clients; use JSON audio frames or
75
+ * the Syrinx binary envelope instead.
76
+ */
77
+ readonly rawBinaryInput?: boolean;
78
+ readonly sessionStore: SessionStore;
79
+ readonly scheduler?: Scheduler;
80
+ }
81
+
82
+ export interface VoiceEdgeWebSocketUpgrade {
83
+ readonly response: Response;
84
+ readonly controller?: WorkersInboundSocketController;
85
+ }
86
+
87
+ type ClientMessage =
88
+ | { readonly type: "text"; readonly text: string; readonly contextId?: string }
89
+ | { readonly type: "audio"; readonly audio: string; readonly contextId?: string; readonly sampleRateHz: number; readonly sequence?: number }
90
+ | { readonly type: "client_interrupt"; readonly assistantContextId?: string; readonly contextId?: string }
91
+ | { readonly type: "codec_capability"; readonly downlinkEncoding: "pcm_s16le" | "opus" }
92
+ | { readonly type: "ping" };
93
+
94
+ interface EdgeConnectionState {
95
+ managed: ManagedSession | null;
96
+ readonly initialContextId: string;
97
+ readonly streamingResamplers: Map<string, StreamingPcm16Resampler>;
98
+ }
99
+
100
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
101
+ const DEFAULT_MAX_SESSION_DURATION_MS = 30 * 60_000;
102
+ const DEFAULT_MAX_INBOUND_MESSAGE_BYTES = 2 * 1024 * 1024;
103
+ const DEFAULT_RESUME_WINDOW_MS = 15_000;
104
+ const DEFAULT_KEEP_ALIVE_INTERVAL_MS = 15_000;
105
+ const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
106
+ const KEEP_ALIVE_KEY = "voice.edge.keep_alive";
107
+
108
+ export function createVoiceEdgeWebSocketResponse(
109
+ request: Request,
110
+ options: VoiceEdgeWebSocketOptions,
111
+ ctx?: WorkersDurableObjectWebSocketContext,
112
+ ): Response {
113
+ return createVoiceEdgeWebSocketUpgrade(request, options, ctx).response;
114
+ }
115
+
116
+ export function createVoiceEdgeWebSocketUpgrade(
117
+ request: Request,
118
+ options: VoiceEdgeWebSocketOptions,
119
+ ctx?: WorkersDurableObjectWebSocketContext,
120
+ ): VoiceEdgeWebSocketUpgrade {
121
+ const inbound = createWorkersInboundSocket(ctx);
122
+ void runVoiceEdgeWebSocketConnection(inbound.socket, request, options);
123
+ return { response: inbound.response, controller: inbound.controller };
124
+ }
125
+
126
+ export async function runVoiceEdgeWebSocketConnection(
127
+ socket: ManagedSocket,
128
+ request: Request,
129
+ options: VoiceEdgeWebSocketOptions,
130
+ ): Promise<void> {
131
+ const scheduler = options.scheduler ?? new TimerScheduler();
132
+ const sessionIdFn = options.sessionId ?? defaultSessionId;
133
+ const contextIdFn = options.contextId ?? defaultContextId;
134
+ const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? 16000;
135
+ const outputSampleRateHz = positiveInteger(options.outputSampleRateHz) ?? 16000;
136
+ const startupTimeoutMs = nonNegativeInteger(options.startupTimeoutMs) ?? DEFAULT_STARTUP_TIMEOUT_MS;
137
+ const maxSessionDurationMs = nonNegativeInteger(options.maxSessionDurationMs) ?? DEFAULT_MAX_SESSION_DURATION_MS;
138
+ const maxInboundMessageBytes = positiveInteger(options.maxInboundMessageBytes) ?? DEFAULT_MAX_INBOUND_MESSAGE_BYTES;
139
+ const resumeWindowMs = nonNegativeInteger(options.resumeWindowMs) ?? DEFAULT_RESUME_WINDOW_MS;
140
+ const keepAliveIntervalMs = nonNegativeInteger(options.keepAliveIntervalMs) ?? DEFAULT_KEEP_ALIVE_INTERVAL_MS;
141
+ const idleTimeoutMs = nonNegativeInteger(options.idleTimeoutMs) ?? DEFAULT_IDLE_TIMEOUT_MS;
142
+ const rawBinaryInput = options.rawBinaryInput ?? false;
143
+ const state: EdgeConnectionState = {
144
+ managed: null,
145
+ initialContextId: contextIdFn(),
146
+ streamingResamplers: new Map(),
147
+ };
148
+ const disposers: Array<() => void> = [];
149
+ const pendingMessages: Array<{ data: SocketData; isBinary: boolean; byteLength: number }> = [];
150
+ let pendingMessageBytes = 0;
151
+ let ready = false;
152
+ let closed = false;
153
+ let maxSessionTimedOut = false;
154
+ let lastClientMessageMs = Date.now();
155
+ let session: VoiceAgentSession | null = null;
156
+
157
+ const sendError = (message: string): void => {
158
+ sendJson(socket, {
159
+ type: "error",
160
+ component: "transport",
161
+ category: "invalid_input",
162
+ message,
163
+ });
164
+ };
165
+
166
+ const processMessage = (data: SocketData, isBinary: boolean): void => {
167
+ if (!session || !state.managed) return;
168
+ const managed = state.managed;
169
+ options.sessionStore.update(managed.id, (stored) => {
170
+ stored.currentContextId = handleClientMessage(
171
+ session!,
172
+ data,
173
+ isBinary,
174
+ stored.currentContextId,
175
+ contextIdFn,
176
+ inputSampleRateHz,
177
+ rawBinaryInput,
178
+ stored.contextSampleRates,
179
+ stored.inputSequence,
180
+ state.streamingResamplers,
181
+ );
182
+ });
183
+ };
184
+
185
+ socket.onMessage((data, isBinary) => {
186
+ try {
187
+ lastClientMessageMs = Date.now();
188
+ const byteLength = socketDataByteLength(data);
189
+ if (byteLength > maxInboundMessageBytes) {
190
+ sendError(`Websocket message exceeds maxInboundMessageBytes (${String(maxInboundMessageBytes)})`);
191
+ socket.dispose();
192
+ return;
193
+ }
194
+ if (!ready) {
195
+ pendingMessageBytes += byteLength;
196
+ if (pendingMessageBytes > maxInboundMessageBytes) {
197
+ sendError(`Pending websocket input exceeds maxInboundMessageBytes (${String(maxInboundMessageBytes)}) before session ready`);
198
+ socket.dispose();
199
+ return;
200
+ }
201
+ pendingMessages.push({ data: cloneSocketData(data), isBinary, byteLength });
202
+ return;
203
+ }
204
+ processMessage(data, isBinary);
205
+ } catch (err) {
206
+ sendError(err instanceof Error ? err.message : String(err));
207
+ }
208
+ });
209
+
210
+ socket.onClose(() => {
211
+ closed = true;
212
+ for (const dispose of disposers.splice(0)) dispose();
213
+ if (state.managed) {
214
+ state.managed.connectionCount = Math.max(0, state.managed.connectionCount - 1);
215
+ void options.sessionStore.release(state.managed.id, maxSessionTimedOut ? 0 : resumeWindowMs);
216
+ }
217
+ if (options.recorder) {
218
+ void Promise.resolve(
219
+ options.recorder.finalize({ sessionId: state.managed?.id ?? "unknown", closedAtMs: Date.now() }),
220
+ ).catch(() => undefined);
221
+ }
222
+ });
223
+
224
+ try {
225
+ const requestedSessionId = sanitizeSessionId(sessionIdFromRequest(request) ?? sessionIdFn(request));
226
+ const leased = await withScheduledTimeout(
227
+ options.sessionStore.lease(requestedSessionId, async () => {
228
+ const sess = await options.createSession(request);
229
+ if (closed) {
230
+ await sess.close().catch(() => undefined);
231
+ throw new Error("websocket session startup aborted");
232
+ }
233
+ await sess.start();
234
+ if (closed) {
235
+ await sess.close().catch(() => undefined);
236
+ throw new Error("websocket session startup aborted");
237
+ }
238
+ return {
239
+ id: requestedSessionId,
240
+ session: sess,
241
+ currentContextId: state.initialContextId,
242
+ contextSampleRates: new Map(),
243
+ inputSequence: { lastSequence: null },
244
+ turnMetricsTurns: new Map(),
245
+ closeTimer: null,
246
+ connectionCount: 1,
247
+ };
248
+ }),
249
+ startupTimeoutMs,
250
+ scheduler,
251
+ "voice.edge.startup_timeout",
252
+ );
253
+ state.managed = leased.managed;
254
+ session = leased.managed.session;
255
+ if (closed) {
256
+ await options.sessionStore.release(leased.managed.id, 0);
257
+ return;
258
+ }
259
+ wireEdgeSessionEvents(session, socket, disposers, outputSampleRateHz, options.recorder);
260
+ if (options.recorder) {
261
+ const recorder = options.recorder;
262
+ disposers.push(
263
+ session.bus.on("user.audio_received", (pkt) => {
264
+ const audio = pkt as UserAudioReceivedPacket;
265
+ recorder.onUserAudio(audio.contextId, audio.audio, inputSampleRateHz);
266
+ }),
267
+ );
268
+ }
269
+ if (maxSessionDurationMs > 0) {
270
+ scheduler.schedule("voice.edge.max_session_duration", maxSessionDurationMs, () => {
271
+ maxSessionTimedOut = true;
272
+ sendJson(socket, {
273
+ type: "error",
274
+ component: "transport",
275
+ category: "session_timeout",
276
+ message: "Websocket max session duration exceeded",
277
+ });
278
+ socket.dispose();
279
+ });
280
+ disposers.push(() => scheduler.cancel("voice.edge.max_session_duration"));
281
+ }
282
+ if (keepAliveIntervalMs > 0) {
283
+ const heartbeat = (): void => {
284
+ if (closed) return;
285
+ if (idleTimeoutMs > 0 && Date.now() - lastClientMessageMs > idleTimeoutMs) {
286
+ sendJson(socket, {
287
+ type: "error",
288
+ component: "transport",
289
+ category: "idle_timeout",
290
+ message: `Websocket idle for more than idleTimeoutMs (${String(idleTimeoutMs)})`,
291
+ });
292
+ socket.dispose();
293
+ return;
294
+ }
295
+ // Re-arm: on the Workers DO scheduler this keeps the alarm (and thus the
296
+ // Durable Object) alive while the client is active; on Node it is a plain
297
+ // interval. Cancelled on close so an idle DO can be evicted.
298
+ scheduler.schedule(KEEP_ALIVE_KEY, keepAliveIntervalMs, heartbeat);
299
+ };
300
+ scheduler.schedule(KEEP_ALIVE_KEY, keepAliveIntervalMs, heartbeat);
301
+ disposers.push(() => scheduler.cancel(KEEP_ALIVE_KEY));
302
+ }
303
+ sendJson(socket, {
304
+ type: "ready",
305
+ sessionId: leased.managed.id,
306
+ turnId: leased.managed.currentContextId,
307
+ resumed: leased.resumed,
308
+ resumeWindowMs,
309
+ maxSessionDurationMs,
310
+ audio: {
311
+ inputSampleRateHz,
312
+ outputSampleRateHz,
313
+ encoding: "pcm_s16le",
314
+ supportedInputCodecs: ["pcm_s16le"],
315
+ channels: 1,
316
+ binaryEnvelope: SYRINX_AUDIO_ENVELOPE_NAME,
317
+ rawBinaryInput,
318
+ maxInboundMessageBytes,
319
+ },
320
+ });
321
+ ready = true;
322
+ for (const pending of pendingMessages.splice(0)) {
323
+ pendingMessageBytes -= pending.byteLength;
324
+ processMessage(pending.data, pending.isBinary);
325
+ }
326
+ } catch (err) {
327
+ sendJson(socket, {
328
+ type: "error",
329
+ component: "session",
330
+ category: "initialization",
331
+ message: err instanceof Error ? err.message : String(err),
332
+ });
333
+ socket.dispose();
334
+ }
335
+ }
336
+
337
+ function wireEdgeSessionEvents(
338
+ session: VoiceAgentSession,
339
+ socket: ManagedSocket,
340
+ disposers: Array<() => void>,
341
+ outputSampleRateHz: number,
342
+ recorder?: EdgeRecorder,
343
+ ): void {
344
+ const onSession = <K extends keyof VoiceAgentSessionEvents>(
345
+ event: K,
346
+ handler: VoiceAgentSessionEvents[K],
347
+ ): void => {
348
+ session.on(event, handler);
349
+ disposers.push(() => session.off(event, handler));
350
+ };
351
+
352
+ onSession("user_input_final", (event) => {
353
+ // Skip empty transcripts — a trailing-silence turn (e.g. realtime VAD re-triggering on silence)
354
+ // produces an empty user input that would render as a blank transcript bubble.
355
+ if (!event.text.trim()) return;
356
+ sendJson(socket, { type: "stt_output", turnId: event.turnId, transcript: event.text, confidence: event.confidence });
357
+ });
358
+ onSession("agent_text_delta", (event) => {
359
+ sendJson(socket, { type: "agent_chunk", turnId: event.turnId, text: event.delta });
360
+ });
361
+ onSession("agent_finished", (event) => {
362
+ sendJson(socket, { type: "agent_end", turnId: event.turnId });
363
+ });
364
+ onSession("error", (event) => {
365
+ sendJson(socket, { type: "error", component: event.stage, category: event.category, message: event.message });
366
+ });
367
+
368
+ disposers.push(
369
+ session.bus.on("tts.audio", (pkt) => {
370
+ const audio = pkt as TextToSpeechAudioPacket;
371
+ const ttsSampleRate = requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz;
372
+ recorder?.onAssistantAudio(audio.contextId, audio.audio, ttsSampleRate);
373
+ sendJson(socket, {
374
+ type: "tts_chunk",
375
+ turnId: audio.contextId,
376
+ sequence: 1,
377
+ sampleRateHz: requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz,
378
+ encoding: "pcm_s16le",
379
+ channels: 1,
380
+ byteLength: audio.audio.byteLength,
381
+ durationMs: pcm16DurationMs(audio.audio, outputSampleRateHz),
382
+ });
383
+ socket.send(encodeSyrinxAudioEnvelope({
384
+ type: "audio",
385
+ contextId: audio.contextId,
386
+ sequence: 1,
387
+ sampleRateHz: requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz,
388
+ encoding: "pcm_s16le",
389
+ channels: 1,
390
+ byteLength: audio.audio.byteLength,
391
+ durationMs: pcm16DurationMs(audio.audio, outputSampleRateHz),
392
+ }, audio.audio));
393
+ }),
394
+ session.bus.on("tts.end", (pkt) => {
395
+ const end = pkt as TextToSpeechEndPacket;
396
+ sendJson(socket, { type: "tts_end", ...(end.contextId ? { turnId: end.contextId } : {}) });
397
+ }),
398
+ session.bus.on("eos.turn_complete", (pkt) => {
399
+ const turn = pkt as { contextId: string; text?: string };
400
+ sendJson(socket, { type: "turn_complete", turnId: turn.contextId, transcript: turn.text ?? "" });
401
+ }),
402
+ // Barge-in: tell the client to flush queued playout immediately (same wire
403
+ // messages as the Node server path — the browser client flushes its jitter
404
+ // buffer on either).
405
+ session.bus.on("interrupt.detected", (pkt) => {
406
+ const interrupt = pkt as { contextId: string };
407
+ sendJson(socket, { type: "audio_clear", turnId: interrupt.contextId, reason: "barge_in" });
408
+ sendJson(socket, { type: "agent_interrupted", turnId: interrupt.contextId, reason: "barge_in" });
409
+ }),
410
+ );
411
+ }
412
+
413
+ function handleClientMessage(
414
+ session: VoiceAgentSession,
415
+ data: SocketData,
416
+ isBinary: boolean,
417
+ currentContextId: string,
418
+ contextId: () => string,
419
+ inputSampleRateHz: number,
420
+ rawBinaryInput: boolean,
421
+ contextSampleRates: Map<string, number>,
422
+ inputSequence: AudioSequenceState,
423
+ streamingResamplers: Map<string, StreamingPcm16Resampler>,
424
+ ): string {
425
+ if (isBinary) {
426
+ const binaryAudio = decodeInboundBinaryAudio(
427
+ socketDataToBytes(data),
428
+ inputSampleRateHz,
429
+ rawBinaryInput,
430
+ inputSampleRateHz,
431
+ streamingResamplers,
432
+ null,
433
+ );
434
+ const nextContextId = binaryAudio.contextId ?? currentContextId;
435
+ if (nextContextId !== currentContextId) {
436
+ session.bus.push(Route.Main, {
437
+ kind: "turn.change",
438
+ contextId: nextContextId,
439
+ previousContextId: currentContextId,
440
+ reason: "websocket_binary_audio_turn",
441
+ timestampMs: Date.now(),
442
+ });
443
+ }
444
+ rememberContextSampleRate(contextSampleRates, nextContextId, binaryAudio.sampleRateHz);
445
+ rememberInputSequence(session, inputSequence, nextContextId, binaryAudio.sequence);
446
+ const audio = resampleAudioBytes(binaryAudio.audio, binaryAudio.sampleRateHz, inputSampleRateHz, streamingResamplers);
447
+ session.bus.push(Route.Main, {
448
+ kind: "user.audio_received",
449
+ contextId: nextContextId,
450
+ timestampMs: Date.now(),
451
+ audio,
452
+ } satisfies UserAudioReceivedPacket);
453
+ return nextContextId;
454
+ }
455
+ const message = parseClientMessage(JSON.parse(typeof data === "string" ? data : textDecoder.decode(data)));
456
+ if (message.type === "ping") return currentContextId;
457
+ if (message.type === "codec_capability") return currentContextId; // edge sends pcm_s16le downlink; no-op
458
+ if (message.type === "client_interrupt") {
459
+ const interruptedContextId = message.assistantContextId ?? message.contextId ?? currentContextId;
460
+ if (interruptedContextId) session.requestClientInterrupt(interruptedContextId);
461
+ return currentContextId;
462
+ }
463
+ if (message.type === "text") {
464
+ const nextContextId = message.contextId ?? contextId();
465
+ session.bus.push(Route.Main, {
466
+ kind: "turn.change",
467
+ contextId: nextContextId,
468
+ previousContextId: currentContextId,
469
+ reason: "websocket_text_turn",
470
+ timestampMs: Date.now(),
471
+ });
472
+ session.bus.push(Route.Main, {
473
+ kind: "user.text_received",
474
+ contextId: nextContextId,
475
+ timestampMs: Date.now(),
476
+ text: message.text,
477
+ } satisfies UserTextReceivedPacket);
478
+ return nextContextId;
479
+ }
480
+ const nextContextId = message.contextId ?? currentContextId;
481
+ rememberInputSequence(session, inputSequence, nextContextId, message.sequence);
482
+ session.bus.push(Route.Main, {
483
+ kind: "user.audio_received",
484
+ contextId: nextContextId,
485
+ timestampMs: Date.now(),
486
+ audio: decodeBase64(message.audio),
487
+ } satisfies UserAudioReceivedPacket);
488
+ return nextContextId;
489
+ }
490
+
491
+ function parseClientMessage(value: unknown): ClientMessage {
492
+ if (!isRecord(value)) throw new Error("Websocket JSON message must be an object");
493
+ if (value.type === "ping") return { type: "ping" };
494
+ if (value.type === "codec_capability") {
495
+ // The edge transmits pcm_s16le downlink only (no opus encoder on workerd); accept the client's
496
+ // capability advert and no-op it. The client decodes per-frame `encoding`, so pcm is always safe.
497
+ const downlinkEncoding = value.downlinkEncoding === "opus" ? "opus" : "pcm_s16le";
498
+ return { type: "codec_capability", downlinkEncoding };
499
+ }
500
+ if (value.type === "client_interrupt") {
501
+ return {
502
+ type: "client_interrupt",
503
+ assistantContextId: optionalString(value.assistantContextId),
504
+ contextId: optionalString(value.contextId),
505
+ };
506
+ }
507
+ if (value.type === "text") {
508
+ const text = optionalString(value.text);
509
+ if (!text) throw new Error("Websocket JSON text must be a non-empty string");
510
+ return { type: "text", text, contextId: optionalString(value.contextId) };
511
+ }
512
+ if (value.type === "audio") {
513
+ const audio = optionalString(value.audio);
514
+ const sampleRateHz = positiveInteger(value.sampleRateHz);
515
+ if (!audio) throw new Error("Websocket JSON audio must be a non-empty base64 string");
516
+ if (!sampleRateHz) throw new Error("JSON websocket audio sampleRateHz must be a positive integer");
517
+ return {
518
+ type: "audio",
519
+ audio,
520
+ contextId: optionalString(value.contextId),
521
+ sampleRateHz,
522
+ sequence: nonNegativeInteger(value.sequence) ?? undefined,
523
+ };
524
+ }
525
+ throw new Error("Unsupported client message type");
526
+ }
527
+
528
+ function rememberInputSequence(
529
+ session: VoiceAgentSession,
530
+ state: AudioSequenceState,
531
+ contextId: string,
532
+ sequence: number | undefined,
533
+ ): void {
534
+ if (sequence === undefined) return;
535
+ const previous = state.lastSequence;
536
+ if (previous !== null && sequence <= previous) {
537
+ throw new Error(`Websocket audio sequence must increase monotonically: ${String(previous)} -> ${String(sequence)}`);
538
+ }
539
+ if (previous !== null && sequence > previous + 1) {
540
+ session.bus.push(Route.Background, {
541
+ kind: "metric.conversation",
542
+ contextId,
543
+ timestampMs: Date.now(),
544
+ name: "websocket.audio_sequence_gap",
545
+ value: JSON.stringify({ expected: previous + 1, actual: sequence, missed: sequence - previous - 1 }),
546
+ });
547
+ }
548
+ state.lastSequence = sequence;
549
+ }
550
+
551
+ function sendJson(socket: ManagedSocket, value: unknown): void {
552
+ if (!socket.isOpen) return;
553
+ socket.send(JSON.stringify(value));
554
+ }
555
+
556
+ const textEncoder = new TextEncoder();
557
+ const textDecoder = new TextDecoder();
558
+
559
+ function decodeBase64(value: string): Uint8Array {
560
+ const raw = globalThis.atob(value);
561
+ const bytes = new Uint8Array(raw.length);
562
+ for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
563
+ return bytes;
564
+ }
565
+
566
+ function socketDataByteLength(data: SocketData): number {
567
+ return typeof data === "string" ? textEncoder.encode(data).byteLength : data.byteLength;
568
+ }
569
+
570
+ function cloneSocketData(data: SocketData): SocketData {
571
+ return typeof data === "string" ? data : data.slice();
572
+ }
573
+
574
+ function sessionIdFromRequest(request: Request): string | null {
575
+ try {
576
+ const parsed = new URL(request.url);
577
+ return parsed.searchParams.get("sessionId") ?? parsed.searchParams.get("session_id");
578
+ } catch {
579
+ return null;
580
+ }
581
+ }
582
+
583
+ function sanitizeSessionId(value: string): string {
584
+ const trimmed = value.trim();
585
+ if (/^[A-Za-z0-9_.:-]{1,128}$/.test(trimmed)) return trimmed;
586
+ return defaultSessionId();
587
+ }
588
+
589
+ function isRecord(value: unknown): value is Record<string, unknown> {
590
+ return typeof value === "object" && value !== null;
591
+ }
592
+
593
+ function optionalString(value: unknown): string | undefined {
594
+ return typeof value === "string" && value.length > 0 ? value : undefined;
595
+ }
596
+
597
+ function positiveInteger(value: unknown): number | null {
598
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return null;
599
+ return value;
600
+ }
601
+
602
+ function nonNegativeInteger(value: unknown): number | null {
603
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return null;
604
+ return value;
605
+ }
606
+
607
+ function requireTtsAudioSampleRate(value: unknown): number | null {
608
+ return positiveInteger(value);
609
+ }
610
+
611
+ function pcm16DurationMs(audio: Uint8Array, sampleRateHz: number): number {
612
+ if (sampleRateHz <= 0) return 0;
613
+ return Math.round((audio.byteLength / 2 / sampleRateHz) * 1000);
614
+ }
615
+
616
+ function defaultContextId(): string {
617
+ return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
618
+ }
619
+
620
+ function defaultSessionId(): string {
621
+ return `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
622
+ }
623
+
624
+ async function withScheduledTimeout<T>(
625
+ promise: Promise<T>,
626
+ timeoutMs: number,
627
+ scheduler: Scheduler,
628
+ key: string,
629
+ ): Promise<T> {
630
+ if (timeoutMs <= 0) return await promise;
631
+ let settled = false;
632
+ return await new Promise<T>((resolve, reject) => {
633
+ scheduler.schedule(key, timeoutMs, () => {
634
+ if (settled) return;
635
+ settled = true;
636
+ reject(new Error(`websocket session startup exceeded ${String(timeoutMs)}ms`));
637
+ });
638
+ promise
639
+ .then((value) => {
640
+ if (settled) return;
641
+ settled = true;
642
+ scheduler.cancel(key);
643
+ resolve(value);
644
+ })
645
+ .catch((err: unknown) => {
646
+ if (settled) return;
647
+ settled = true;
648
+ scheduler.cancel(key);
649
+ reject(err);
650
+ });
651
+ });
652
+ }