@kodelyth/voice-call 2026.5.42 → 2026.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +16 -4
  2. package/api.ts +0 -16
  3. package/cli-metadata.ts +0 -10
  4. package/config-api.ts +0 -12
  5. package/index.test.ts +0 -1075
  6. package/index.ts +0 -863
  7. package/runtime-api.ts +0 -20
  8. package/runtime-entry.ts +0 -1
  9. package/setup-api.ts +0 -47
  10. package/src/allowlist.test.ts +0 -18
  11. package/src/allowlist.ts +0 -19
  12. package/src/cli.test.ts +0 -12
  13. package/src/cli.ts +0 -866
  14. package/src/config-compat.test.ts +0 -130
  15. package/src/config-compat.ts +0 -227
  16. package/src/config.test.ts +0 -542
  17. package/src/config.ts +0 -883
  18. package/src/core-bridge.ts +0 -14
  19. package/src/deep-merge.test.ts +0 -40
  20. package/src/deep-merge.ts +0 -23
  21. package/src/gateway-continue-operation.ts +0 -200
  22. package/src/http-headers.test.ts +0 -16
  23. package/src/http-headers.ts +0 -15
  24. package/src/manager/context.ts +0 -50
  25. package/src/manager/events.test.ts +0 -578
  26. package/src/manager/events.ts +0 -332
  27. package/src/manager/lifecycle.ts +0 -53
  28. package/src/manager/lookup.test.ts +0 -52
  29. package/src/manager/lookup.ts +0 -35
  30. package/src/manager/outbound.test.ts +0 -629
  31. package/src/manager/outbound.ts +0 -508
  32. package/src/manager/state.ts +0 -48
  33. package/src/manager/store.ts +0 -107
  34. package/src/manager/timers.test.ts +0 -127
  35. package/src/manager/timers.ts +0 -113
  36. package/src/manager/twiml.test.ts +0 -13
  37. package/src/manager/twiml.ts +0 -17
  38. package/src/manager.closed-loop.test.ts +0 -259
  39. package/src/manager.inbound-allowlist.test.ts +0 -183
  40. package/src/manager.notify.test.ts +0 -390
  41. package/src/manager.restore.test.ts +0 -310
  42. package/src/manager.test-harness.ts +0 -127
  43. package/src/manager.ts +0 -441
  44. package/src/media-stream.test.ts +0 -953
  45. package/src/media-stream.ts +0 -876
  46. package/src/providers/base.ts +0 -99
  47. package/src/providers/mock.test.ts +0 -86
  48. package/src/providers/mock.ts +0 -185
  49. package/src/providers/plivo.test.ts +0 -93
  50. package/src/providers/plivo.ts +0 -601
  51. package/src/providers/shared/call-status.test.ts +0 -24
  52. package/src/providers/shared/call-status.ts +0 -24
  53. package/src/providers/shared/guarded-json-api.test.ts +0 -127
  54. package/src/providers/shared/guarded-json-api.ts +0 -49
  55. package/src/providers/telnyx.test.ts +0 -489
  56. package/src/providers/telnyx.ts +0 -419
  57. package/src/providers/twilio/api.test.ts +0 -184
  58. package/src/providers/twilio/api.ts +0 -100
  59. package/src/providers/twilio/twiml-policy.test.ts +0 -84
  60. package/src/providers/twilio/twiml-policy.ts +0 -87
  61. package/src/providers/twilio/webhook.ts +0 -34
  62. package/src/providers/twilio.test.ts +0 -607
  63. package/src/providers/twilio.ts +0 -861
  64. package/src/providers/twilio.types.ts +0 -17
  65. package/src/realtime-agent-context.test.ts +0 -101
  66. package/src/realtime-agent-context.ts +0 -149
  67. package/src/realtime-defaults.ts +0 -3
  68. package/src/realtime-fast-context.test.ts +0 -74
  69. package/src/realtime-fast-context.ts +0 -27
  70. package/src/realtime-transcription.runtime.ts +0 -4
  71. package/src/realtime-voice.runtime.ts +0 -5
  72. package/src/response-generator.test.ts +0 -385
  73. package/src/response-generator.ts +0 -348
  74. package/src/response-model.test.ts +0 -71
  75. package/src/response-model.ts +0 -23
  76. package/src/runtime.test.ts +0 -625
  77. package/src/runtime.ts +0 -528
  78. package/src/telephony-audio.test.ts +0 -61
  79. package/src/telephony-audio.ts +0 -12
  80. package/src/telephony-tts.test.ts +0 -196
  81. package/src/telephony-tts.ts +0 -235
  82. package/src/test-fixtures.ts +0 -82
  83. package/src/tts-provider-voice.test.ts +0 -34
  84. package/src/tts-provider-voice.ts +0 -21
  85. package/src/tunnel.test.ts +0 -173
  86. package/src/tunnel.ts +0 -314
  87. package/src/types.ts +0 -311
  88. package/src/utils.test.ts +0 -17
  89. package/src/utils.ts +0 -14
  90. package/src/voice-mapping.test.ts +0 -32
  91. package/src/voice-mapping.ts +0 -65
  92. package/src/webhook/realtime-audio-pacer.test.ts +0 -146
  93. package/src/webhook/realtime-audio-pacer.ts +0 -204
  94. package/src/webhook/realtime-handler.test.ts +0 -1450
  95. package/src/webhook/realtime-handler.ts +0 -1382
  96. package/src/webhook/stale-call-reaper.test.ts +0 -89
  97. package/src/webhook/stale-call-reaper.ts +0 -38
  98. package/src/webhook/stream-frame-adapter.test.ts +0 -187
  99. package/src/webhook/stream-frame-adapter.ts +0 -219
  100. package/src/webhook/tailscale.test.ts +0 -216
  101. package/src/webhook/tailscale.ts +0 -129
  102. package/src/webhook-exposure.test.ts +0 -33
  103. package/src/webhook-exposure.ts +0 -84
  104. package/src/webhook-security.test.ts +0 -813
  105. package/src/webhook-security.ts +0 -982
  106. package/src/webhook.hangup-once.lifecycle.test.ts +0 -179
  107. package/src/webhook.test.ts +0 -1615
  108. package/src/webhook.ts +0 -933
  109. package/src/webhook.types.ts +0 -5
  110. package/src/websocket-test-support.ts +0 -72
  111. package/tsconfig.json +0 -16
