@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,163 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Workers-safe Telnyx codec helpers (TypedArray only — no Node Buffer / process /
4
+ // node: imports). Shared by the Node host (`telnyx.ts`) and the Workers edge
5
+ // runner (`edge-telnyx.ts`). Live trunk negotiation remains carrier-gated.
6
+
7
+ import {
8
+ bigEndianPcm16BytesToSamples,
9
+ createG722DecoderState,
10
+ createG722EncoderState,
11
+ decodeALawToPcm16,
12
+ decodeG722,
13
+ decodeMuLawToPcm16,
14
+ encodeG722,
15
+ encodePcm16ToALaw,
16
+ encodePcm16ToMuLaw,
17
+ pcm16SamplesToBigEndianBytes,
18
+ type G722DecoderState,
19
+ type G722EncoderState,
20
+ } from "@kuralle-syrinx/core/audio";
21
+
22
+ /** Telnyx stream codecs. PCMA/G722 unit-tested; live trunk negotiation unverified. */
23
+ export type TelnyxCodec = "PCMU" | "L16" | "PCMA" | "G722";
24
+
25
+ export interface TelnyxStartMediaFormat {
26
+ readonly encoding?: string;
27
+ readonly sample_rate?: number | string;
28
+ readonly channels?: number | string;
29
+ }
30
+
31
+ export interface TelnyxStartPayload {
32
+ readonly stream_id?: string;
33
+ readonly call_control_id?: string;
34
+ readonly call_session_id?: string;
35
+ readonly media_format?: TelnyxStartMediaFormat;
36
+ }
37
+
38
+ /** Stateful G.722 pair for a stream. Other codecs leave both null. */
39
+ export interface TelnyxG722State {
40
+ decoder: G722DecoderState | null;
41
+ encoder: G722EncoderState | null;
42
+ }
43
+
44
+ export function wireSampleRateForCodec(codec: TelnyxCodec): number {
45
+ return codec === "L16" || codec === "G722" ? 16_000 : 8_000;
46
+ }
47
+
48
+ /** Alias kept for callers that used the Node-side name. */
49
+ export const outboundSampleRateForCodec = wireSampleRateForCodec;
50
+
51
+ export function createTelnyxG722State(codec: TelnyxCodec): TelnyxG722State {
52
+ if (codec !== "G722") return { decoder: null, encoder: null };
53
+ return {
54
+ decoder: createG722DecoderState(),
55
+ encoder: createG722EncoderState(),
56
+ };
57
+ }
58
+
59
+ export function validateTelnyxStart(
60
+ start: TelnyxStartPayload,
61
+ ): { readonly codec: TelnyxCodec; readonly sampleRateHz: number } {
62
+ const format = start.media_format;
63
+ if (!format) throw new Error("Telnyx start event is missing media_format");
64
+ const encoding = format.encoding?.trim().toUpperCase();
65
+ if (encoding !== "PCMU" && encoding !== "L16" && encoding !== "PCMA" && encoding !== "G722") {
66
+ throw new Error(`Unsupported Telnyx media encoding: ${format.encoding ?? "unknown"}`);
67
+ }
68
+ const sampleRateHz = numberFromString(format.sample_rate);
69
+ if (sampleRateHz === null) throw new Error(`Unsupported Telnyx sample rate: ${String(format.sample_rate)}`);
70
+ if ((encoding === "PCMU" || encoding === "PCMA") && sampleRateHz !== 8000) {
71
+ throw new Error(`Unsupported Telnyx ${encoding} sample rate: ${String(format.sample_rate)}`);
72
+ }
73
+ if ((encoding === "L16" || encoding === "G722") && sampleRateHz !== 16000) {
74
+ throw new Error(`Unsupported Telnyx ${encoding} sample rate: ${String(format.sample_rate)}`);
75
+ }
76
+ const channels = numberFromString(format.channels);
77
+ if (channels !== 1) throw new Error(`Unsupported Telnyx channel count: ${String(format.channels)}`);
78
+ return { codec: encoding as TelnyxCodec, sampleRateHz };
79
+ }
80
+
81
+ /**
82
+ * Decode a Telnyx media payload (RTP bytes, already base64-decoded) to PCM16 at
83
+ * the wire sample rate. G.722 keeps 16 kHz — do not downsample before STT.
84
+ */
85
+ export function decodeTelnyxInboundPayload(
86
+ input: Uint8Array,
87
+ codec: TelnyxCodec,
88
+ g722: TelnyxG722State,
89
+ ): Int16Array {
90
+ switch (codec) {
91
+ case "PCMU":
92
+ return decodeMuLawToPcm16(input);
93
+ case "PCMA":
94
+ return decodeALawToPcm16(input);
95
+ case "G722": {
96
+ if (!g722.decoder) g722.decoder = createG722DecoderState();
97
+ return decodeG722(g722.decoder, input);
98
+ }
99
+ case "L16":
100
+ return bigEndianPcm16BytesToSamples(input);
101
+ }
102
+ }
103
+
104
+ /** Encode PCM16 samples already at the wire sample rate into a Telnyx media payload. */
105
+ export function encodeTelnyxOutboundPayload(
106
+ samples: Int16Array,
107
+ codec: TelnyxCodec,
108
+ g722: TelnyxG722State,
109
+ ): Uint8Array {
110
+ switch (codec) {
111
+ case "PCMU":
112
+ return encodePcm16ToMuLaw(samples);
113
+ case "PCMA":
114
+ return encodePcm16ToALaw(samples);
115
+ case "G722": {
116
+ if (!g722.encoder) g722.encoder = createG722EncoderState();
117
+ return encodeG722(g722.encoder, samples);
118
+ }
119
+ case "L16":
120
+ return pcm16SamplesToBigEndianBytes(samples);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Split an encoded payload into fixed-duration wire frames (Node paced playout).
126
+ * G.722: 1 byte per 2 samples @ 16 kHz; L16: 2 bytes/sample; PCMU/PCMA: 1 byte/sample.
127
+ */
128
+ export function splitTelnyxEncodedFrames(
129
+ encoded: Uint8Array,
130
+ codec: TelnyxCodec,
131
+ wireSampleRateHz: number,
132
+ frameDurationMs: number,
133
+ ): Uint8Array[] {
134
+ const samplesPerFrame = Math.max(1, Math.round((wireSampleRateHz * frameDurationMs) / 1000));
135
+ const frameBytes =
136
+ codec === "G722"
137
+ ? Math.max(1, samplesPerFrame >> 1)
138
+ : samplesPerFrame * (codec === "L16" ? 2 : 1);
139
+ const frames: Uint8Array[] = [];
140
+ for (let offset = 0; offset < encoded.byteLength; offset += frameBytes) {
141
+ frames.push(encoded.subarray(offset, Math.min(encoded.byteLength, offset + frameBytes)));
142
+ }
143
+ return frames;
144
+ }
145
+
146
+ export function defaultTelnyxContextId(start: TelnyxStartPayload): string {
147
+ const callControlId = start.call_control_id?.trim();
148
+ if (callControlId) return `telnyx-${callControlId}`;
149
+ const callSessionId = start.call_session_id?.trim();
150
+ if (callSessionId) return `telnyx-${callSessionId}`;
151
+ const streamId = start.stream_id?.trim();
152
+ if (streamId) return `telnyx-${streamId}`;
153
+ return `telnyx-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
154
+ }
155
+
156
+ function numberFromString(value: number | string | undefined): number | null {
157
+ if (typeof value === "number" && Number.isFinite(value)) return value;
158
+ if (typeof value === "string" && value.trim().length > 0) {
159
+ const parsed = Number(value);
160
+ if (Number.isFinite(parsed)) return parsed;
161
+ }
162
+ return null;
163
+ }
package/src/telnyx.ts CHANGED
@@ -5,12 +5,9 @@ import { createServer, type Server as HttpServer } from "node:http";
5
5
  import { WebSocket, WebSocketServer, type RawData } from "ws";
6
6
  import { Route, type VoiceAgentSession } from "@kuralle-syrinx/core";
7
7
  import {
8
- bigEndianPcm16BytesToSamples,
9
- decodeMuLawToPcm16,
10
- encodePcm16ToMuLaw,
8
+ createG722DecoderState,
11
9
  pcm16BytesToSamples,
12
10
  pcm16SamplesToBytes,
13
- pcm16SamplesToBigEndianBytes,
14
11
  resamplePcm16Streaming,
15
12
  type StreamingPcm16Resampler,
16
13
  } from "@kuralle-syrinx/core/audio";
@@ -30,12 +27,28 @@ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./o
30
27
  import {
31
28
  decodeStrictBase64,
32
29
  nonNegativeInteger,
33
- numberFromString,
34
30
  optionalNonNegativeIntegerString,
35
31
  optionalPositiveIntegerString,
36
32
  positiveInteger,
37
33
  rawDataToText,
38
34
  } from "./transport-helpers.js";
35
+ import type { FetchLike, TelnyxRestCredentials, WarmTransferSummarizer } from "./carrier-commands.js";
36
+ import { wireCarrierControl } from "./wire-carrier-control.js";
37
+ import {
38
+ createTelnyxG722State,
39
+ decodeTelnyxInboundPayload,
40
+ defaultTelnyxContextId,
41
+ encodeTelnyxOutboundPayload,
42
+ splitTelnyxEncodedFrames,
43
+ validateTelnyxStart,
44
+ wireSampleRateForCodec,
45
+ type TelnyxCodec,
46
+ type TelnyxG722State,
47
+ type TelnyxStartPayload,
48
+ } from "./telnyx-codec.js";
49
+
50
+ export type { TelnyxCodec, TelnyxStartPayload } from "./telnyx-codec.js";
51
+ export { validateTelnyxStart, defaultTelnyxContextId } from "./telnyx-codec.js";
39
52
 
40
53
  export interface TelnyxMediaStreamServerOptions {
41
54
  readonly server?: HttpServer;
@@ -47,7 +60,7 @@ export interface TelnyxMediaStreamServerOptions {
47
60
  readonly inputSampleRateHz?: number;
48
61
  readonly outputSampleRateHz?: number;
49
62
  /** Must match the `stream_bidirectional_codec` selected when starting the Telnyx call stream. */
50
- readonly bidirectionalCodec?: "PCMU" | "L16";
63
+ readonly bidirectionalCodec?: TelnyxCodec;
51
64
  readonly outboundFrameDurationMs?: number;
52
65
  readonly maxQueuedOutputAudioMs?: number;
53
66
  /** Ambient/thinking bed mixed (ducked) under assistant speech (see BackgroundAudioConfig). */
@@ -61,6 +74,15 @@ export interface TelnyxMediaStreamServerOptions {
61
74
  readonly maxConcurrentSessions?: number;
62
75
  readonly maxConcurrentSessionsScope?: "path" | "server";
63
76
  readonly onTransportMetric?: (name: string) => void;
77
+ /**
78
+ * Injected Call Control credentials for dtmf.send / call.transfer dispatch.
79
+ * Workers: pass secrets from `env`. Never read from process.env here.
80
+ * Live dispatch unverified without credentials.
81
+ */
82
+ readonly callControlCredentials?: TelnyxRestCredentials;
83
+ readonly callControlFetch?: FetchLike;
84
+ readonly callControlWebhookUrl?: string;
85
+ readonly warmTransferSummarizer?: WarmTransferSummarizer;
64
86
  }
65
87
 
66
88
  export interface TelnyxMediaStreamServer {
@@ -70,17 +92,6 @@ export interface TelnyxMediaStreamServer {
70
92
  close(opts?: GracefulCloseOptions): Promise<void>;
71
93
  }
72
94
 
73
- export interface TelnyxStartPayload {
74
- readonly stream_id?: string;
75
- readonly call_control_id?: string;
76
- readonly call_session_id?: string;
77
- readonly media_format?: {
78
- readonly encoding?: string;
79
- readonly sample_rate?: number | string;
80
- readonly channels?: number | string;
81
- };
82
- }
83
-
84
95
  interface TelnyxMediaMessage {
85
96
  readonly event?: string;
86
97
  readonly stream_id?: string;
@@ -102,10 +113,9 @@ interface PendingTelnyxMediaFrame {
102
113
  readonly pcm: Int16Array;
103
114
  }
104
115
 
105
- type TelnyxCodec = "PCMU" | "L16";
106
-
107
116
  interface TelnyxConnectionState {
108
117
  streamId: string;
118
+ callControlId: string;
109
119
  contextId: string;
110
120
  contextBase: string;
111
121
  turnCounter: number;
@@ -125,6 +135,8 @@ interface TelnyxConnectionState {
125
135
  onPlaybackMarkReceived?: () => void;
126
136
  clearPlayout: (reason: string) => void;
127
137
  readonly streamingResamplers: Map<string, StreamingPcm16Resampler>;
138
+ /** Stateful G.722 encoder/decoder (only used when codec is G722). */
139
+ g722: TelnyxG722State;
128
140
  }
129
141
 
130
142
  const DEFAULT_ENGINE_SAMPLE_RATE_HZ = 16000;
@@ -155,7 +167,7 @@ export async function createTelnyxMediaStreamServer(
155
167
  const sessions = new Set<VoiceAgentSession>();
156
168
  const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? DEFAULT_ENGINE_SAMPLE_RATE_HZ;
157
169
  const bidirectionalCodec: TelnyxCodec = options.bidirectionalCodec ?? "PCMU";
158
- const outboundSampleRateHz = bidirectionalCodec === "L16" ? 16000 : 8000;
170
+ const outboundSampleRateHz = wireSampleRateForCodec(bidirectionalCodec);
159
171
  const outboundFrameDurationMs = positiveInteger(options.outboundFrameDurationMs) ?? DEFAULT_OUTBOUND_FRAME_DURATION_MS;
160
172
  const maxQueuedOutputAudioMs = positiveInteger(options.maxQueuedOutputAudioMs) ?? DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS;
161
173
  const maxInboundReorderFrames = positiveInteger(options.maxInboundReorderFrames) ?? DEFAULT_MAX_INBOUND_REORDER_FRAMES;
@@ -172,6 +184,7 @@ export async function createTelnyxMediaStreamServer(
172
184
  const adapter: TransportAdapter<TelnyxConnectionState> = {
173
185
  createState: () => ({
174
186
  streamId: "",
187
+ callControlId: "",
175
188
  contextId: "",
176
189
  contextBase: "",
177
190
  turnCounter: 0,
@@ -190,6 +203,7 @@ export async function createTelnyxMediaStreamServer(
190
203
  pendingEndMarkName: "",
191
204
  clearPlayout: () => undefined,
192
205
  streamingResamplers: new Map(),
206
+ g722: createTelnyxG722State(bidirectionalCodec),
193
207
  }),
194
208
 
195
209
  async acquireSession({ request, shouldAbort, onSessionCreated }) {
@@ -221,6 +235,15 @@ export async function createTelnyxMediaStreamServer(
221
235
  };
222
236
  state.onPlaybackMarkReceived = sendPendingEndMark;
223
237
 
238
+ wireCarrierControl(session, disposers, {
239
+ carrier: "telnyx",
240
+ getCallControlId: () => state.callControlId || undefined,
241
+ credentials: options.callControlCredentials,
242
+ fetchImpl: options.callControlFetch,
243
+ webhookUrl: options.callControlWebhookUrl,
244
+ warmSummarizer: options.warmTransferSummarizer,
245
+ });
246
+
224
247
  const outbound = wireTelephonyOutboundPipeline({
225
248
  session,
226
249
  socket,
@@ -323,10 +346,16 @@ export async function createTelnyxMediaStreamServer(
323
346
  const format = validateTelnyxStart(start);
324
347
  state.streamId = message.stream_id ?? start.stream_id ?? "";
325
348
  if (!state.streamId) throw new Error("Telnyx start event is missing stream_id");
349
+ state.callControlId = start.call_control_id?.trim() ?? "";
326
350
  state.contextId = contextIdFn(start);
327
351
  state.contextBase = state.contextId;
328
352
  state.inboundCodec = format.codec;
329
353
  state.inboundSampleRateHz = format.sampleRateHz;
354
+ // G.722 is stateful — reset decoder on each stream start. Keep 16 kHz
355
+ // for inbound (do NOT downsample before STT; that is the quality pitfall).
356
+ if (format.codec === "G722") {
357
+ state.g722.decoder = createG722DecoderState();
358
+ }
330
359
  state.started = true;
331
360
  state.nextInboundMediaChunk = 1;
332
361
  state.inboundMediaReorderBuffer.clear();
@@ -338,7 +367,7 @@ export async function createTelnyxMediaStreamServer(
338
367
  const payload = message.media?.payload;
339
368
  if (!payload) throw new Error("Telnyx media event is missing media.payload");
340
369
  const encoded = decodeStrictBase64(payload, "media.payload");
341
- const pcm = decodeInboundPayload(encoded, state.inboundCodec);
370
+ const pcm = decodeInboundPayload(encoded, state);
342
371
  const chunk = optionalPositiveIntegerString(message.media?.chunk, "Telnyx media.chunk");
343
372
  if (chunk === undefined) {
344
373
  emitTelnyxMediaFrame(session, state, { chunk: 0, timestamp: message.media?.timestamp, pcm }, inputSampleRateHz);
@@ -620,6 +649,7 @@ function emitTelnyxMediaFrame(
620
649
  contextId: state.contextId,
621
650
  timestampMs: Date.now(),
622
651
  audio: pcm16SamplesToBytes(resampled),
652
+ sampleRateHz: inputSampleRateHz,
623
653
  });
624
654
  }
625
655
 
@@ -656,25 +686,8 @@ function rememberTelnyxMediaTimestamp(
656
686
  state.lastInboundMediaTimestampMs = timestampMs;
657
687
  }
658
688
 
659
- function validateTelnyxStart(start: TelnyxStartPayload): { readonly codec: TelnyxCodec; readonly sampleRateHz: number } {
660
- const format = start.media_format;
661
- if (!format) throw new Error("Telnyx start event is missing media_format");
662
- const encoding = format.encoding?.trim().toUpperCase();
663
- if (encoding !== "PCMU" && encoding !== "L16") {
664
- throw new Error(`Unsupported Telnyx media encoding: ${format.encoding ?? "unknown"}`);
665
- }
666
- const sampleRateHz = numberFromString(format.sample_rate);
667
- if (encoding === "PCMU" && sampleRateHz !== 8000) throw new Error(`Unsupported Telnyx PCMU sample rate: ${String(format.sample_rate)}`);
668
- if (encoding === "L16" && sampleRateHz !== 16000) throw new Error(`Unsupported Telnyx L16 sample rate: ${String(format.sample_rate)}`);
669
- if (sampleRateHz === null) throw new Error(`Unsupported Telnyx sample rate: ${String(format.sample_rate)}`);
670
- const channels = numberFromString(format.channels);
671
- if (channels !== 1) throw new Error(`Unsupported Telnyx channel count: ${String(format.channels)}`);
672
- return { codec: encoding, sampleRateHz };
673
- }
674
-
675
- function decodeInboundPayload(input: Uint8Array, codec: TelnyxCodec): Int16Array {
676
- if (codec === "PCMU") return decodeMuLawToPcm16(input);
677
- return bigEndianPcm16BytesToSamples(input);
689
+ function decodeInboundPayload(input: Uint8Array, state: TelnyxConnectionState): Int16Array {
690
+ return decodeTelnyxInboundPayload(input, state.inboundCodec, state.g722);
678
691
  }
679
692
 
680
693
  function encodeOutboundPayload(
@@ -685,25 +698,8 @@ function encodeOutboundPayload(
685
698
  ): Uint8Array[] {
686
699
  const samples = pcm16BytesToSamples(audio);
687
700
  const resampled = resamplePcm16Streaming(state.streamingResamplers, samples, sourceSampleRateHz, state.outboundSampleRateHz);
688
- const encoded = state.outboundCodec === "PCMU"
689
- ? encodePcm16ToMuLaw(resampled)
690
- : pcm16SamplesToBigEndianBytes(resampled);
691
- const frameBytes = Math.max(1, Math.round((state.outboundSampleRateHz * frameDurationMs) / 1000) * (state.outboundCodec === "L16" ? 2 : 1));
692
- const frames: Uint8Array[] = [];
693
- for (let offset = 0; offset < encoded.byteLength; offset += frameBytes) {
694
- frames.push(encoded.subarray(offset, Math.min(encoded.byteLength, offset + frameBytes)));
695
- }
696
- return frames;
697
- }
698
-
699
- function defaultTelnyxContextId(start: TelnyxStartPayload): string {
700
- const callControlId = start.call_control_id?.trim();
701
- if (callControlId) return `telnyx-${callControlId}`;
702
- const callSessionId = start.call_session_id?.trim();
703
- if (callSessionId) return `telnyx-${callSessionId}`;
704
- const streamId = start.stream_id?.trim();
705
- if (streamId) return `telnyx-${streamId}`;
706
- return `telnyx-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
701
+ const encoded = encodeTelnyxOutboundPayload(resampled, state.outboundCodec, state.g722);
702
+ return splitTelnyxEncodedFrames(encoded, state.outboundCodec, state.outboundSampleRateHz, frameDurationMs);
707
703
  }
708
704
 
709
705
  function sendTelnyxError(socket: WebSocket, streamId: string, message: string, maxBufferedAmountBytes: number): void {
package/src/twilio.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  positiveInteger,
34
34
  rawDataToText,
35
35
  } from "./transport-helpers.js";
36
+ import type { FetchLike, TwilioRestCredentials, WarmTransferSummarizer } from "./carrier-commands.js";
37
+ import { wireCarrierControl } from "./wire-carrier-control.js";
36
38
 
37
39
  export interface TwilioMediaStreamServerOptions {
38
40
  readonly server?: HttpServer;
@@ -56,6 +58,15 @@ export interface TwilioMediaStreamServerOptions {
56
58
  readonly maxConcurrentSessions?: number;
57
59
  readonly maxConcurrentSessionsScope?: "path" | "server";
58
60
  readonly onTransportMetric?: (name: string) => void;
61
+ /**
62
+ * Injected REST credentials for dtmf.send / call.transfer dispatch.
63
+ * Workers: pass secrets from `env`. Never read from process.env here.
64
+ * Live dispatch unverified without credentials.
65
+ */
66
+ readonly restCredentials?: TwilioRestCredentials;
67
+ readonly restFetch?: FetchLike;
68
+ readonly transferRedirectUrl?: string;
69
+ readonly warmTransferSummarizer?: WarmTransferSummarizer;
59
70
  }
60
71
 
61
72
  export interface TwilioMediaStreamServer {
@@ -92,6 +103,7 @@ interface TwilioMediaMessage {
92
103
 
93
104
  interface TwilioConnectionState {
94
105
  streamSid: string;
106
+ callSid: string;
95
107
  contextId: string;
96
108
  contextBase: string;
97
109
  turnCounter: number;
@@ -152,6 +164,7 @@ export async function createTwilioMediaStreamServer(
152
164
  const adapter: TransportAdapter<TwilioConnectionState> = {
153
165
  createState: () => ({
154
166
  streamSid: "",
167
+ callSid: "",
155
168
  contextId: "",
156
169
  contextBase: "",
157
170
  turnCounter: 0,
@@ -197,6 +210,15 @@ export async function createTwilioMediaStreamServer(
197
210
  };
198
211
  state.onPlaybackMarkReceived = sendPendingEndMark;
199
212
 
213
+ wireCarrierControl(session, disposers, {
214
+ carrier: "twilio",
215
+ getCallSid: () => state.callSid || undefined,
216
+ credentials: options.restCredentials,
217
+ fetchImpl: options.restFetch,
218
+ redirectUrl: options.transferRedirectUrl,
219
+ warmSummarizer: options.warmTransferSummarizer,
220
+ });
221
+
200
222
  const outbound = wireTelephonyOutboundPipeline({
201
223
  session,
202
224
  socket,
@@ -304,6 +326,7 @@ export async function createTwilioMediaStreamServer(
304
326
  validateTwilioStart(start, twilioSampleRateHz);
305
327
  state.streamSid = start.streamSid ?? message.streamSid ?? "";
306
328
  if (!state.streamSid) throw new Error("Twilio start event is missing streamSid");
329
+ state.callSid = start.callSid?.trim() ?? "";
307
330
  state.contextId = contextIdFn(start);
308
331
  state.contextBase = state.contextId;
309
332
  state.started = true;
@@ -324,6 +347,7 @@ export async function createTwilioMediaStreamServer(
324
347
  contextId: state.contextId,
325
348
  timestampMs: Date.now(),
326
349
  audio: pcm16SamplesToBytes(pcm16k),
350
+ sampleRateHz: inputSampleRateHz,
327
351
  });
328
352
  return;
329
353
  }
@@ -0,0 +1,179 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Bus wiring for dtmf.send + call.transfer → pure constructor + injectable dispatch.
4
+ // Mechanism unit-tested; live carrier HTTP unverified.
5
+
6
+ import { Route, type CallTransferPacket, type DtmfSendPacket, type VoiceAgentSession } from "@kuralle-syrinx/core";
7
+ import {
8
+ buildTelnyxSendDtmf,
9
+ buildTelnyxTransfer,
10
+ buildTwilioSendDigits,
11
+ buildTwilioTransfer,
12
+ dispatchTelnyxCommand,
13
+ dispatchTwilioCommand,
14
+ resolveTransferSummary,
15
+ type FetchLike,
16
+ type TelnyxRestCredentials,
17
+ type TwilioRestCredentials,
18
+ type WarmTransferSummarizer,
19
+ } from "./carrier-commands.js";
20
+
21
+ export interface TwilioCarrierControlOptions {
22
+ readonly carrier: "twilio";
23
+ /** Call SID for the active media stream (injected; may resolve lazily). */
24
+ getCallSid: () => string | undefined;
25
+ readonly credentials?: TwilioRestCredentials;
26
+ readonly fetchImpl?: FetchLike;
27
+ readonly redirectUrl?: string;
28
+ readonly warmSummarizer?: WarmTransferSummarizer;
29
+ }
30
+
31
+ export interface TelnyxCarrierControlOptions {
32
+ readonly carrier: "telnyx";
33
+ getCallControlId: () => string | undefined;
34
+ readonly credentials?: TelnyxRestCredentials;
35
+ readonly fetchImpl?: FetchLike;
36
+ readonly webhookUrl?: string;
37
+ readonly warmSummarizer?: WarmTransferSummarizer;
38
+ }
39
+
40
+ export type CarrierControlOptions = TwilioCarrierControlOptions | TelnyxCarrierControlOptions;
41
+
42
+ /**
43
+ * Subscribe to `dtmf.send` and `call.transfer` on the session bus.
44
+ * When credentials are absent, still builds the command and emits a metric so
45
+ * unit tests can assert the constructor path without live HTTP.
46
+ */
47
+ export function wireCarrierControl(
48
+ session: VoiceAgentSession,
49
+ disposers: Array<() => void>,
50
+ options: CarrierControlOptions,
51
+ ): void {
52
+ disposers.push(
53
+ session.bus.on("dtmf.send", (pkt) => {
54
+ void handleDtmfSend(session, pkt as DtmfSendPacket, options);
55
+ }),
56
+ session.bus.on("call.transfer", (pkt) => {
57
+ void handleCallTransfer(session, pkt as CallTransferPacket, options);
58
+ }),
59
+ );
60
+ }
61
+
62
+ async function handleDtmfSend(
63
+ session: VoiceAgentSession,
64
+ pkt: DtmfSendPacket,
65
+ options: CarrierControlOptions,
66
+ ): Promise<void> {
67
+ try {
68
+ if (options.carrier === "twilio") {
69
+ const callSid = options.getCallSid();
70
+ if (!callSid) {
71
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.missing_call_handle", "twilio");
72
+ return;
73
+ }
74
+ const command = buildTwilioSendDigits(callSid, pkt);
75
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.built", JSON.stringify({ carrier: "twilio", digits: command.form.SendDigits }));
76
+ if (!options.credentials) {
77
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.no_credentials", "twilio");
78
+ return;
79
+ }
80
+ const res = await dispatchTwilioCommand(options.credentials, command, options.fetchImpl);
81
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.dispatched", String(res.status));
82
+ return;
83
+ }
84
+
85
+ const callControlId = options.getCallControlId();
86
+ if (!callControlId) {
87
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.missing_call_handle", "telnyx");
88
+ return;
89
+ }
90
+ const command = buildTelnyxSendDtmf(callControlId, pkt);
91
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.built", JSON.stringify({ carrier: "telnyx", digits: command.json.digits }));
92
+ if (!options.credentials) {
93
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.no_credentials", "telnyx");
94
+ return;
95
+ }
96
+ const res = await dispatchTelnyxCommand(options.credentials, command, options.fetchImpl);
97
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.dispatched", String(res.status));
98
+ } catch (err) {
99
+ emitMetric(session, pkt.contextId, "carrier.dtmf_send.error", err instanceof Error ? err.message : String(err));
100
+ }
101
+ }
102
+
103
+ async function handleCallTransfer(
104
+ session: VoiceAgentSession,
105
+ pkt: CallTransferPacket,
106
+ options: CarrierControlOptions,
107
+ ): Promise<void> {
108
+ try {
109
+ const summary = await resolveTransferSummary(pkt, options.warmSummarizer);
110
+ const resolved = summary !== undefined ? { ...pkt, summary } : pkt;
111
+
112
+ if (options.carrier === "twilio") {
113
+ const callSid = options.getCallSid();
114
+ if (!callSid) {
115
+ emitMetric(session, pkt.contextId, "carrier.transfer.missing_call_handle", "twilio");
116
+ return;
117
+ }
118
+ const command = buildTwilioTransfer(callSid, resolved, {
119
+ redirectUrl: options.redirectUrl,
120
+ });
121
+ emitMetric(
122
+ session,
123
+ pkt.contextId,
124
+ "carrier.transfer.built",
125
+ JSON.stringify({
126
+ carrier: "twilio",
127
+ mode: command.mode,
128
+ target: resolved.target,
129
+ summary: command.summary ?? null,
130
+ }),
131
+ );
132
+ if (!options.credentials) {
133
+ emitMetric(session, pkt.contextId, "carrier.transfer.no_credentials", "twilio");
134
+ return;
135
+ }
136
+ const res = await dispatchTwilioCommand(options.credentials, command, options.fetchImpl);
137
+ emitMetric(session, pkt.contextId, "carrier.transfer.dispatched", String(res.status));
138
+ return;
139
+ }
140
+
141
+ const callControlId = options.getCallControlId();
142
+ if (!callControlId) {
143
+ emitMetric(session, pkt.contextId, "carrier.transfer.missing_call_handle", "telnyx");
144
+ return;
145
+ }
146
+ const command = buildTelnyxTransfer(callControlId, resolved, {
147
+ webhookUrl: options.webhookUrl,
148
+ });
149
+ emitMetric(
150
+ session,
151
+ pkt.contextId,
152
+ "carrier.transfer.built",
153
+ JSON.stringify({
154
+ carrier: "telnyx",
155
+ mode: command.mode,
156
+ target: command.json.to,
157
+ summary: command.summary ?? null,
158
+ }),
159
+ );
160
+ if (!options.credentials) {
161
+ emitMetric(session, pkt.contextId, "carrier.transfer.no_credentials", "telnyx");
162
+ return;
163
+ }
164
+ const res = await dispatchTelnyxCommand(options.credentials, command, options.fetchImpl);
165
+ emitMetric(session, pkt.contextId, "carrier.transfer.dispatched", String(res.status));
166
+ } catch (err) {
167
+ emitMetric(session, pkt.contextId, "carrier.transfer.error", err instanceof Error ? err.message : String(err));
168
+ }
169
+ }
170
+
171
+ function emitMetric(session: VoiceAgentSession, contextId: string, name: string, value: string): void {
172
+ session.bus.push(Route.Background, {
173
+ kind: "metric.conversation",
174
+ contextId,
175
+ timestampMs: Date.now(),
176
+ name,
177
+ value,
178
+ });
179
+ }