@kuralle-syrinx/server-websocket 3.1.0 → 4.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/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import { closeWebSocketWithFallback, waitForWebSocketClose } from "./websocket-c
32
32
  import { isRecord, parseJsonRecord, optionalString, requiredString } from "./json-message.js";
33
33
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
34
34
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
35
+ import { BackgroundAudioMixer, type BackgroundAudioConfig } from "./background-audio.js";
35
36
  import { wireTelephonyOutboundPipeline, type TelephonyOutboundCallbacks, type TelephonyOutboundHandle } from "./outbound-playout-pipeline.js";
36
37
  import { TurnMetricsTracker, type TurnTimestampState } from "./turn-metrics.js";
37
38
  import { type PacedPlayoutFrame } from "./paced-playout.js";
@@ -58,6 +59,13 @@ export * from "./twilio.js";
58
59
  export * from "./telnyx.js";
59
60
  export * from "./smartpbx.js";
60
61
  export * from "./session-store.js";
62
+ export { validateTwilioSignature } from "./twilio-auth.js";
63
+ export {
64
+ BackgroundAudioMixer,
65
+ wireBackgroundThinking,
66
+ type BackgroundAudioConfig,
67
+ type BackgroundAudioSource,
68
+ } from "./background-audio.js";
61
69
  export { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
62
70
 
63
71
  export interface VoiceWebSocketServerOptions {
@@ -96,10 +104,23 @@ export interface VoiceWebSocketServerOptions {
96
104
  * envelope. Set false only for tests or legacy clients that require PCM envelopes.
97
105
  */
98
106
  readonly browserOpusDownlink?: boolean;
107
+ /**
108
+ * Ambient/thinking bed mixed (ducked) under assistant speech. No idle bed is
109
+ * sent between turns on the browser path — a web client can loop ambience
110
+ * locally; the server-side idle bed exists for telephony wires.
111
+ */
112
+ readonly backgroundAudio?: BackgroundAudioConfig;
99
113
  readonly sessionStore?: SessionStore;
100
114
  readonly maxConcurrentSessions?: number;
101
115
  readonly maxConcurrentSessionsScope?: "path" | "server";
102
116
  readonly onTransportMetric?: (name: string) => void;
117
+ /**
118
+ * Authorize each inbound `/ws` upgrade (shared secret / bearer / signature).
119
+ * Return false or throw to reject with 4401. Unset = OPEN — voice endpoints are
120
+ * unauthenticated by default and each connection incurs provider cost and can
121
+ * attach to a live session; set this before exposing the endpoint publicly.
122
+ */
123
+ readonly authorize?: (request: import("node:http").IncomingMessage) => boolean | Promise<boolean>;
103
124
  }
104
125
 
105
126
  export type { GracefulCloseOptions } from "./transport-host.js";
@@ -127,6 +148,12 @@ type ClientMessage =
127
148
  readonly sequence?: number;
128
149
  }
129
150
  | { readonly type: "codec_capability"; readonly downlinkEncoding: "pcm_s16le" | "opus" }
151
+ | {
152
+ readonly type: "playout_progress";
153
+ readonly contextId?: string;
154
+ readonly playedOutMs: number;
155
+ readonly complete?: boolean;
156
+ }
130
157
  | { readonly type: "ping" };
131
158
 
132
159
  interface BrowserConnectionState {
@@ -159,6 +186,7 @@ export async function createVoiceWebSocketServer(
159
186
  maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
160
187
  maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
161
188
  onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
189
+ authorize: options.authorize,
162
190
  });
163
191
  const wsServer = routedWebSocket.wsServer;
164
192
  const sessionStore = options.sessionStore ?? new InMemorySessionStore();
@@ -238,6 +266,7 @@ export async function createVoiceWebSocketServer(
238
266
  () => state.browserOpusDownlink,
239
267
  managed.turnMetricsTurns,
240
268
  state.streamingResamplers,
269
+ options.backgroundAudio ? new BackgroundAudioMixer(options.backgroundAudio) : undefined,
241
270
  );
242
271
  gracefulCloseRegistry.set(socket, (deadlineMs) => {
243
272
  if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) {
@@ -417,6 +446,7 @@ function wireBrowserSessionEvents(
417
446
  getBrowserOpusDownlink: () => boolean,
418
447
  turnMetricsTurns: Map<string, TurnTimestampState>,
419
448
  streamingResamplers: Map<string, StreamingPcm16Resampler>,
449
+ backgroundAudio?: BackgroundAudioMixer,
420
450
  ): TelephonyOutboundHandle {
421
451
  const ttsSequences = new Map<string, number>();
422
452
  const speechEndedSent = new Set<string>();
@@ -462,6 +492,16 @@ function wireBrowserSessionEvents(
462
492
  onSession("agent_tool_result", (event) => {
463
493
  sendJson(socket, { type: "agent_tool_result", turnId: event.turnId, id: event.id, result: event.result }, maxBufferedAmountBytes);
464
494
  });
495
+ // G3: typed preamble/filler lifecycle — same wire shape as the edge transport.
496
+ onSession("tool_call_cue", (event) => {
497
+ sendJson(socket, {
498
+ type: `tool_call_${event.phase}`,
499
+ turnId: event.turnId,
500
+ toolId: event.toolId,
501
+ toolName: event.toolName,
502
+ ...(event.afterMs !== undefined ? { afterMs: event.afterMs } : {}),
503
+ }, maxBufferedAmountBytes);
504
+ });
465
505
  onSession("agent_finished", (event) => {
466
506
  sendJson(socket, { type: "agent_end", turnId: event.turnId }, maxBufferedAmountBytes);
467
507
  });
@@ -489,6 +529,10 @@ function wireBrowserSessionEvents(
489
529
  wireAudio: Uint8Array,
490
530
  wireEncoding: "pcm_s16le" | "opus",
491
531
  durationMs: number,
532
+ // The sample rate of THIS frame's payload. Opus frames are encoded at the
533
+ // codec rate (48 kHz), not outputSampleRateHz — mislabeling them 16 kHz made
534
+ // the client skip its 48→16 kHz downsample and play assistant audio ~3× slow.
535
+ wireSampleRateHz: number,
492
536
  ): void => {
493
537
  const sequence = (ttsSequences.get(contextId) ?? 0) + 1;
494
538
  ttsSequences.set(contextId, sequence);
@@ -500,7 +544,7 @@ function wireBrowserSessionEvents(
500
544
  type: "tts_chunk",
501
545
  turnId: contextId,
502
546
  sequence,
503
- sampleRateHz: outputSampleRateHz,
547
+ sampleRateHz: wireSampleRateHz,
504
548
  encoding: wireEncoding,
505
549
  channels: 1,
506
550
  byteLength: wireAudio.byteLength,
@@ -512,7 +556,7 @@ function wireBrowserSessionEvents(
512
556
  type: "audio",
513
557
  contextId,
514
558
  sequence,
515
- sampleRateHz: outputSampleRateHz,
559
+ sampleRateHz: wireSampleRateHz,
516
560
  encoding: wireEncoding,
517
561
  channels: 1,
518
562
  byteLength: wireAudio.byteLength,
@@ -532,7 +576,7 @@ function wireBrowserSessionEvents(
532
576
  samples = resamplePcm16Streaming(streamingResamplers, samples, outputSampleRateHz, opusCodec.sampleRateHz);
533
577
  }
534
578
  for (const opus of opusCodec.encodePcm16Frame(samples, false)) {
535
- pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS);
579
+ pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS, opusCodec.sampleRateHz);
536
580
  }
537
581
  }
538
582
  return frames;
@@ -540,7 +584,7 @@ function wireBrowserSessionEvents(
540
584
 
541
585
  for (let offset = 0; offset < resampled.byteLength; offset += frameBytesCount) {
542
586
  const frameAudio = resampled.subarray(offset, Math.min(resampled.byteLength, offset + frameBytesCount));
543
- pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz));
587
+ pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz), outputSampleRateHz);
544
588
  }
545
589
  return frames;
546
590
  },
@@ -564,7 +608,7 @@ function wireBrowserSessionEvents(
564
608
  type: "tts_chunk",
565
609
  turnId: contextId,
566
610
  sequence,
567
- sampleRateHz: outputSampleRateHz,
611
+ sampleRateHz: opusCodec.sampleRateHz,
568
612
  encoding: "opus",
569
613
  channels: 1,
570
614
  byteLength: opus.byteLength,
@@ -575,7 +619,7 @@ function wireBrowserSessionEvents(
575
619
  type: "audio",
576
620
  contextId,
577
621
  sequence,
578
- sampleRateHz: outputSampleRateHz,
622
+ sampleRateHz: opusCodec.sampleRateHz,
579
623
  encoding: "opus",
580
624
  channels: 1,
581
625
  byteLength: opus.byteLength,
@@ -627,6 +671,7 @@ function wireBrowserSessionEvents(
627
671
  outboundFrameDurationMs,
628
672
  maxQueuedOutputAudioMs,
629
673
  callbacks,
674
+ ...(backgroundAudio ? { backgroundAudio } : {}),
630
675
  });
631
676
  }
632
677
 
@@ -683,6 +728,22 @@ function handleClientMessage(
683
728
  }
684
729
  return currentContextId;
685
730
  }
