@kuralle-syrinx/server-websocket 4.1.0 → 4.3.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.
@@ -0,0 +1,389 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Telnyx Media Streaming ingress for the Workers edge: bridges Telnyx's WebSocket
4
+ // protocol (base64 RTP-in-JSON, PCMU/PCMA/G722/L16) to a VoiceAgentSession, mirroring
5
+ // the session-lease/heartbeat pattern of edge-twilio.ts. Provider-agnostic — the bridge
6
+ // only speaks core packets (user.audio_received / tts.audio / interrupt.detected).
7
+ //
8
+ // Codec transcoding is shared with the Node host via telnyx-codec.ts (Workers-safe).
9
+ // Live trunk negotiation (streaming_start → /telnyx) is carrier-gated / unit-tested only.
10
+ //
11
+ // Barge-in: interrupt.detected → Telnyx `clear` event. Playout-clock caveat: like the
12
+ // browser/Twilio edge paths, turn-taking uses the estimate fallback (no paced transport
13
+ // progress events on the edge yet).
14
+
15
+ import {
16
+ Route,
17
+ TimerScheduler,
18
+ type Scheduler,
19
+ type VoiceAgentSession,
20
+ type TextToSpeechAudioPacket,
21
+ } from "@kuralle-syrinx/core";
22
+ import {
23
+ pcm16BytesToSamples,
24
+ pcm16SamplesToBytes,
25
+ resamplePcm16Streaming,
26
+ type StreamingPcm16Resampler,
27
+ } from "@kuralle-syrinx/core/audio";
28
+ import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
29
+ import {
30
+ BackgroundAudioMixer,
31
+ wireBackgroundAudio,
32
+ type BackgroundAudioConfig,
33
+ } from "./background-audio.js";
34
+ import {
35
+ createWorkersInboundSocket,
36
+ type WorkersDurableObjectWebSocketContext,
37
+ } from "@kuralle-syrinx/ws/workers";
38
+ import type { SessionStore, ManagedSession } from "./session-store.js";
39
+ import {
40
+ createTelnyxG722State,
41
+ decodeTelnyxInboundPayload,
42
+ defaultTelnyxContextId,
43
+ encodeTelnyxOutboundPayload,
44
+ validateTelnyxStart,
45
+ wireSampleRateForCodec,
46
+ type TelnyxCodec,
47
+ type TelnyxG722State,
48
+ type TelnyxStartPayload,
49
+ } from "./telnyx-codec.js";
50
+
51
+ const DEFAULT_RESUME_WINDOW_MS = 15_000;
52
+ const DEFAULT_KEEP_ALIVE_INTERVAL_MS = 15_000;
53
+ const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
54
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
55
+ const KEEP_ALIVE_KEY = "voice.edge.telnyx.keep_alive";
56
+
57
+ export interface TelnyxEdgeWebSocketOptions {
58
+ readonly sessionStore: SessionStore;
59
+ readonly createSession: (request: Request) => VoiceAgentSession | Promise<VoiceAgentSession>;
60
+ readonly scheduler?: Scheduler;
61
+ /** Engine-side PCM rate (default 16000). Wire rate follows the negotiated codec. */
62
+ readonly engineSampleRateHz?: number;
63
+ /**
64
+ * Default bidirectional codec when `start.media_format` is absent (should match the
65
+ * `stream_bidirectional_codec` used when starting the Telnyx stream). @default "PCMU"
66
+ */
67
+ readonly bidirectionalCodec?: TelnyxCodec;
68
+ /**
69
+ * Ambient/thinking bed: mixed (ducked) under assistant speech, and sent as
70
+ * comfort-noise frames between turns — pure digital silence on a phone line
71
+ * reads as "the call died". Thinking loop follows the G3 tool-call cues.
72
+ */
73
+ readonly backgroundAudio?: BackgroundAudioConfig;
74
+ /** Cadence of idle comfort-noise frames (ms). @default 200 */
75
+ readonly backgroundIdleFrameMs?: number;
76
+ readonly resumeWindowMs?: number;
77
+ readonly keepAliveIntervalMs?: number;
78
+ readonly idleTimeoutMs?: number;
79
+ readonly startupTimeoutMs?: number;
80
+ }
81
+
82
+ export interface TelnyxEdgeWebSocketUpgrade {
83
+ readonly response: Response;
84
+ readonly controller: ReturnType<typeof createWorkersInboundSocket>["controller"];
85
+ }
86
+
87
+ export function createTelnyxEdgeWebSocketUpgrade(
88
+ request: Request,
89
+ options: TelnyxEdgeWebSocketOptions,
90
+ ctx?: WorkersDurableObjectWebSocketContext,
91
+ ): TelnyxEdgeWebSocketUpgrade {
92
+ const inbound = createWorkersInboundSocket(ctx);
93
+ void runTelnyxEdgeWebSocketConnection(inbound.socket, request, options);
94
+ return { response: inbound.response, controller: inbound.controller };
95
+ }
96
+
97
+ export async function runTelnyxEdgeWebSocketConnection(
98
+ socket: ManagedSocket,
99
+ request: Request,
100
+ options: TelnyxEdgeWebSocketOptions,
101
+ ): Promise<void> {
102
+ const scheduler = options.scheduler ?? new TimerScheduler();
103
+ const engineRate = options.engineSampleRateHz ?? 16_000;
104
+ const defaultCodec: TelnyxCodec = options.bidirectionalCodec ?? "PCMU";
105
+ const resumeWindowMs = options.resumeWindowMs ?? DEFAULT_RESUME_WINDOW_MS;
106
+ const keepAliveIntervalMs = options.keepAliveIntervalMs ?? DEFAULT_KEEP_ALIVE_INTERVAL_MS;
107
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
108
+ const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
109
+
110
+ const uplinkResamplers = new Map<string, StreamingPcm16Resampler>();
111
+ const downlinkResamplers = new Map<string, StreamingPcm16Resampler>();
112
+ const disposers: Array<() => void> = [];
113
+ let session: VoiceAgentSession | null = null;
114
+ let managed: ManagedSession | null = null;
115
+ let pendingLease: Promise<{ managed: ManagedSession }> | null = null;
116
+ let sessionId = "";
117
+ let streamId = "";
118
+ let contextId = "";
119
+ let contextBase = "";
120
+ let turnCounter = 0;
121
+ let closed = false;
122
+ let stopped = false;
123
+ let lastClientMessageMs = Date.now();
124
+ let wireCodec: TelnyxCodec = defaultCodec;
125
+ let wireSampleRateHz = wireSampleRateForCodec(defaultCodec);
126
+ let g722: TelnyxG722State = createTelnyxG722State(defaultCodec);
127
+ let started = false;
128
+
129
+ const sendTelnyxJson = (value: unknown): void => {
130
+ if (!socket.isOpen) return;
131
+ socket.send(JSON.stringify(value));
132
+ };
133
+
134
+ const encodeWirePayload = (samplesAtWireRate: Int16Array): Uint8Array =>
135
+ encodeTelnyxOutboundPayload(samplesAtWireRate, wireCodec, g722);
136
+
137
+ const cleanup = (): void => {
138
+ if (closed) return;
139
+ closed = true;
140
+ scheduler.cancel(KEEP_ALIVE_KEY);
141
+ scheduler.cancel("voice.edge.telnyx.startup");
142
+ for (const dispose of disposers.splice(0)) dispose();
143
+ if (managed && sessionId) {
144
+ // Decrement the connection count BEFORE releasing (R1) — otherwise release
145
+ // early-returns while connectionCount > 0 and session.close() never runs, so
146
+ // Deepgram/TTS provider sockets + the reasoner leak until DO eviction. A
147
+ // caller-hangup (`stopped`) releases immediately (retain 0); a transient drop
148
+ // keeps the session warm for the resume window.
149
+ managed.connectionCount = Math.max(0, managed.connectionCount - 1);
150
+ void options.sessionStore.release(sessionId, stopped ? 0 : resumeWindowMs);
151
+ } else if (pendingLease && sessionId) {
152
+ // Closed before the lease was adopted (e.g. startup-timeout race): tear the
153
+ // in-flight session down when it resolves so it is never orphaned in the store.
154
+ const id = sessionId;
155
+ void pendingLease
156
+ .then((leased) => {
157
+ leased.managed.connectionCount = Math.max(0, leased.managed.connectionCount - 1);
158
+ return options.sessionStore.release(id, 0);
159
+ })
160
+ .catch(() => undefined);
161
+ }
162
+ };
163
+
164
+ socket.onClose(() => {
165
+ cleanup();
166
+ });
167
+ socket.onError(() => {
168
+ cleanup();
169
+ });
170
+
171
+ // Telnyx sends connected/start (and media) immediately after the upgrade —
172
+ // before the session lease (provider sockets, kuralle init) resolves. Buffer
173
+ // until the handler is live or the start event is lost and the whole call
174
+ // streams into the void.
175
+ const pendingMessages: Array<{ data: SocketData; isBinary: boolean }> = [];
176
+ let pendingBytes = 0;
177
+ let liveHandler: ((data: SocketData, isBinary: boolean) => void) | null = null;
178
+ socket.onMessage((data: SocketData, isBinary: boolean) => {
179
+ lastClientMessageMs = Date.now();
180
+ if (liveHandler) {
181
+ liveHandler(data, isBinary);
182
+ return;
183
+ }
184
+ const byteLength = typeof data === "string" ? data.length : (data as ArrayBuffer | Uint8Array).byteLength;
185
+ if (pendingBytes + byteLength > 2 * 1024 * 1024) return; // cap startup buffering
186
+ pendingBytes += byteLength;
187
+ pendingMessages.push({ data, isBinary });
188
+ });
189
+
190
+ try {
191
+ const url = new URL(request.url);
192
+ sessionId = url.searchParams.get("sessionId")?.trim() || crypto.randomUUID();
193
+
194
+ const startupTimer = new Promise<never>((_, reject) => {
195
+ scheduler.schedule("voice.edge.telnyx.startup", startupTimeoutMs, () => {
196
+ reject(new Error("telnyx session startup timeout"));
197
+ });
198
+ });
199
+ pendingLease = options.sessionStore.lease(sessionId, async () => {
200
+ const sess = await options.createSession(request);
201
+ await sess.start();
202
+ return {
203
+ id: sessionId,
204
+ session: sess,
205
+ currentContextId: "",
206
+ contextSampleRates: new Map(),
207
+ inputSequence: { lastSequence: null },
208
+ turnMetricsTurns: new Map(),
209
+ closeTimer: null,
210
+ connectionCount: 1,
211
+ };
212
+ });
213
+ const leased = await Promise.race([pendingLease, startupTimer]);
214
+ pendingLease = null;
215
+ scheduler.cancel("voice.edge.telnyx.startup");
216
+ managed = leased.managed;
217
+ session = managed.session;
218
+ if (closed) {
219
+ // The socket closed while the session was starting up — tear the just-started
220
+ // session down instead of orphaning it (decrement so release actually closes it).
221
+ managed.connectionCount = Math.max(0, managed.connectionCount - 1);
222
+ await options.sessionStore.release(sessionId, 0);
223
+ return;
224
+ }
225
+
226
+ // Background bed: ducked under speech in the tts.audio handler below, and
227
+ // sent as idle comfort-noise frames between turns — a phone line carrying
228
+ // pure digital silence reads as "the call died". Telnyx's `clear` on
229
+ // barge-in drops any queued bed audio; the ticker refills on the next tick.
230
+ const backgroundAudio = options.backgroundAudio
231
+ ? new BackgroundAudioMixer(options.backgroundAudio)
232
+ : null;
233
+ if (backgroundAudio) {
234
+ wireBackgroundAudio(session, backgroundAudio);
235
+ const idleFrameMs = options.backgroundIdleFrameMs ?? 200;
236
+ const idleTimer = setInterval(() => {
237
+ if (!streamId || !started || stopped || closed || !socket.isOpen) return;
238
+ const frame = backgroundAudio.idleFrame(idleFrameMs, wireSampleRateHz);
239
+ if (!frame) return;
240
+ const samples = pcm16BytesToSamples(frame);
241
+ const payload = encodeWirePayload(samples);
242
+ sendTelnyxJson({ event: "media", media: { payload: bytesToBase64(payload) } });
243
+ }, idleFrameMs);
244
+ disposers.push(() => clearInterval(idleTimer));
245
+ }
246
+
247
+ // Downlink: engine PCM → wire codec media frames; barge-in → clear.
248
+ disposers.push(
249
+ session.bus.on("tts.audio", (pkt) => {
250
+ if (!streamId || !started) return;
251
+ const audio = pkt as TextToSpeechAudioPacket;
252
+ const sourceRate = audio.sampleRateHz ?? engineRate;
253
+ const wireAudio = backgroundAudio ? backgroundAudio.mix(audio.audio, sourceRate) : audio.audio;
254
+ const samples = pcm16BytesToSamples(wireAudio);
255
+ const resampled = resamplePcm16Streaming(downlinkResamplers, samples, sourceRate, wireSampleRateHz);
256
+ const payload = encodeWirePayload(resampled);
257
+ sendTelnyxJson({
258
+ event: "media",
259
+ media: { payload: bytesToBase64(payload) },
260
+ });
261
+ }),
262
+ session.bus.on("interrupt.detected", () => {
263
+ if (!streamId || !started) return;
264
+ sendTelnyxJson({ event: "clear" });
265
+ }),
266
+ // The phone mic streams continuously on one call, but the engine finishes a
267
+ // turn per contextId — rotate on turn_complete exactly like the browser
268
+ // client does, or only the first utterance is ever heard (the STT plugin
269
+ // drops transcripts for already-finalized contexts).
270
+ session.bus.on("eos.turn_complete", () => {
271
+ if (!contextBase) return;
272
+ turnCounter += 1;
273
+ contextId = `${contextBase}-t${String(turnCounter)}`;
274
+ }),
275
+ );
276
+
277
+ if (keepAliveIntervalMs > 0) {
278
+ const heartbeat = (): void => {
279
+ if (closed) return;
280
+ if (idleTimeoutMs > 0 && Date.now() - lastClientMessageMs > idleTimeoutMs) {
281
+ socket.dispose();
282
+ return;
283
+ }
284
+ scheduler.schedule(KEEP_ALIVE_KEY, keepAliveIntervalMs, heartbeat);
285
+ };
286
+ scheduler.schedule(KEEP_ALIVE_KEY, keepAliveIntervalMs, heartbeat);
287
+ }
288
+
289
+ const handleMessage = (data: SocketData, isBinary: boolean): void => {
290
+ if (isBinary || closed || !session) return;
291
+ let message: Record<string, unknown>;
292
+ try {
293
+ message = JSON.parse(socketDataToText(data)) as Record<string, unknown>;
294
+ } catch {
295
+ return;
296
+ }
297
+ const event = message["event"];
298
+ if (event === "connected" || event === "mark" || event === "dtmf") return;
299
+ if (event === "start") {
300
+ const start = (message["start"] ?? {}) as TelnyxStartPayload;
301
+ streamId =
302
+ (typeof message["stream_id"] === "string" && message["stream_id"]) ||
303
+ (typeof start.stream_id === "string" && start.stream_id) ||
304
+ "";
305
+ try {
306
+ if (start.media_format) {
307
+ const format = validateTelnyxStart(start);
308
+ wireCodec = format.codec;
309
+ wireSampleRateHz = format.sampleRateHz;
310
+ g722 = createTelnyxG722State(wireCodec);
311
+ } else {
312
+ wireCodec = defaultCodec;
313
+ wireSampleRateHz = wireSampleRateForCodec(defaultCodec);
314
+ g722 = createTelnyxG722State(defaultCodec);
315
+ }
316
+ } catch {
317
+ // Malformed start: keep defaults so the call can still proceed with
318
+ // the configured bidirectional codec (unit path); live trunks always
319
+ // send media_format.
320
+ }
321
+ contextBase = defaultTelnyxContextId({
322
+ ...start,
323
+ stream_id: streamId || start.stream_id,
324
+ });
325
+ contextId = contextBase;
326
+ started = true;
327
+ return;
328
+ }
329
+ if (event === "media") {
330
+ const media = (message["media"] ?? {}) as Record<string, unknown>;
331
+ const payload = typeof media["payload"] === "string" ? media["payload"] : "";
332
+ if (!payload || !contextId || !started) return;
333
+ const encoded = base64ToBytes(payload);
334
+ const pcmWire = decodeTelnyxInboundPayload(encoded, wireCodec, g722);
335
+ const pcmEngine = resamplePcm16Streaming(uplinkResamplers, pcmWire, wireSampleRateHz, engineRate);
336
+ session.bus.push(Route.Main, {
337
+ kind: "user.audio_received",
338
+ contextId,
339
+ timestampMs: Date.now(),
340
+ audio: pcm16SamplesToBytes(pcmEngine),
341
+ sampleRateHz: engineRate,
342
+ });
343
+ return;
344
+ }
345
+ if (event === "stop") {
346
+ stopped = true;
347
+ started = false;
348
+ socket.dispose();
349
+ }
350
+ };
351
+ liveHandler = handleMessage;
352
+ for (const queued of pendingMessages.splice(0)) {
353
+ handleMessage(queued.data, queued.isBinary);
354
+ }
355
+ } catch (err) {
356
+ sendTelnyxJson({
357
+ event: "error",
358
+ payload: {
359
+ code: 100003,
360
+ title: "syrinx_transport_error",
361
+ detail: err instanceof Error ? err.message : String(err),
362
+ },
363
+ });
364
+ socket.dispose();
365
+ cleanup();
366
+ }
367
+ }
368
+
369
+ function socketDataToText(data: SocketData): string {
370
+ if (typeof data === "string") return data;
371
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
372
+ return new TextDecoder().decode(data as Uint8Array);
373
+ }
374
+
375
+ function base64ToBytes(value: string): Uint8Array {
376
+ const binary = atob(value);
377
+ const bytes = new Uint8Array(binary.length);
378
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
379
+ return bytes;
380
+ }
381
+
382
+ function bytesToBase64(bytes: Uint8Array): string {
383
+ let binary = "";
384
+ const chunkSize = 0x8000;
385
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
386
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
387
+ }
388
+ return btoa(binary);
389
+ }
@@ -28,7 +28,7 @@ import {
28
28
  import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
29
29
  import {
30
30
  BackgroundAudioMixer,
31
- wireBackgroundThinking,
31
+ wireBackgroundAudio,
32
32
  type BackgroundAudioConfig,
33
33
  } from "./background-audio.js";
34
34
  import {
@@ -208,7 +208,7 @@ export async function runTwilioEdgeWebSocketConnection(
208
208
  ? new BackgroundAudioMixer(options.backgroundAudio)
209
209
  : null;
210
210
  if (backgroundAudio) {
211
- wireBackgroundThinking(session, backgroundAudio);
211
+ wireBackgroundAudio(session, backgroundAudio);
212
212
  const idleFrameMs = options.backgroundIdleFrameMs ?? 200;
213
213
  const idleTimer = setInterval(() => {
214
214
  if (!streamSid || stopped || closed || !socket.isOpen) return;
@@ -296,6 +296,7 @@ export async function runTwilioEdgeWebSocketConnection(
296
296
  contextId,
297
297
  timestampMs: Date.now(),
298
298
  audio: pcm16SamplesToBytes(pcm16k),
299
+ sampleRateHz: engineRate,
299
300
  });
300
301
  return;
301
302
  }
package/src/edge.ts CHANGED
@@ -16,7 +16,7 @@ import { TimerScheduler, type Scheduler } from "@kuralle-syrinx/core";
16
16
  import { type StreamingPcm16Resampler } from "@kuralle-syrinx/core/audio";
17
17
  import {
18
18
  BackgroundAudioMixer,
19
- wireBackgroundThinking,
19
+ wireBackgroundAudio,
20
20
  type BackgroundAudioConfig,
21
21
  } from "./background-audio.js";
22
22
  export type { BackgroundAudioConfig, BackgroundAudioSource } from "./background-audio.js";
@@ -54,6 +54,7 @@ export interface EdgeRecorder {
54
54
  export interface VoiceEdgeWebSocketOptions {
55
55
  readonly createSession: (request: Request) => VoiceAgentSession | Promise<VoiceAgentSession>;
56
56
  readonly recorder?: EdgeRecorder;
57
+ /** Explicit resolver for the session identity; when set, it takes precedence over the request query. */
57
58
  readonly sessionId?: (request: Request) => string;
58
59
  readonly contextId?: () => string;
59
60
  readonly inputSampleRateHz?: number;
@@ -143,7 +144,6 @@ export async function runVoiceEdgeWebSocketConnection(
143
144
  options: VoiceEdgeWebSocketOptions,
144
145
  ): Promise<void> {
145
146
  const scheduler = options.scheduler ?? new TimerScheduler();
146
- const sessionIdFn = options.sessionId ?? defaultSessionId;
147
147
  const contextIdFn = options.contextId ?? defaultContextId;
148
148
  const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? 16000;
149
149
  const outputSampleRateHz = positiveInteger(options.outputSampleRateHz) ?? 16000;
@@ -243,7 +243,12 @@ export async function runVoiceEdgeWebSocketConnection(
243
243
  socket.onError(teardownConnection);
244
244
 
245
245
  try {
246
- const requestedSessionId = sanitizeSessionId(sessionIdFromRequest(request) ?? sessionIdFn(request));
246
+ // An explicit resolver is authoritative. Hosts such as cf-agents resolve the
247
+ // id before creating the session so the reasoner, session store, and transport
248
+ // all use the same identity even when the request carries a different wire id.
249
+ const requestedSessionId = sanitizeSessionId(
250
+ options.sessionId?.(request) ?? sessionIdFromRequest(request) ?? defaultSessionId(),
251
+ );
247
252
  const leased = await withScheduledTimeout(
248
253
  options.sessionStore.lease(requestedSessionId, async () => {
249
254
  const sess = await options.createSession(request);
@@ -370,7 +375,7 @@ function wireEdgeSessionEvents(
370
375
  recorder?: EdgeRecorder,
371
376
  backgroundAudio?: BackgroundAudioMixer,
372
377
  ): void {
373
- if (backgroundAudio) wireBackgroundThinking(session, backgroundAudio);
378
+ if (backgroundAudio) wireBackgroundAudio(session, backgroundAudio);
374
379
  const onSession = <K extends keyof VoiceAgentSessionEvents>(
375
380
  event: K,
376
381
  handler: VoiceAgentSessionEvents[K],
@@ -494,6 +499,7 @@ function handleClientMessage(
494
499
  contextId: nextContextId,
495
500
  timestampMs: Date.now(),
496
501
  audio,
502
+ sampleRateHz: inputSampleRateHz,
497
503
  } satisfies UserAudioReceivedPacket);
498
504
  return nextContextId;
499
505
  }
@@ -557,6 +563,7 @@ function handleClientMessage(
557
563
  contextId: nextContextId,
558
564
  timestampMs: Date.now(),
559
565
  audio,
566
+ sampleRateHz: inputSampleRateHz,
560
567
  } satisfies UserAudioReceivedPacket);
561
568
  return nextContextId;
562
569
  }
package/src/index.ts CHANGED
@@ -60,12 +60,33 @@ export * from "./telnyx.js";
60
60
  export * from "./smartpbx.js";
61
61
  export * from "./session-store.js";
62
62
  export { validateTwilioSignature } from "./twilio-auth.js";
63
+ export {
64
+ buildTwilioSendDigits,
65
+ buildTelnyxSendDtmf,
66
+ buildTwilioTransfer,
67
+ buildTelnyxTransfer,
68
+ dispatchTwilioCommand,
69
+ dispatchTelnyxCommand,
70
+ resolveTransferSummary,
71
+ type TwilioSendDigitsCommand,
72
+ type TelnyxSendDtmfCommand,
73
+ type TwilioTransferCommand,
74
+ type TelnyxTransferCommand,
75
+ type TwilioRestCredentials,
76
+ type TelnyxRestCredentials,
77
+ type WarmTransferSummarizer,
78
+ type FetchLike,
79
+ } from "./carrier-commands.js";
80
+ export { wireCarrierControl, type CarrierControlOptions } from "./wire-carrier-control.js";
63
81
  export {
64
82
  BackgroundAudioMixer,
65
83
  wireBackgroundThinking,
84
+ wireBackgroundBackchannel,
85
+ wireBackgroundAudio,
66
86
  type BackgroundAudioConfig,
67
87
  type BackgroundAudioSource,
68
88
  } from "./background-audio.js";
89
+ export { buildPlaceholderBackchannelCues } from "./backchannel-cue-fixtures.js";
69
90
  export { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
70
91
 
71
92
  export interface VoiceWebSocketServerOptions {
@@ -74,6 +95,7 @@ export interface VoiceWebSocketServerOptions {
74
95
  readonly host?: string;
75
96
  readonly path?: string;
76
97
  readonly createSession: (request: IncomingMessage) => VoiceAgentSession | Promise<VoiceAgentSession>;
98
+ /** Explicit resolver for the session identity; when set, it takes precedence over the request query. */
77
99
  readonly sessionId?: (request: IncomingMessage) => string;
78
100
  readonly contextId?: () => string;
79
101
  readonly inputSampleRateHz?: number;
@@ -190,7 +212,6 @@ export async function createVoiceWebSocketServer(
190
212
  });
191
213
  const wsServer = routedWebSocket.wsServer;
192
214
  const sessionStore = options.sessionStore ?? new InMemorySessionStore();
193
- const sessionIdFn = options.sessionId ?? defaultSessionId;
194
215
  const contextIdFn = options.contextId ?? defaultContextId;
195
216
  const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? 16000;
196
217
  const outputSampleRateHz = positiveInteger(options.outputSampleRateHz) ?? 16000;
@@ -220,7 +241,12 @@ export async function createVoiceWebSocketServer(
220
241
  }),
221
242
 
222
243
  async acquireSession({ request, state, shouldAbort, onSessionCreated }) {
223
- const requestedSessionId = sanitizeSessionId(sessionIdFromRequest(request) ?? sessionIdFn(request));
244
+ // An explicit resolver is authoritative. Hosts such as cf-agents resolve the
245
+ // id before creating the session so the reasoner, session store, and transport
246
+ // all use the same identity even when the request carries a different wire id.
247
+ const requestedSessionId = sanitizeSessionId(
248
+ options.sessionId?.(request) ?? sessionIdFromRequest(request) ?? defaultSessionId(),
249
+ );
224
250
  const leased = await sessionStore.lease(requestedSessionId, async () => {
225
251
  const sess = await options.createSession(request);
226
252
  onSessionCreated(sess);
@@ -711,6 +737,7 @@ function handleClientMessage(
711
737
  contextId: nextContextId,
712
738
  timestampMs: Date.now(),
713
739
  audio,
740
+ sampleRateHz: inputSampleRateHz,
714
741
  } satisfies UserAudioReceivedPacket);
715
742
  return nextContextId;
716
743
  }
@@ -770,6 +797,7 @@ function handleClientMessage(
770
797
  contextId: nextContextId,
771
798
  timestampMs: Date.now(),
772
799
  audio: resampleAudioBytes(decodeStrictBase64(message.audio, "audio"), sourceSampleRateHz, inputSampleRateHz, streamingResamplers),
800
+ sampleRateHz: inputSampleRateHz,
773
801
  } satisfies UserAudioReceivedPacket);
774
802
  return nextContextId;
775
803
  }
@@ -3,7 +3,7 @@
3
3
  import { Route, type InterruptTtsPacket, type TextToSpeechAudioPacket, type TextToSpeechEndPacket, type VoiceAgentSession } from "@kuralle-syrinx/core";
4
4
  import { WebSocket } from "ws";
5
5
  import type { BackgroundAudioMixer } from "./background-audio.js";
6
- import { wireBackgroundThinking } from "./background-audio.js";
6
+ import { wireBackgroundAudio } from "./background-audio.js";
7
7
  import type { PacedPlayoutFrame } from "./paced-playout.js";
8
8
  import { PacedPlayoutQueue } from "./paced-playout.js";
9
9
  import { PlayoutProgressEmitter } from "./playout-progress.js";
@@ -81,7 +81,7 @@ export function wireTelephonyOutboundPipeline(args: {
81
81
  readonly backgroundAudio?: BackgroundAudioMixer;
82
82
  }): TelephonyOutboundHandle {
83
83
  const { session, socket, disposers, outboundFrameDurationMs, maxQueuedOutputAudioMs, callbacks, backgroundAudio } = args;
84
- if (backgroundAudio) wireBackgroundThinking(session, backgroundAudio);
84
+ if (backgroundAudio) wireBackgroundAudio(session, backgroundAudio);
85
85
 
86
86
  const recordDiscardedPlayout = (discardedMs: number, reason: string): void => {
87
87
  if (discardedMs <= 0) return;
package/src/smartpbx.ts CHANGED
@@ -292,6 +292,7 @@ export async function createSmartPbxMediaStreamServer(
292
292
  contextId: state.contextId,
293
293
  timestampMs: Date.now(),
294
294
  audio: pcm16SamplesToBytes(resampled),
295
+ sampleRateHz: inputSampleRateHz,
295
296
  });
296
297
  return;
297
298
  }