@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/package.json +3 -3
- package/src/admission-control.test.ts +23 -0
- package/src/background-audio.test.ts +172 -0
- package/src/background-audio.ts +263 -0
- package/src/browser-pacing.test.ts +1 -1
- package/src/edge-twilio.test.ts +79 -1
- package/src/edge-twilio.ts +77 -21
- package/src/edge.test.ts +139 -0
- package/src/edge.ts +44 -8
- package/src/inbound-audio.test.ts +58 -0
- package/src/inbound-audio.ts +9 -2
- package/src/index.test.ts +6 -3
- package/src/index.ts +84 -8
- package/src/outbound-playout-pipeline.test.ts +107 -1
- package/src/outbound-playout-pipeline.ts +55 -6
- package/src/smartpbx.ts +11 -1
- package/src/telnyx.ts +11 -1
- package/src/transport-host.ts +12 -1
- package/src/twilio-auth.test.ts +36 -0
- package/src/twilio-auth.ts +55 -0
- package/src/twilio.ts +11 -1
- package/src/websocket-upgrade.ts +20 -1
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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-${
|
|
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 {
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
audioPacket.
|
|
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 {
|
|
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 {
|
|
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;
|
package/src/transport-host.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|