731
+ if (message.type === "playout_progress") {
732
+ // The browser reports how much assistant audio it has actually played out. This
733
+ // is a more accurate "heard" clock than the server pacer (which leads the ear by
734
+ // the client jitter buffer), so barge-in truncates history to what was heard (B2).
735
+ const progressContextId = message.contextId ?? currentContextId;
736
+ if (progressContextId) {
737
+ session.bus.push(Route.Main, {
738
+ kind: "tts.playout_progress",
739
+ contextId: progressContextId,
740
+ timestampMs: Date.now(),
741
+ playedOutMs: message.playedOutMs,
742
+ complete: message.complete ?? false,
743
+ });
744
+ }
745
+ return currentContextId;
746
+ }
686
747
  if (message.type === "text") {
687
748
  const nextContextId = message.contextId ?? contextId();
688
749
  if (nextContextId !== currentContextId) {
@@ -750,6 +811,18 @@ function parseClientMessage(value: unknown): ClientMessage {
750
811
  }
751
812
  return { type, downlinkEncoding };
752
813
  }
814
+ if (type === "playout_progress") {
815
+ const playedOutMs = value.playedOutMs;
816
+ if (typeof playedOutMs !== "number" || !Number.isFinite(playedOutMs) || playedOutMs < 0) {
817
+ throw new Error("playout_progress.playedOutMs must be a non-negative number");
818
+ }
819
+ return {
820
+ type,
821
+ contextId: optionalContextId(value.contextId),
822
+ playedOutMs,
823
+ complete: value.complete === true ? true : undefined,
824
+ };
825
+ }
753
826
  throw new Error("Unsupported client message type");
754
827
  }