package/src/runtime.ts DELETED
@@ -1,528 +0,0 @@
1
- import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
2
- import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
3
- import { isLoopbackHost } from "klaw/plugin-sdk/gateway-runtime";
4
- import {
5
- consultRealtimeVoiceAgent,
6
- REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
7
- resolveRealtimeVoiceAgentConsultTools,
8
- resolveRealtimeVoiceAgentConsultToolsAllow,
9
- type RealtimeVoiceAgentConsultTranscriptEntry,
10
- type ResolvedRealtimeVoiceProvider,
11
- } from "klaw/plugin-sdk/realtime-voice";
12
- import type { VoiceCallConfig } from "./config.js";
13
- import {
14
- resolveVoiceCallEffectiveConfig,
15
- resolveVoiceCallSessionKey,
16
- resolveTwilioAuthToken,
17
- resolveVoiceCallConfig,
18
- validateProviderConfig,
19
- } from "./config.js";
20
- import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
21
- import { CallManager } from "./manager.js";
22
- import type { VoiceCallProvider } from "./providers/base.js";
23
- import type { TwilioProvider } from "./providers/twilio.js";
24
- import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
25
- import { resolveRealtimeFastContextConsult } from "./realtime-fast-context.js";
26
- import { resolveVoiceResponseModel } from "./response-model.js";
27
- import type { TelephonyTtsRuntime } from "./telephony-tts.js";
28
- import { createTelephonyTtsProvider } from "./telephony-tts.js";
29
- import { startTunnel, type TunnelResult } from "./tunnel.js";
30
- import {
31
- isProviderUnreachableWebhookUrl,
32
- providerRequiresPublicWebhook,
33
- } from "./webhook-exposure.js";
34
- import { VoiceCallWebhookServer } from "./webhook.js";
35
- import type { ToolHandlerContext } from "./webhook/realtime-handler.js";
36
- import { cleanupTailscaleExposure, setupTailscaleExposure } from "./webhook/tailscale.js";
37
-
38
- export type VoiceCallRuntime = {
39
- config: VoiceCallConfig;
40
- provider: VoiceCallProvider;
41
- manager: CallManager;
42
- webhookServer: VoiceCallWebhookServer;
43
- webhookUrl: string;
44
- publicUrl: string | null;
45
- stop: () => Promise<void>;
46
- };
47
-
48
- type Logger = {
49
- info: (message: string) => void;
50
- warn: (message: string) => void;
51
- error: (message: string) => void;
52
- debug?: (message: string) => void;
53
- };
54
-
55
- type ResolvedRealtimeProvider = ResolvedRealtimeVoiceProvider;
56
-
57
- type TelnyxProviderModule = typeof import("./providers/telnyx.js");
58
- type TwilioProviderModule = typeof import("./providers/twilio.js");
59
- type PlivoProviderModule = typeof import("./providers/plivo.js");
60
- type MockProviderModule = typeof import("./providers/mock.js");
61
- type RealtimeVoiceRuntimeModule = typeof import("./realtime-voice.runtime.js");
62
- type RealtimeHandlerModule = typeof import("./webhook/realtime-handler.js");
63
-
64
- const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
65
- "You are the configured Klaw agent receiving delegated requests from a live phone voice bridge.",
66
- "Act on behalf of the caller using the normal available tools when the caller asks you to do work.",
67
- "Prioritize completing the user's request and returning a fast, speakable result over exhaustive investigation.",
68
- "For tool-backed status checks, prefer one or two bounded read-only queries before answering.",
69
- "Do not print secret values or dump environment variables; only check whether required configuration is present.",
70
- "Be accurate, brief, and speakable.",
71
- ].join(" ");
72
-
73
- let telnyxProviderPromise: Promise<TelnyxProviderModule> | undefined;
74
- let twilioProviderPromise: Promise<TwilioProviderModule> | undefined;
75
- let plivoProviderPromise: Promise<PlivoProviderModule> | undefined;
76
- let mockProviderPromise: Promise<MockProviderModule> | undefined;
77
- let realtimeVoiceRuntimePromise: Promise<RealtimeVoiceRuntimeModule> | undefined;
78
- let realtimeHandlerPromise: Promise<RealtimeHandlerModule> | undefined;
79
-
80
- function loadTelnyxProvider(): Promise<TelnyxProviderModule> {
81
- telnyxProviderPromise ??= import("./providers/telnyx.js");
82
- return telnyxProviderPromise;
83
- }
84
-
85
- function loadTwilioProvider(): Promise<TwilioProviderModule> {
86
- twilioProviderPromise ??= import("./providers/twilio.js");
87
- return twilioProviderPromise;
88
- }
89
-
90
- function loadPlivoProvider(): Promise<PlivoProviderModule> {
91
- plivoProviderPromise ??= import("./providers/plivo.js");
92
- return plivoProviderPromise;
93
- }
94
-
95
- function loadMockProvider(): Promise<MockProviderModule> {
96
- mockProviderPromise ??= import("./providers/mock.js");
97
- return mockProviderPromise;
98
- }
99
-
100
- function loadRealtimeVoiceRuntime(): Promise<RealtimeVoiceRuntimeModule> {
101
- realtimeVoiceRuntimePromise ??= import("./realtime-voice.runtime.js");
102
- return realtimeVoiceRuntimePromise;
103
- }
104
-
105
- function loadRealtimeHandler(): Promise<RealtimeHandlerModule> {
106
- realtimeHandlerPromise ??= import("./webhook/realtime-handler.js");
107
- return realtimeHandlerPromise;
108
- }
109
-
110
- function resolveVoiceCallConsultSessionKey(call: {
111
- config: VoiceCallConfig;
112
- sessionKey?: string;
113
- from?: string;
114
- to?: string;
115
- direction?: "inbound" | "outbound";
116
- callId: string;
117
- }): string {
118
- if (call.sessionKey) {
119
- return call.sessionKey;
120
- }
121
- const phone = call.direction === "outbound" ? call.to : call.from;
122
- return resolveVoiceCallSessionKey({
123
- config: call.config,
124
- callId: call.callId,
125
- phone,
126
- });
127
- }
128
-
129
- function mapVoiceCallConsultTranscript(
130
- call: {
131
- transcript?: Array<{ speaker: "user" | "bot"; text: string }>;
132
- },
133
- context?: ToolHandlerContext,
134
- ): RealtimeVoiceAgentConsultTranscriptEntry[] {
135
- const transcript: RealtimeVoiceAgentConsultTranscriptEntry[] = (call.transcript ?? []).map(
136
- (entry) => ({
137
- role: entry.speaker === "bot" ? "assistant" : "user",
138
- text: entry.text,
139
- }),
140
- );
141
- const partial = context?.partialUserTranscript?.trim();
142
- if (partial && transcript.at(-1)?.text !== partial) {
143
- transcript.push({ role: "user", text: partial });
144
- }
145
- return transcript;
146
- }
147
-
148
- function createRuntimeResourceLifecycle(params: {
149
- config: VoiceCallConfig;
150
- webhookServer: VoiceCallWebhookServer;
151
- }): {
152
- setTunnelResult: (result: TunnelResult | null) => void;
153
- stop: (opts?: { suppressErrors?: boolean }) => Promise<void>;
154
- } {
155
- let tunnelResult: TunnelResult | null = null;
156
- let stopped = false;
157
-
158
- const runStep = async (step: () => Promise<void>, suppressErrors: boolean) => {
159
- if (suppressErrors) {
160
- await step().catch(() => {});
161
- return;
162
- }
163
- await step();
164
- };
165
-
166
- return {
167
- setTunnelResult: (result) => {
168
- tunnelResult = result;
169
- },
170
- stop: async (opts) => {
171
- if (stopped) {
172
- return;
173
- }
174
- stopped = true;
175
- const suppressErrors = opts?.suppressErrors ?? false;
176
- await runStep(async () => {
177
- if (tunnelResult) {
178
- await tunnelResult.stop();
179
- }
180
- }, suppressErrors);
181
- await runStep(async () => {
182
- await cleanupTailscaleExposure(params.config);
183
- }, suppressErrors);
184
- await runStep(async () => {
185
- await params.webhookServer.stop();
186
- }, suppressErrors);
187
- },
188
- };
189
- }
190
-
191
- async function resolveProvider(config: VoiceCallConfig): Promise<VoiceCallProvider> {
192
- const allowNgrokFreeTierLoopbackBypass =
193
- config.tunnel?.provider === "ngrok" &&
194
- isLoopbackHost(config.serve?.bind ?? "") &&
195
- (config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
196
-
197
- switch (config.provider) {
198
- case "telnyx": {
199
- const { TelnyxProvider } = await loadTelnyxProvider();
200
- return new TelnyxProvider(
201
- {
202
- apiKey: config.telnyx?.apiKey,
203
- connectionId: config.telnyx?.connectionId,
204
- publicKey: config.telnyx?.publicKey,
205
- },
206
- {
207
- skipVerification: config.skipSignatureVerification,
208
- },
209
- );
210
- }
211
- case "twilio": {
212
- const { TwilioProvider } = await loadTwilioProvider();
213
- return new TwilioProvider(
214
- {
215
- accountSid: config.twilio?.accountSid,
216
- authToken: resolveTwilioAuthToken(config),
217
- },
218
- {
219
- allowNgrokFreeTierLoopbackBypass,
220
- publicUrl: config.publicUrl,
221
- skipVerification: config.skipSignatureVerification,
222
- streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
223
- webhookSecurity: config.webhookSecurity,
224
- },
225
- );
226
- }
227
- case "plivo": {
228
- const { PlivoProvider } = await loadPlivoProvider();
229
- return new PlivoProvider(
230
- {
231
- authId: config.plivo?.authId,
232
- authToken: config.plivo?.authToken,
233
- },
234
- {
235
- publicUrl: config.publicUrl,
236
- skipVerification: config.skipSignatureVerification,
237
- ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
238
- webhookSecurity: config.webhookSecurity,
239
- },
240
- );
241
- }
242
- case "mock": {
243
- const { MockProvider } = await loadMockProvider();
244
- return new MockProvider();
245
- }
246
- default:
247
- throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
248
- }
249
- }
250
-
251
- async function resolveRealtimeProvider(params: {
252
- config: VoiceCallConfig;
253
- fullConfig: KlawConfig;
254
- }): Promise<ResolvedRealtimeProvider> {
255
- const { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
256
- return resolveConfiguredRealtimeVoiceProvider({
257
- configuredProviderId: params.config.realtime.provider,
258
- providerConfigs: params.config.realtime.providers,
259
- cfg: params.fullConfig,
260
- });
261
- }
262
-
263
- export async function createVoiceCallRuntime(params: {
264
- config: VoiceCallConfig;
265
- coreConfig: CoreConfig;
266
- fullConfig?: KlawConfig;
267
- agentRuntime: CoreAgentDeps;
268
- ttsRuntime?: TelephonyTtsRuntime;
269
- logger?: Logger;
270
- }): Promise<VoiceCallRuntime> {
271
- const { config: rawConfig, coreConfig, fullConfig, agentRuntime, ttsRuntime, logger } = params;
272
- const log = logger ?? {
273
- info: console.log,
274
- warn: console.warn,
275
- error: console.error,
276
- debug: console.debug,
277
- };
278
-
279
- const config = resolveVoiceCallConfig(rawConfig);
280
- const cfg = fullConfig ?? (coreConfig as KlawConfig);
281
-
282
- if (!config.enabled) {
283
- throw new Error("Voice call disabled. Enable the plugin entry in config.");
284
- }
285
-
286
- if (config.skipSignatureVerification) {
287
- log.warn(
288
- "[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.",
289
- );
290
- }
291
-
292
- const validation = validateProviderConfig(config);
293
- if (!validation.valid) {
294
- throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
295
- }
296
-
297
- const provider = await resolveProvider(config);
298
- const manager = new CallManager(config);
299
- const realtimeProvider = config.realtime.enabled
300
- ? await resolveRealtimeProvider({
301
- config,
302
- fullConfig: cfg,
303
- })
304
- : null;
305
- const webhookServer = new VoiceCallWebhookServer(
306
- config,
307
- manager,
308
- provider,
309
- coreConfig,
310
- fullConfig ?? (coreConfig as KlawConfig),
311
- agentRuntime,
312
- log,
313
- );
314
- if (realtimeProvider) {
315
- const { RealtimeCallHandler } = await loadRealtimeHandler();
316
- const realtimeInstructions = await buildRealtimeVoiceInstructions({
317
- baseInstructions: config.realtime.instructions,
318
- config,
319
- coreConfig,
320
- agentRuntime,
321
- });
322
- const realtimeConfig = {
323
- ...config.realtime,
324
- instructions: realtimeInstructions,
325
- tools: resolveRealtimeVoiceAgentConsultTools(
326
- config.realtime.toolPolicy,
327
- config.realtime.tools,
328
- ),
329
- };
330
- const realtimeHandler = new RealtimeCallHandler(
331
- realtimeConfig,
332
- manager,
333
- provider,
334
- realtimeProvider.provider,
335
- realtimeProvider.providerConfig,
336
- config.serve.path,
337
- cfg,
338
- );
339
- if (config.realtime.toolPolicy !== "none") {
340
- realtimeHandler.registerToolHandler(
341
- REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
342
- async (args, callId, handlerContext) => {
343
- const call = manager.getCall(callId);
344
- if (!call) {
345
- return { error: `Call "${callId}" not found` };
346
- }
347
- const numberRouteKey =
348
- typeof call.metadata?.numberRouteKey === "string"
349
- ? call.metadata.numberRouteKey
350
- : call.to;
351
- const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
352
- const agentId = effectiveConfig.agentId ?? "main";
353
- const sessionKey = resolveVoiceCallConsultSessionKey({
354
- ...call,
355
- config: effectiveConfig,
356
- });
357
- const requesterSessionKey =
358
- typeof call.metadata?.requesterSessionKey === "string"
359
- ? call.metadata.requesterSessionKey
360
- : undefined;
361
- const fastContext = await resolveRealtimeFastContextConsult({
362
- cfg,
363
- agentId,
364
- sessionKey,
365
- config: effectiveConfig.realtime.fastContext,
366
- args,
367
- logger: log,
368
- });
369
- if (fastContext.handled) {
370
- return fastContext.result;
371
- }
372
- const { provider: agentProvider, model } = resolveVoiceResponseModel({
373
- voiceConfig: effectiveConfig,
374
- agentRuntime,
375
- });
376
- const thinkLevel =
377
- effectiveConfig.realtime.consultThinkingLevel ??
378
- agentRuntime.resolveThinkingDefault({
379
- cfg,
380
- provider: agentProvider,
381
- model,
382
- });
383
- return await consultRealtimeVoiceAgent({
384
- cfg,
385
- agentRuntime,
386
- logger: log,
387
- agentId,
388
- sessionKey,
389
- messageProvider: "voice",
390
- lane: "voice",
391
- runIdPrefix: `voice-realtime-consult:${callId}`,
392
- args,
393
- transcript: mapVoiceCallConsultTranscript(call, handlerContext),
394
- surface: "a live phone call",
395
- userLabel: "Caller",
396
- assistantLabel: "Agent",
397
- questionSourceLabel: "caller",
398
- provider: agentProvider,
399
- model,
400
- thinkLevel,
401
- fastMode: effectiveConfig.realtime.consultFastMode,
402
- timeoutMs: effectiveConfig.responseTimeoutMs,
403
- spawnedBy: requesterSessionKey,
404
- contextMode: requesterSessionKey ? "fork" : undefined,
405
- toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(
406
- effectiveConfig.realtime.toolPolicy,
407
- ),
408
- extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
409
- });
410
- },
411
- );
412
- }
413
- webhookServer.setRealtimeHandler(realtimeHandler);
414
- }
415
- const lifecycle = createRuntimeResourceLifecycle({ config, webhookServer });
416
-
417
- const localUrl = await webhookServer.start();
418
-
419
- // Wrap remaining initialization in try/catch so the webhook server is
420
- // properly stopped if any subsequent step fails. Without this, the server
421
- // keeps the port bound while the runtime promise rejects, causing
422
- // EADDRINUSE on the next attempt. See: #32387
423
- try {
424
- // Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale
425
- let publicUrl: string | null = config.publicUrl ?? null;
426
-
427
- if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") {
428
- try {
429
- const nextTunnelResult = await startTunnel({
430
- provider: config.tunnel.provider,
431
- port: config.serve.port,
432
- path: config.serve.path,
433
- ngrokAuthToken: config.tunnel.ngrokAuthToken,
434
- ngrokDomain: config.tunnel.ngrokDomain,
435
- });
436
- lifecycle.setTunnelResult(nextTunnelResult);
437
- publicUrl = nextTunnelResult?.publicUrl ?? null;
438
- } catch (err) {
439
- log.error(`[voice-call] Tunnel setup failed: ${formatErrorMessage(err)}`);
440
- }
441
- }
442
-
443
- if (!publicUrl && config.tailscale?.mode !== "off") {
444
- publicUrl = await setupTailscaleExposure(config);
445
- }
446
-
447
- const webhookUrl = publicUrl ?? localUrl;
448
-
449
- if (
450
- providerRequiresPublicWebhook(provider.name) &&
451
- isProviderUnreachableWebhookUrl(webhookUrl)
452
- ) {
453
- throw new Error(
454
- `[voice-call] ${provider.name} requires a publicly reachable webhook URL. ` +
455
- `Refusing to use local-only webhook ${webhookUrl}. ` +
456
- "Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.",
457
- );
458
- }
459
-
460
- if (publicUrl) {
461
- provider.setPublicUrl?.(publicUrl);
462
- }
463
- if (publicUrl && realtimeProvider) {
464
- webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
465
- }
466
-
467
- const realtimeHandler = webhookServer.getRealtimeHandler();
468
- if (realtimeHandler) {
469
- manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
470
- }
471
-
472
- if (provider.name === "twilio" && config.streaming?.enabled) {
473
- const twilioProvider = provider as TwilioProvider;
474
- if (ttsRuntime?.textToSpeechTelephony) {
475
- try {
476
- const ttsProvider = createTelephonyTtsProvider({
477
- coreConfig,
478
- ttsOverride: config.tts,
479
- runtime: ttsRuntime,
480
- logger: log,
481
- });
482
- twilioProvider.setTTSProvider(ttsProvider);
483
- log.info("[voice-call] Telephony TTS provider configured");
484
- } catch (err) {
485
- log.warn(`[voice-call] Failed to initialize telephony TTS: ${formatErrorMessage(err)}`);
486
- }
487
- } else {
488
- log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
489
- }
490
-
491
- const mediaHandler = webhookServer.getMediaStreamHandler();
492
- if (mediaHandler) {
493
- twilioProvider.setMediaStreamHandler(mediaHandler);
494
- log.info("[voice-call] Media stream handler wired to provider");
495
- }
496
- }
497
-
498
- if (realtimeProvider) {
499
- log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
500
- }
501
-
502
- await manager.initialize(provider, webhookUrl);
503
-
504
- const stop = async () => await lifecycle.stop();
505
-
506
- log.info("[voice-call] Runtime initialized");
507
- log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
508
- if (publicUrl && publicUrl !== webhookUrl) {
509
- log.info(`[voice-call] Public URL: ${publicUrl}`);
510
- }
511
-
512
- return {
513
- config,
514
- provider,
515
- manager,
516
- webhookServer,
517
- webhookUrl,
518
- publicUrl,
519
- stop,
520
- };
521
- } catch (err) {
522
- // If any step after the server started fails, clean up every provisioned
523
- // resource (tunnel, tailscale exposure, and webhook server) so retries
524
- // don't leak processes or keep the port bound.
525
- await lifecycle.stop({ suppressErrors: true });
526
- throw err;
527
- }
528
- }
@@ -1,61 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { convertPcmToMulaw8k, resamplePcmTo8k } from "./telephony-audio.js";
3
-
4
- function makeSinePcm(
5
- sampleRate: number,
6
- frequencyHz: number,
7
- durationSeconds: number,
8
- amplitude = 12_000,
9
- ): Buffer {
10
- const samples = Math.floor(sampleRate * durationSeconds);
11
- const output = Buffer.alloc(samples * 2);
12
- for (let i = 0; i < samples; i++) {
13
- const value = Math.round(Math.sin((2 * Math.PI * frequencyHz * i) / sampleRate) * amplitude);
14
- output.writeInt16LE(value, i * 2);
15
- }
16
- return output;
17
- }
18
-
19
- function rmsPcm(buffer: Buffer): number {
20
- const samples = Math.floor(buffer.length / 2);
21
- if (samples === 0) {
22
- return 0;
23
- }
24
- let sum = 0;
25
- for (let i = 0; i < samples; i++) {
26
- const sample = buffer.readInt16LE(i * 2);
27
- sum += sample * sample;
28
- }
29
- return Math.sqrt(sum / samples);
30
- }
31
-
32
- describe("telephony-audio resamplePcmTo8k", () => {
33
- it("returns identical buffer for 8k input", () => {
34
- const pcm8k = makeSinePcm(8_000, 1_000, 0.2);
35
- const resampled = resamplePcmTo8k(pcm8k, 8_000);
36
- expect(resampled).toBe(pcm8k);
37
- });
38
-
39
- it("preserves low-frequency speech-band energy when downsampling", () => {
40
- const input = makeSinePcm(48_000, 1_000, 0.6);
41
- const output = resamplePcmTo8k(input, 48_000);
42
- expect(output.length).toBe(9_600);
43
- expect(rmsPcm(output)).toBeGreaterThan(7_500);
44
- });
45
-
46
- it("attenuates out-of-band high frequencies before 8k telephony conversion", () => {
47
- const lowTone = resamplePcmTo8k(makeSinePcm(48_000, 1_000, 0.6), 48_000);
48
- const highTone = resamplePcmTo8k(makeSinePcm(48_000, 6_000, 0.6), 48_000);
49
- const ratio = rmsPcm(highTone) / rmsPcm(lowTone);
50
- expect(ratio).toBeLessThan(0.1);
51
- });
52
- });
53
-
54
- describe("telephony-audio convertPcmToMulaw8k", () => {
55
- it("converts to 8k mu-law frame length", () => {
56
- const input = makeSinePcm(24_000, 1_000, 0.5);
57
- const mulaw = convertPcmToMulaw8k(input, 24_000);
58
- // 0.5s @ 8kHz => 4000 8-bit samples
59
- expect(mulaw.length).toBe(4_000);
60
- });
61
- });
@@ -1,12 +0,0 @@
1
- export { convertPcmToMulaw8k, resamplePcmTo8k } from "klaw/plugin-sdk/realtime-voice";
2
-
3
- /**
4
- * Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
5
- */
6
- export function chunkAudio(audio: Buffer, chunkSize = 160): Generator<Buffer, void, unknown> {
7
- return (function* () {
8
- for (let i = 0; i < audio.length; i += chunkSize) {
9
- yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
10
- }
11
- })();
12
- }