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