755
828
 
@@ -883,9 +956,12 @@ function outboundMessageByteLength(data: string | Buffer): number {
883
956
  }
884
957
 
885
958
  function defaultContextId(): string {
886
- return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
959
+ // crypto-random, not Math.random: ids are guessable resource handles. V8's PRNG
960
+ // is state-recoverable from a few observed outputs, which would let a client
961
+ // predict other sessions' turn/session ids and attach to a live conversation.
962
+ return `turn-${globalThis.crypto.randomUUID()}`;
887
963
  }
888
964
 
889
965
  function defaultSessionId(): string {
890
- return `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
966
+ return `session-${globalThis.crypto.randomUUID()}`;
891
967
  }
@@ -5,7 +5,8 @@ import { describe, expect, it } from "vitest";
5
5
  import WebSocket from "ws";
6
6
  import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
7
7
  import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
8
- import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
8
+ import { BackgroundAudioMixer } from "./background-audio.js";
9
+ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation, type RotatableTurnContext } from "./outbound-playout-pipeline.js";
9
10
 
10
11
  function createMockSocket(): WebSocket {
11
12
  const emitter = new EventEmitter();
@@ -206,3 +207,108 @@ describe("wireTelephonyOutboundPipeline.drainAndClose", () => {
206
207
  for (const dispose of disposers) dispose();
207
208
  });
208
209
  });
210
+
211
+ describe("installTelephonyTurnRotation", () => {
212
+ // Regression for the telephony "deaf after turn 1" P0: a carrier gives one
213
+ // stream per call, but STT/TTS retire a contextId once its turn completes, so
214
+ // reusing a single callSid id muted the agent after turn 1. Every turn must get
215
+ // a fresh per-turn id with a turn.change boundary.
216
+ it("rotates a per-turn contextId and emits turn.change on each completed turn", async () => {
217
+ const session = new VoiceAgentSession({ plugins: {} });
218
+ void session.start();
219
+ const disposers: Array<() => void> = [];
220
+ const state: RotatableTurnContext = {
221
+ contextId: "twilio-CA123",
222
+ contextBase: "twilio-CA123",
223
+ turnCounter: 0,
224
+ };
225
+ const turnChanges: Array<{ contextId: string; previousContextId: string; reason: string }> = [];
226
+ session.bus.on("turn.change", (pkt) => {
227
+ const change = pkt as unknown as { contextId: string; previousContextId: string; reason: string };
228
+ turnChanges.push({ contextId: change.contextId, previousContextId: change.previousContextId, reason: change.reason });
229
+ });
230
+
231
+ installTelephonyTurnRotation(session, disposers, state);
232
+
233
+ // Turn 1 runs on the base id.
234
+ expect(state.contextId).toBe("twilio-CA123");
235
+
236
+ session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123", timestampMs: Date.now(), text: "one", transcripts: [] });
237
+ await new Promise((r) => setTimeout(r, 0));
238
+ expect(state.contextId).toBe("twilio-CA123-t1");
239
+
240
+ session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123-t1", timestampMs: Date.now(), text: "two", transcripts: [] });
241
+ await new Promise((r) => setTimeout(r, 0));
242
+ expect(state.contextId).toBe("twilio-CA123-t2");
243
+
244
+ expect(turnChanges).toEqual([
245
+ { contextId: "twilio-CA123-t1", previousContextId: "twilio-CA123", reason: "telephony_turn_complete" },
246
+ { contextId: "twilio-CA123-t2", previousContextId: "twilio-CA123-t1", reason: "telephony_turn_complete" },
247
+ ]);
248
+
249
+ for (const dispose of disposers) dispose();
250
+ await session.close();
251
+ });
252
+
253
+ it("no-ops until a base is set", async () => {
254
+ const session = new VoiceAgentSession({ plugins: {} });
255
+ void session.start();
256
+ const disposers: Array<() => void> = [];
257
+ const state: RotatableTurnContext = { contextId: "", contextBase: "", turnCounter: 0 };
258
+ installTelephonyTurnRotation(session, disposers, state);
259
+ session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "", timestampMs: Date.now(), text: "", transcripts: [] });
260
+ await new Promise((r) => setTimeout(r, 0));
261
+ expect(state.contextId).toBe("");
262
+ expect(state.turnCounter).toBe(0);
263
+ for (const dispose of disposers) dispose();
264
+ await session.close();
265
+ });
266
+
267
+ it("mixes the background bed into speech before per-carrier encoding", async () => {
268
+ const socket = createMockSocket();
269
+ const session = new VoiceAgentSession({ plugins: {} });
270
+ void session.start();
271
+ const disposers: Array<() => void> = [];
272
+ const encodedChunks: Int16Array[] = [];
273
+ wireTelephonyOutboundPipeline({
274
+ session,
275
+ socket,
276
+ disposers,
277
+ outboundFrameDurationMs: 20,
278
+ maxQueuedOutputAudioMs: 30_000,
279
+ backgroundAudio: new BackgroundAudioMixer({
280
+ ambient: { pcm: new Int16Array(160).fill(1000), sampleRateHz: 16000, gain: 0.5 },
281
+ duckWhileSpeaking: 0.5,
282
+ fadeMs: 0,
283
+ }),
284
+ callbacks: {
285
+ carrierLabel: "test",
286
+ getContextId: () => "turn-mix",
287
+ isActive: () => true,
288
+ encodeFrames: (audio) => {
289
+ encodedChunks.push(new Int16Array(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength)));
290
+ return [{ contextId: "turn-mix", send: () => true }];
291
+ },
292
+ onInterrupt: () => undefined,
293
+ onDrain: () => undefined,
294
+ onStop: () => undefined,
295
+ },
296
+ });
297
+
298
+ session.bus.push(Route.Main, {
299
+ kind: "tts.audio",
300
+ contextId: "turn-mix",
301
+ timestampMs: Date.now(),
302
+ audio: pcm16SamplesToBytes(new Int16Array(4)), // silence in → ambient-only out
303
+ sampleRateHz: 16000,
304
+ });
305
+ await new Promise((r) => setTimeout(r, 20));
306
+
307
+ expect(encodedChunks).toHaveLength(1);
308
+ // ambient 1000 × gain 0.5 × duck 0.5 = 250 layered under the (silent) speech
309
+ expect(Array.from(encodedChunks[0]!)).toEqual([250, 250, 250, 250]);
310
+
311
+ for (const dispose of disposers) dispose();
312
+ await session.close();
313
+ });
314
+ });
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { Route, type InterruptTtsPacket, type TextToSpeechAudioPacket, type TextToSpeechEndPacket, type VoiceAgentSession } from "@kuralle-syrinx/core";
4
4
  import { WebSocket } from "ws";
5
+ import type { BackgroundAudioMixer } from "./background-audio.js";
6
+ import { wireBackgroundThinking } from "./background-audio.js";
5
7
  import type { PacedPlayoutFrame } from "./paced-playout.js";
6
8
  import { PacedPlayoutQueue } from "./paced-playout.js";
7
9
  import { PlayoutProgressEmitter } from "./playout-progress.js";
@@ -24,6 +26,45 @@ export interface TelephonyOutboundHandle {
24
26
  drainAndClose(socket: WebSocket, deadlineMs: number): Promise<void>;
25
27
  }
26
28
 
29
+ /**
30
+ * Per-turn contextId identity for carrier transports.
31
+ *
32
+ * A phone carrier gives one continuous stream per call, but the engine finishes a
33
+ * turn per contextId and its STT/TTS plugins permanently retire a contextId once it
34
+ * completes (finalized/cancelled sets). Reusing one id for the whole call therefore
35
+ * makes the agent go deaf/mute after turn 1. Every carrier adapter mints a fresh
36
+ * per-turn id (`<base>-t<n>`) and rotates it at each turn boundary, emitting a
37
+ * `turn.change` so STT/metrics/dedup see a consistent boundary — matching the browser
38
+ * transport. Turn 1 uses `contextBase`; rotation begins at `-t1`.
39
+ */
40
+ export interface RotatableTurnContext {
41
+ contextId: string;
42
+ contextBase: string;
43
+ turnCounter: number;
44
+ }
45
+
46
+ export function installTelephonyTurnRotation(
47
+ session: VoiceAgentSession,
48
+ disposers: Array<() => void>,
49
+ state: RotatableTurnContext,
50
+ ): void {
51
+ disposers.push(
52
+ session.bus.on("eos.turn_complete", () => {
53
+ if (!state.contextBase) return;
54
+ state.turnCounter += 1;
55
+ const previousContextId = state.contextId;
56
+ state.contextId = `${state.contextBase}-t${String(state.turnCounter)}`;
57
+ session.bus.push(Route.Main, {
58
+ kind: "turn.change",
59
+ contextId: state.contextId,
60
+ previousContextId,
61
+ timestampMs: Date.now(),
62
+ reason: "telephony_turn_complete",
63
+ });
64
+ }),
65
+ );
66
+ }
67
+
27
68
  export function wireTelephonyOutboundPipeline(args: {
28
69
  readonly session: VoiceAgentSession;
29
70
  readonly socket: WebSocket;
@@ -31,8 +72,16 @@ export function wireTelephonyOutboundPipeline(args: {
31
72
  readonly outboundFrameDurationMs: number;
32
73
  readonly maxQueuedOutputAudioMs: number;
33
74
  readonly callbacks: TelephonyOutboundCallbacks;
75
+ /**
76
+ * Mixes the ambient/thinking bed under every outbound speech chunk (ducked).
77
+ * Idle comfort-noise frames are not generated on this path yet — the paced
78
+ * queue's per-encode mark machinery needs a per-carrier background-frame
79
+ * encoder first (see background-audio-implementation-notes.md).
80
+ */
81
+ readonly backgroundAudio?: BackgroundAudioMixer;
34
82
  }): TelephonyOutboundHandle {
35
- const { session, socket, disposers, outboundFrameDurationMs, maxQueuedOutputAudioMs, callbacks } = args;
83
+ const { session, socket, disposers, outboundFrameDurationMs, maxQueuedOutputAudioMs, callbacks, backgroundAudio } = args;
84
+ if (backgroundAudio) wireBackgroundThinking(session, backgroundAudio);
36
85
 
37
86
  const recordDiscardedPlayout = (discardedMs: number, reason: string): void => {
38
87
  if (discardedMs <= 0) return;
@@ -108,11 +157,11 @@ export function wireTelephonyOutboundPipeline(args: {
108
157
  });
109
158
  return;
110
159
  }
111
- const frames = callbacks.encodeFrames(
112
- audioPacket.audio,
113
- requireTtsAudioSampleRate(audioPacket.sampleRateHz),
114
- audioPacket.contextId,
115
- );
160
+ const sampleRateHz = requireTtsAudioSampleRate(audioPacket.sampleRateHz);
161
+ const audio = backgroundAudio
162
+ ? backgroundAudio.mix(audioPacket.audio, sampleRateHz)
163
+ : audioPacket.audio;
164
+ const frames = callbacks.encodeFrames(audio, sampleRateHz, audioPacket.contextId);
116
165
  playout.enqueue(frames);
117
166
  }),
118
167
  session.bus.on("tts.end", (pkt) => {
package/src/smartpbx.ts CHANGED
@@ -23,7 +23,8 @@ import {
23
23
  } from "./json-message.js";
24
24
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
25
25
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
26
- import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
26
+ import { BackgroundAudioMixer, type BackgroundAudioConfig } from "./background-audio.js";
27
+ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
27
28
  import {
28
29
  decodeStrictBase64,
29
30
  nonNegativeInteger,
@@ -43,6 +44,8 @@ export interface SmartPbxMediaStreamServerOptions {
43
44
  readonly outputSampleRateHz?: number;
44
45
  readonly outboundFrameDurationMs?: number;
45
46
  readonly maxQueuedOutputAudioMs?: number;
47
+ /** Ambient/thinking bed mixed (ducked) under assistant speech (see BackgroundAudioConfig). */
48
+ readonly backgroundAudio?: BackgroundAudioConfig;
46
49
  readonly heartbeatIntervalMs?: number;
47
50
  readonly startupTimeoutMs?: number;
48
51
  readonly maxSessionDurationMs?: number;
@@ -86,6 +89,8 @@ interface SmartPbxConnectionState {
86
89
  callId: string;
87
90
  accountId: string;
88
91
  contextId: string;
92
+ contextBase: string;
93
+ turnCounter: number;
89
94
  codec: SmartPbxCodec;
90
95
  wireSampleRateHz: number;
91
96
  opusDecoder: OpusDecoder | null;
@@ -140,6 +145,8 @@ export async function createSmartPbxMediaStreamServer(
140
145
  callId: "",
141
146
  accountId: "",
142
147
  contextId: "",
148
+ contextBase: "",
149
+ turnCounter: 0,
143
150
  codec: "g711_ulaw",
144
151
  wireSampleRateHz: 8000,
145
152
  opusDecoder: null,
@@ -175,6 +182,7 @@ export async function createSmartPbxMediaStreamServer(
175
182
  disposers,
176
183
  outboundFrameDurationMs,
177
184
  maxQueuedOutputAudioMs,
185
+ ...(options.backgroundAudio ? { backgroundAudio: new BackgroundAudioMixer(options.backgroundAudio) } : {}),
178
186
  callbacks: {
179
187
  carrierLabel: "smartpbx",
180
188
  getContextId: () => state.contextId,
@@ -239,6 +247,7 @@ export async function createSmartPbxMediaStreamServer(
239
247
  },
240
248
  });
241
249
  state.clearPlayout = outbound.clearPlayout;
250
+ installTelephonyTurnRotation(session, disposers, state);
242
251
  gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
243
252
  disposers.push(() => gracefulCloseRegistry.delete(socket));
244
253
  return (reason) => {
@@ -261,6 +270,7 @@ export async function createSmartPbxMediaStreamServer(
261
270
  if (!state.callId) throw new Error("SmartPBX start event is missing callId");
262
271
  if (!state.accountId) throw new Error("SmartPBX start event is missing accountId");
263
272
  state.contextId = contextIdFn(start);
273
+ state.contextBase = state.contextId;
264
274
  state.codec = format.codec;
265
275
  state.wireSampleRateHz = format.sampleRateHz;
266
276
  state.opusDecoder = format.codec === "opus" ? new OpusDecoder({ channels: 1, sample_rate: 48000 }) : null;
package/src/telnyx.ts CHANGED
@@ -25,7 +25,8 @@ import {
25
25
  } from "./json-message.js";
26
26
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
27
27
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
28
- import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
28
+ import { BackgroundAudioMixer, type BackgroundAudioConfig } from "./background-audio.js";
29
+ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
29
30
  import {
30
31
  decodeStrictBase64,
31
32
  nonNegativeInteger,
@@ -49,6 +50,8 @@ export interface TelnyxMediaStreamServerOptions {
49
50
  readonly bidirectionalCodec?: "PCMU" | "L16";
50
51
  readonly outboundFrameDurationMs?: number;
51
52
  readonly maxQueuedOutputAudioMs?: number;
53
+ /** Ambient/thinking bed mixed (ducked) under assistant speech (see BackgroundAudioConfig). */
54
+ readonly backgroundAudio?: BackgroundAudioConfig;
52
55
  readonly maxInboundReorderFrames?: number;
53
56
  readonly heartbeatIntervalMs?: number;
54
57
  readonly startupTimeoutMs?: number;
@@ -104,6 +107,8 @@ type TelnyxCodec = "PCMU" | "L16";
104
107
  interface TelnyxConnectionState {
105
108
  streamId: string;
106
109
  contextId: string;
110
+ contextBase: string;
111
+ turnCounter: number;
107
112
  inboundCodec: TelnyxCodec;
108
113
  inboundSampleRateHz: number;
109
114
  readonly outboundCodec: TelnyxCodec;
@@ -168,6 +173,8 @@ export async function createTelnyxMediaStreamServer(
168
173
  createState: () => ({
169
174
  streamId: "",
170
175
  contextId: "",
176
+ contextBase: "",
177
+ turnCounter: 0,
171
178
  inboundCodec: "PCMU",
172
179
  inboundSampleRateHz: 8000,
173
180
  outboundCodec: bidirectionalCodec,
@@ -220,6 +227,7 @@ export async function createTelnyxMediaStreamServer(
220
227
  disposers,
221
228
  outboundFrameDurationMs,
222
229
  maxQueuedOutputAudioMs,
230
+ ...(options.backgroundAudio ? { backgroundAudio: new BackgroundAudioMixer(options.backgroundAudio) } : {}),
223
231
  callbacks: {
224
232
  carrierLabel: "telnyx",
225
233
  getContextId: () => state.contextId,
@@ -290,6 +298,7 @@ export async function createTelnyxMediaStreamServer(
290
298
  },
291
299
  });
292
300
  state.clearPlayout = outbound.clearPlayout;
301
+ installTelephonyTurnRotation(session, disposers, state);
293
302
  gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
294
303
  disposers.push(() => gracefulCloseRegistry.delete(socket));
295
304
  return (reason) => state.clearPlayout(reason);
@@ -315,6 +324,7 @@ export async function createTelnyxMediaStreamServer(
315
324
  state.streamId = message.stream_id ?? start.stream_id ?? "";
316
325
  if (!state.streamId) throw new Error("Telnyx start event is missing stream_id");
317
326
  state.contextId = contextIdFn(start);
327
+ state.contextBase = state.contextId;
318
328
  state.inboundCodec = format.codec;
319
329
  state.inboundSampleRateHz = format.sampleRateHz;
320
330
  state.started = true;
@@ -30,6 +30,15 @@ export interface TransportAdmissionOptions {
30
30
  readonly maxConcurrentSessions?: number;
31
31
  readonly maxConcurrentSessionsScope?: "path" | "server";
32
32
  readonly onAdmissionRejected?: () => void;
33
+ /**
34
+ * Authorize an inbound connection before the WebSocket upgrade completes. Return
35
+ * false (or throw) to reject with 4401. Voice endpoints are unauthenticated by
36
+ * default and each connection incurs provider cost and can attach to a live
37
+ * session — set this (shared secret / bearer / Twilio signature) before exposing
38
+ * the endpoint. Receives the raw upgrade request (headers + URL) so it can read
39
+ * an `Authorization` header or a `?token=` query param.
40
+ */
41
+ readonly authorize?: (request: IncomingMessage) => boolean | Promise<boolean>;
33
42
  }
34
43
 
35
44
  export const TRANSPORT_ADMISSION_REJECTED_METRIC = "transport.admission_rejected";
@@ -39,9 +48,11 @@ export function rejectWebSocketAdmission(
39
48
  request: IncomingMessage,
40
49
  socket: Socket,
41
50
  head: Buffer,
51
+ code = 1013,
52
+ reason = "try again later",
42
53
  ): void {
43
54
  wsServer.handleUpgrade(request, socket, head, (websocket) => {
44
- websocket.close(1013, "try again later");
55
+ websocket.close(code, reason);
45
56
  });
46
57
  }
47
58
 
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { validateTwilioSignature } from "./twilio-auth.js";
5
+
6
+ // Vector computed with the canonical HMAC-SHA1 algorithm (verified against
7
+ // node:crypto createHmac("sha1")) for these exact inputs — this pins that our
8
+ // Web Crypto implementation matches Twilio's documented signing scheme.
9
+ describe("validateTwilioSignature", () => {
10
+ const authToken = "12345";
11
+ const url = "https://mycompany.com/myapp.php?foo=1&bar=2";
12
+ const params = {
13
+ CallSid: "CA1234567890ABCDE",
14
+ Caller: "+14158675310",
15
+ Digits: "1234",
16
+ From: "+14158675310",
17
+ To: "+18005551212",
18
+ };
19
+ const validSignature = "GvWf1cFY/Q7PnoempGyD5oXAezc=";
20
+
21
+ it("accepts a correct signature", async () => {
22
+ expect(await validateTwilioSignature({ authToken, signature: validSignature, url, params })).toBe(true);
23
+ });
24
+
25
+ it("rejects a tampered signature", async () => {
26
+ expect(await validateTwilioSignature({ authToken, signature: "AAAA" + validSignature.slice(4), url, params })).toBe(false);
27
+ });
28
+
29
+ it("rejects a wrong auth token", async () => {
30
+ expect(await validateTwilioSignature({ authToken: "wrong", signature: validSignature, url, params })).toBe(false);
31
+ });
32
+
33
+ it("rejects a missing signature", async () => {
34
+ expect(await validateTwilioSignature({ authToken, signature: null, url, params })).toBe(false);
35
+ });
36
+ });
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Twilio request-signature validation (X-Twilio-Signature). Runtime-neutral —
4
+ // uses Web Crypto (HMAC-SHA1), so it works on Node and Cloudflare Workers. Twilio
5
+ // signs each webhook: signature = base64(HMAC-SHA1(authToken, fullUrl + concat of
6
+ // POST params sorted by key, each as key+value)). Reference:
7
+ // https://www.twilio.com/docs/usage/security#validating-requests
8
+ //
9
+ // Plug into the transport `authorize` hook, or call directly in a webhook handler.
10
+
11
+ /**
12
+ * Validate a Twilio request signature. `params` are the POST form fields (empty
13
+ * for a bare GET / WS upgrade). Constant-time compares the computed signature
14
+ * against the provided one. Returns false on any mismatch or malformed input.
15
+ */
16
+ export async function validateTwilioSignature(args: {
17
+ readonly authToken: string;
18
+ readonly signature: string | null | undefined;
19
+ readonly url: string;
20
+ readonly params?: Record<string, string>;
21
+ }): Promise<boolean> {
22
+ const { authToken, signature, url } = args;
23
+ if (!authToken || !signature) return false;
24
+
25
+ const params = args.params ?? {};
26
+ let data = url;
27
+ for (const key of Object.keys(params).sort()) {
28
+ data += key + params[key];
29
+ }
30
+
31
+ const key = await crypto.subtle.importKey(
32
+ "raw",
33
+ new TextEncoder().encode(authToken),
34
+ { name: "HMAC", hash: "SHA-1" },
35
+ false,
36
+ ["sign"],
37
+ );
38
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
39
+ const expected = base64FromBytes(new Uint8Array(mac));
40
+ return timingSafeEqual(expected, signature);
41
+ }
42
+
43
+ function base64FromBytes(bytes: Uint8Array): string {
44
+ let binary = "";
45
+ for (const b of bytes) binary += String.fromCharCode(b);
46
+ // btoa exists on Workers and modern Node globals.
47
+ return btoa(binary);
48
+ }
49
+
50
+ function timingSafeEqual(a: string, b: string): boolean {
51
+ if (a.length !== b.length) return false;
52
+ let diff = 0;
53
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
54
+ return diff === 0;
55
+ }