@clawvoice/voice-assistant 1.0.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.
Files changed (50) hide show
  1. package/.env.example +125 -0
  2. package/CHANGELOG.md +112 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/dist/cli.d.ts +10 -0
  6. package/dist/cli.js +272 -0
  7. package/dist/config.d.ts +42 -0
  8. package/dist/config.js +182 -0
  9. package/dist/diagnostics/health.d.ts +14 -0
  10. package/dist/diagnostics/health.js +182 -0
  11. package/dist/hooks.d.ts +16 -0
  12. package/dist/hooks.js +113 -0
  13. package/dist/inbound/classifier.d.ts +5 -0
  14. package/dist/inbound/classifier.js +72 -0
  15. package/dist/inbound/types.d.ts +30 -0
  16. package/dist/inbound/types.js +2 -0
  17. package/dist/index.d.ts +5 -0
  18. package/dist/index.js +52 -0
  19. package/dist/routes.d.ts +6 -0
  20. package/dist/routes.js +89 -0
  21. package/dist/services/memory-extraction.d.ts +42 -0
  22. package/dist/services/memory-extraction.js +117 -0
  23. package/dist/services/post-call.d.ts +56 -0
  24. package/dist/services/post-call.js +112 -0
  25. package/dist/services/relay.d.ts +9 -0
  26. package/dist/services/relay.js +19 -0
  27. package/dist/services/voice-call.d.ts +61 -0
  28. package/dist/services/voice-call.js +189 -0
  29. package/dist/telephony/telnyx.d.ts +12 -0
  30. package/dist/telephony/telnyx.js +60 -0
  31. package/dist/telephony/twilio.d.ts +12 -0
  32. package/dist/telephony/twilio.js +63 -0
  33. package/dist/telephony/types.d.ts +15 -0
  34. package/dist/telephony/types.js +2 -0
  35. package/dist/telephony/util.d.ts +2 -0
  36. package/dist/telephony/util.js +25 -0
  37. package/dist/tools.d.ts +5 -0
  38. package/dist/tools.js +167 -0
  39. package/dist/voice/bridge.d.ts +47 -0
  40. package/dist/voice/bridge.js +411 -0
  41. package/dist/voice/types.d.ts +168 -0
  42. package/dist/voice/types.js +42 -0
  43. package/dist/webhooks/verify.d.ts +30 -0
  44. package/dist/webhooks/verify.js +95 -0
  45. package/docs/FEATURES.md +36 -0
  46. package/docs/OPENCLAW_PLUGIN_GUIDE.md +1202 -0
  47. package/docs/SETUP.md +303 -0
  48. package/openclaw.plugin.json +137 -0
  49. package/package.json +37 -0
  50. package/skills/voice-assistant/SKILL.md +15 -0
@@ -0,0 +1,411 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VoiceBridgeService = void 0;
4
+ const types_1 = require("./types");
5
+ const TWILIO_CHUNK_SIZE = 160;
6
+ const BUFFER_CHUNKS = 20;
7
+ const BUFFER_SIZE = TWILIO_CHUNK_SIZE * BUFFER_CHUNKS;
8
+ const HEARTBEAT_TIMEOUT_MS = 2000;
9
+ class VoiceBridgeService {
10
+ constructor(config) {
11
+ this.config = config;
12
+ this.bridges = new Map();
13
+ this.disconnectionHandler = null;
14
+ }
15
+ onDisconnection(handler) {
16
+ this.disconnectionHandler = handler;
17
+ }
18
+ negotiateAndValidate(telephonyCodec = "mulaw", voiceProviderCodec = "mulaw", sampleRate = 8000) {
19
+ return (0, types_1.negotiateCodec)(telephonyCodec, voiceProviderCodec, sampleRate);
20
+ }
21
+ createSession(sessionConfig) {
22
+ const codecResult = this.negotiateAndValidate(sessionConfig.telephonyCodec, sessionConfig.voiceProviderCodec, sessionConfig.sampleRate);
23
+ if (!codecResult.ok) {
24
+ return {
25
+ type: "error",
26
+ callId: sessionConfig.callId,
27
+ timestamp: new Date().toISOString(),
28
+ data: {
29
+ error: codecResult.error,
30
+ suggestion: codecResult.suggestion,
31
+ },
32
+ };
33
+ }
34
+ const bridge = {
35
+ callId: sessionConfig.callId,
36
+ providerCallId: sessionConfig.providerCallId,
37
+ codecResult,
38
+ transcript: [],
39
+ keepAliveTimer: null,
40
+ heartbeatTimer: null,
41
+ lastActivityAt: Date.now(),
42
+ greetingGraceActive: true,
43
+ audioBuffer: Buffer.alloc(BUFFER_SIZE),
44
+ audioBufferOffset: 0,
45
+ connected: false,
46
+ startedAt: new Date().toISOString(),
47
+ turnState: "idle",
48
+ pendingFunctionCalls: new Map(),
49
+ disconnectionRecord: null,
50
+ failures: [],
51
+ voiceSocket: null,
52
+ };
53
+ this.bridges.set(sessionConfig.callId, bridge);
54
+ return {
55
+ type: "connected",
56
+ callId: sessionConfig.callId,
57
+ timestamp: bridge.startedAt,
58
+ data: {
59
+ codec: codecResult.telephonyCodec,
60
+ sampleRate: codecResult.sampleRate,
61
+ voiceModel: sessionConfig.voiceModel,
62
+ },
63
+ };
64
+ }
65
+ buildSettingsMessage(sessionConfig) {
66
+ return {
67
+ type: "Settings",
68
+ audio: {
69
+ input: {
70
+ encoding: sessionConfig.telephonyCodec === "mulaw" ? "mulaw" : "linear16",
71
+ sample_rate: sessionConfig.sampleRate,
72
+ },
73
+ output: {
74
+ encoding: sessionConfig.voiceProviderCodec === "mulaw" ? "mulaw" : "linear16",
75
+ sample_rate: sessionConfig.sampleRate,
76
+ container: "none",
77
+ },
78
+ },
79
+ agent: {
80
+ listen: { model: "nova-3" },
81
+ speak: { model: sessionConfig.voiceModel },
82
+ think: {
83
+ provider: { type: "open_ai" },
84
+ model: this.config.analysisModel,
85
+ instructions: sessionConfig.systemPrompt ?? "",
86
+ },
87
+ greeting: { text: sessionConfig.greeting },
88
+ },
89
+ };
90
+ }
91
+ setVoiceSocket(callId, socket) {
92
+ const bridge = this.bridges.get(callId);
93
+ if (bridge) {
94
+ bridge.voiceSocket = socket;
95
+ }
96
+ }
97
+ getVoiceSocket(callId) {
98
+ return this.bridges.get(callId)?.voiceSocket ?? null;
99
+ }
100
+ startKeepAlive(callId, intervalMs) {
101
+ const bridge = this.bridges.get(callId);
102
+ if (!bridge) {
103
+ return;
104
+ }
105
+ bridge.connected = true;
106
+ if (bridge.keepAliveTimer) {
107
+ clearInterval(bridge.keepAliveTimer);
108
+ }
109
+ bridge.keepAliveTimer = setInterval(() => {
110
+ if (bridge.voiceSocket && bridge.voiceSocket.readyState === 1) {
111
+ bridge.voiceSocket.send(JSON.stringify({ type: "KeepAlive" }));
112
+ }
113
+ }, intervalMs);
114
+ bridge.keepAliveTimer.unref?.();
115
+ }
116
+ recordActivity(callId) {
117
+ const bridge = this.bridges.get(callId);
118
+ if (bridge) {
119
+ bridge.lastActivityAt = Date.now();
120
+ }
121
+ }
122
+ startHeartbeatMonitor(callId, timeoutMs = HEARTBEAT_TIMEOUT_MS) {
123
+ const bridge = this.bridges.get(callId);
124
+ if (!bridge) {
125
+ return;
126
+ }
127
+ this.stopHeartbeatMonitor(callId);
128
+ bridge.lastActivityAt = Date.now();
129
+ const check = () => {
130
+ const b = this.bridges.get(callId);
131
+ if (!b || !b.connected) {
132
+ return;
133
+ }
134
+ const elapsed = Date.now() - b.lastActivityAt;
135
+ if (elapsed >= timeoutMs) {
136
+ this.reportDisconnection(callId, "heartbeat_timeout", `No activity for ${elapsed}ms (threshold: ${timeoutMs}ms)`);
137
+ return;
138
+ }
139
+ b.heartbeatTimer = setTimeout(check, Math.max(timeoutMs - elapsed, 100));
140
+ b.heartbeatTimer.unref?.();
141
+ };
142
+ bridge.heartbeatTimer = setTimeout(check, timeoutMs);
143
+ bridge.heartbeatTimer.unref?.();
144
+ }
145
+ stopHeartbeatMonitor(callId) {
146
+ const bridge = this.bridges.get(callId);
147
+ if (bridge?.heartbeatTimer) {
148
+ clearTimeout(bridge.heartbeatTimer);
149
+ bridge.heartbeatTimer = null;
150
+ }
151
+ }
152
+ reportDisconnection(callId, reason, detail) {
153
+ const bridge = this.bridges.get(callId);
154
+ if (!bridge) {
155
+ return null;
156
+ }
157
+ const startMs = new Date(bridge.startedAt).getTime();
158
+ const record = {
159
+ callId,
160
+ reason,
161
+ detail,
162
+ detectedAt: new Date().toISOString(),
163
+ callDurationMs: Date.now() - startMs,
164
+ transcriptLength: bridge.transcript.length,
165
+ };
166
+ bridge.disconnectionRecord = record;
167
+ bridge.connected = false;
168
+ bridge.failures.push({
169
+ type: "disconnection",
170
+ description: `${reason}: ${detail}`,
171
+ timestamp: new Date().toISOString(),
172
+ });
173
+ this.stopHeartbeatMonitor(callId);
174
+ if (this.disconnectionHandler) {
175
+ this.disconnectionHandler(record);
176
+ }
177
+ return record;
178
+ }
179
+ getDisconnectionRecord(callId) {
180
+ return this.bridges.get(callId)?.disconnectionRecord ?? null;
181
+ }
182
+ endGreetingGrace(callId) {
183
+ const bridge = this.bridges.get(callId);
184
+ if (bridge) {
185
+ bridge.greetingGraceActive = false;
186
+ }
187
+ }
188
+ isGreetingGraceActive(callId) {
189
+ return this.bridges.get(callId)?.greetingGraceActive ?? false;
190
+ }
191
+ bufferTelephonyAudio(callId, chunk) {
192
+ const bridge = this.bridges.get(callId);
193
+ if (!bridge) {
194
+ return null;
195
+ }
196
+ bridge.lastActivityAt = Date.now();
197
+ const remaining = BUFFER_SIZE - bridge.audioBufferOffset;
198
+ const toCopy = Math.min(chunk.length, remaining);
199
+ chunk.copy(bridge.audioBuffer, bridge.audioBufferOffset, 0, toCopy);
200
+ bridge.audioBufferOffset += toCopy;
201
+ if (bridge.audioBufferOffset >= BUFFER_SIZE) {
202
+ const flushed = Buffer.from(bridge.audioBuffer.subarray(0, BUFFER_SIZE));
203
+ bridge.audioBufferOffset = 0;
204
+ return flushed;
205
+ }
206
+ return null;
207
+ }
208
+ flushAudioBuffer(callId) {
209
+ const bridge = this.bridges.get(callId);
210
+ if (!bridge || bridge.audioBufferOffset === 0) {
211
+ return null;
212
+ }
213
+ const flushed = Buffer.from(bridge.audioBuffer.subarray(0, bridge.audioBufferOffset));
214
+ bridge.audioBufferOffset = 0;
215
+ return flushed;
216
+ }
217
+ addTranscriptEntry(callId, entry) {
218
+ const bridge = this.bridges.get(callId);
219
+ if (bridge) {
220
+ bridge.transcript.push(entry);
221
+ }
222
+ }
223
+ getTranscript(callId) {
224
+ return this.bridges.get(callId)?.transcript ?? [];
225
+ }
226
+ destroySession(callId) {
227
+ const bridge = this.bridges.get(callId);
228
+ if (bridge?.keepAliveTimer) {
229
+ clearInterval(bridge.keepAliveTimer);
230
+ }
231
+ if (bridge?.heartbeatTimer) {
232
+ clearTimeout(bridge.heartbeatTimer);
233
+ }
234
+ if (bridge?.voiceSocket) {
235
+ try {
236
+ bridge.voiceSocket.close();
237
+ }
238
+ catch { /* ignore */ }
239
+ }
240
+ this.bridges.delete(callId);
241
+ return {
242
+ type: "disconnected",
243
+ callId,
244
+ timestamp: new Date().toISOString(),
245
+ data: {
246
+ transcriptLength: bridge?.transcript.length ?? 0,
247
+ },
248
+ };
249
+ }
250
+ handleVoiceAgentMessage(callId, message) {
251
+ const bridge = this.bridges.get(callId);
252
+ if (!bridge) {
253
+ return { action: "none" };
254
+ }
255
+ bridge.lastActivityAt = Date.now();
256
+ const msgType = message.type;
257
+ switch (msgType) {
258
+ case "SettingsApplied":
259
+ bridge.connected = true;
260
+ return { action: "settings_applied" };
261
+ case "UserStartedSpeaking":
262
+ return this.handleBargeIn(callId);
263
+ case "AgentStartedSpeaking":
264
+ bridge.turnState = "agent_speaking";
265
+ return { action: "turn_change", state: "agent_speaking" };
266
+ case "ConversationText": {
267
+ const role = message.role;
268
+ const content = message.content;
269
+ if (role && content) {
270
+ const speaker = role === "user" ? "user" : "agent";
271
+ const entry = {
272
+ speaker,
273
+ text: content,
274
+ timestamp: new Date().toISOString(),
275
+ };
276
+ bridge.transcript.push(entry);
277
+ return { action: "transcript", entry };
278
+ }
279
+ return { action: "none" };
280
+ }
281
+ case "FunctionCallRequest": {
282
+ const fcReq = {
283
+ id: message.function_call_id,
284
+ name: message.function_name,
285
+ input: message.input ?? {},
286
+ };
287
+ bridge.pendingFunctionCalls.set(fcReq.id, fcReq);
288
+ return { action: "function_call", request: fcReq };
289
+ }
290
+ case "Error": {
291
+ const errorMsg = message.message ?? "Unknown voice agent error";
292
+ const record = this.reportDisconnection(callId, "voice_provider_error", errorMsg);
293
+ if (record) {
294
+ return { action: "disconnection", record };
295
+ }
296
+ return { action: "error", error: errorMsg };
297
+ }
298
+ default:
299
+ return { action: "none" };
300
+ }
301
+ }
302
+ handleBargeIn(callId) {
303
+ const bridge = this.bridges.get(callId);
304
+ if (!bridge) {
305
+ return { action: "none" };
306
+ }
307
+ const duringGrace = bridge.greetingGraceActive;
308
+ bridge.turnState = "user_speaking";
309
+ return { action: "barge_in", duringGrace };
310
+ }
311
+ completeFunctionCall(callId, response) {
312
+ const bridge = this.bridges.get(callId);
313
+ if (!bridge) {
314
+ return false;
315
+ }
316
+ bridge.pendingFunctionCalls.delete(response.id);
317
+ return true;
318
+ }
319
+ getTurnState(callId) {
320
+ return this.bridges.get(callId)?.turnState ?? "idle";
321
+ }
322
+ setTurnState(callId, state) {
323
+ const bridge = this.bridges.get(callId);
324
+ if (bridge) {
325
+ bridge.turnState = state;
326
+ }
327
+ }
328
+ getPendingFunctionCalls(callId) {
329
+ const bridge = this.bridges.get(callId);
330
+ if (!bridge) {
331
+ return [];
332
+ }
333
+ return Array.from(bridge.pendingFunctionCalls.values());
334
+ }
335
+ recordFailure(callId, failure) {
336
+ const bridge = this.bridges.get(callId);
337
+ if (bridge) {
338
+ bridge.failures.push(failure);
339
+ }
340
+ }
341
+ getFailures(callId) {
342
+ return this.bridges.get(callId)?.failures ?? [];
343
+ }
344
+ generateCallSummary(callId) {
345
+ const bridge = this.bridges.get(callId);
346
+ if (!bridge) {
347
+ return null;
348
+ }
349
+ const startMs = new Date(bridge.startedAt).getTime();
350
+ const durationMs = Date.now() - startMs;
351
+ const pendingActions = Array.from(bridge.pendingFunctionCalls.values()).map((fc) => `${fc.name}(${JSON.stringify(fc.input)})`);
352
+ const outcome = this.determineOutcome(bridge);
353
+ const retryContext = outcome !== "completed"
354
+ ? this.buildRetryContext(bridge, pendingActions)
355
+ : null;
356
+ return {
357
+ callId,
358
+ outcome,
359
+ durationMs,
360
+ transcriptLength: bridge.transcript.length,
361
+ failures: [...bridge.failures],
362
+ pendingActions,
363
+ retryContext,
364
+ completedAt: new Date().toISOString(),
365
+ };
366
+ }
367
+ determineOutcome(bridge) {
368
+ if (bridge.failures.length === 0 && bridge.pendingFunctionCalls.size === 0) {
369
+ return "completed";
370
+ }
371
+ const hasHardFailure = bridge.failures.some((f) => f.type === "disconnection" || f.type === "timeout");
372
+ if (hasHardFailure || bridge.transcript.length === 0) {
373
+ return "failed";
374
+ }
375
+ return "partial";
376
+ }
377
+ buildRetryContext(bridge, pendingActions) {
378
+ const failureReasons = bridge.failures.map((f) => f.description);
379
+ if (bridge.disconnectionRecord) {
380
+ failureReasons.push(`Disconnected: ${bridge.disconnectionRecord.reason} — ${bridge.disconnectionRecord.detail}`);
381
+ }
382
+ const lastEntries = bridge.transcript.slice(-3);
383
+ const previousTranscriptSummary = lastEntries.length > 0
384
+ ? lastEntries.map((e) => `${e.speaker}: ${e.text}`).join(" | ")
385
+ : "No transcript recorded.";
386
+ const suggestedApproach = pendingActions.length > 0
387
+ ? `Retry with pending actions: ${pendingActions.join(", ")}`
388
+ : failureReasons.length > 0
389
+ ? `Address failures: ${failureReasons.join("; ")}`
390
+ : "Retry call with same parameters.";
391
+ return {
392
+ originalCallId: bridge.callId,
393
+ failureReasons,
394
+ uncompletedActions: pendingActions,
395
+ previousTranscriptSummary,
396
+ suggestedApproach,
397
+ };
398
+ }
399
+ getActiveBridgeCount() {
400
+ return this.bridges.size;
401
+ }
402
+ hasActiveBridge(callId) {
403
+ return this.bridges.has(callId);
404
+ }
405
+ async stopAll() {
406
+ for (const callId of Array.from(this.bridges.keys())) {
407
+ this.destroySession(callId);
408
+ }
409
+ }
410
+ }
411
+ exports.VoiceBridgeService = VoiceBridgeService;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Supported audio codecs for telephony ↔ voice provider bridging.
3
+ */
4
+ export type AudioCodec = "mulaw" | "pcm16";
5
+ /**
6
+ * Audio format negotiation result.
7
+ */
8
+ export interface AudioFormat {
9
+ codec: AudioCodec;
10
+ sampleRate: number;
11
+ channels: number;
12
+ }
13
+ /**
14
+ * Configuration for a voice bridge session.
15
+ */
16
+ export interface BridgeSessionConfig {
17
+ callId: string;
18
+ providerCallId: string;
19
+ voiceProviderUrl: string;
20
+ voiceProviderAuth: string;
21
+ telephonyCodec: AudioCodec;
22
+ voiceProviderCodec: AudioCodec;
23
+ sampleRate: number;
24
+ greeting: string;
25
+ systemPrompt?: string;
26
+ voiceModel: string;
27
+ keepAliveIntervalMs: number;
28
+ greetingGracePeriodMs: number;
29
+ }
30
+ /**
31
+ * Events emitted by a VoiceBridge session.
32
+ */
33
+ export type BridgeEventType = "connected" | "audio_from_agent" | "transcript" | "agent_speaking" | "user_speaking" | "function_call" | "error" | "disconnected";
34
+ export interface BridgeEvent {
35
+ type: BridgeEventType;
36
+ callId: string;
37
+ timestamp: string;
38
+ data?: Record<string, unknown>;
39
+ }
40
+ /**
41
+ * Transcript entry for a voice bridge session.
42
+ */
43
+ export interface TranscriptEntry {
44
+ speaker: "user" | "agent";
45
+ text: string;
46
+ timestamp: string;
47
+ }
48
+ /**
49
+ * Turn-taking state for a voice bridge session.
50
+ */
51
+ export type TurnState = "idle" | "agent_speaking" | "user_speaking";
52
+ /**
53
+ * Reasons a voice bridge session can disconnect.
54
+ */
55
+ export type DisconnectionReason = "heartbeat_timeout" | "voice_provider_error" | "telephony_provider_error" | "agent_ended" | "user_hangup" | "max_duration" | "unknown";
56
+ /**
57
+ * Disconnection event data logged when a call terminates unexpectedly.
58
+ */
59
+ export interface DisconnectionRecord {
60
+ callId: string;
61
+ reason: DisconnectionReason;
62
+ detail: string;
63
+ detectedAt: string;
64
+ callDurationMs: number;
65
+ transcriptLength: number;
66
+ }
67
+ /**
68
+ * Inbound function call request from the voice agent.
69
+ */
70
+ export interface FunctionCallRequest {
71
+ id: string;
72
+ name: string;
73
+ input: Record<string, unknown>;
74
+ }
75
+ /**
76
+ * Outbound function call response back to the voice agent.
77
+ */
78
+ export interface FunctionCallResponse {
79
+ id: string;
80
+ name: string;
81
+ output: string;
82
+ }
83
+ /**
84
+ * Result from processing a voice agent message through the bridge.
85
+ */
86
+ export type VoiceAgentMessageResult = {
87
+ action: "audio";
88
+ data: Buffer;
89
+ } | {
90
+ action: "function_call";
91
+ request: FunctionCallRequest;
92
+ } | {
93
+ action: "barge_in";
94
+ duringGrace: boolean;
95
+ } | {
96
+ action: "transcript";
97
+ entry: TranscriptEntry;
98
+ } | {
99
+ action: "turn_change";
100
+ state: TurnState;
101
+ } | {
102
+ action: "settings_applied";
103
+ } | {
104
+ action: "error";
105
+ error: string;
106
+ } | {
107
+ action: "disconnection";
108
+ record: DisconnectionRecord;
109
+ } | {
110
+ action: "none";
111
+ };
112
+ /**
113
+ * Completion status of a call.
114
+ */
115
+ export type CallOutcome = "completed" | "partial" | "failed";
116
+ /**
117
+ * Specific failure that occurred during a call.
118
+ */
119
+ export interface CallFailure {
120
+ type: "tool_failure" | "missing_data" | "timeout" | "disconnection" | "agent_error";
121
+ description: string;
122
+ timestamp: string;
123
+ functionCallId?: string;
124
+ }
125
+ /**
126
+ * Post-call summary including outcome, failures, and retry context.
127
+ */
128
+ export interface CallSummary {
129
+ callId: string;
130
+ outcome: CallOutcome;
131
+ durationMs: number;
132
+ transcriptLength: number;
133
+ failures: CallFailure[];
134
+ pendingActions: string[];
135
+ retryContext: RetryContext | null;
136
+ completedAt: string;
137
+ }
138
+ /**
139
+ * Context for retrying a call that was incomplete or failed.
140
+ */
141
+ export interface RetryContext {
142
+ originalCallId: string;
143
+ failureReasons: string[];
144
+ uncompletedActions: string[];
145
+ previousTranscriptSummary: string;
146
+ suggestedApproach: string;
147
+ }
148
+ /**
149
+ * Codec negotiation result — either success or actionable diagnostic.
150
+ */
151
+ export type CodecNegotiationResult = {
152
+ ok: true;
153
+ telephonyCodec: AudioCodec;
154
+ voiceProviderCodec: AudioCodec;
155
+ sampleRate: number;
156
+ } | {
157
+ ok: false;
158
+ error: string;
159
+ suggestion: string;
160
+ };
161
+ /**
162
+ * Negotiate audio codec between telephony provider and voice provider.
163
+ *
164
+ * Telephony side (Twilio/Telnyx) sends mulaw 8kHz.
165
+ * Deepgram Voice Agent accepts mulaw 8kHz for input and produces mulaw 8kHz output.
166
+ * If codecs don't match, return actionable diagnostic.
167
+ */
168
+ export declare function negotiateCodec(telephonyCodec: AudioCodec, voiceProviderCodec: AudioCodec, sampleRate: number): CodecNegotiationResult;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.negotiateCodec = negotiateCodec;
4
+ /**
5
+ * Negotiate audio codec between telephony provider and voice provider.
6
+ *
7
+ * Telephony side (Twilio/Telnyx) sends mulaw 8kHz.
8
+ * Deepgram Voice Agent accepts mulaw 8kHz for input and produces mulaw 8kHz output.
9
+ * If codecs don't match, return actionable diagnostic.
10
+ */
11
+ function negotiateCodec(telephonyCodec, voiceProviderCodec, sampleRate) {
12
+ const supportedTelephony = ["mulaw"];
13
+ const supportedVoice = ["mulaw", "pcm16"];
14
+ const supportedRates = [8000];
15
+ if (!supportedTelephony.includes(telephonyCodec)) {
16
+ return {
17
+ ok: false,
18
+ error: `Telephony codec "${telephonyCodec}" is not supported. Supported: ${supportedTelephony.join(", ")}.`,
19
+ suggestion: "Set telephony provider to stream mulaw audio (default for Twilio/Telnyx media streams).",
20
+ };
21
+ }
22
+ if (!supportedVoice.includes(voiceProviderCodec)) {
23
+ return {
24
+ ok: false,
25
+ error: `Voice provider codec "${voiceProviderCodec}" is not supported. Supported: ${supportedVoice.join(", ")}.`,
26
+ suggestion: "Configure voice provider to use mulaw or pcm16 encoding.",
27
+ };
28
+ }
29
+ if (!supportedRates.includes(sampleRate)) {
30
+ return {
31
+ ok: false,
32
+ error: `Sample rate ${sampleRate}Hz is not supported. Supported: ${supportedRates.join(", ")}Hz.`,
33
+ suggestion: "Use 8000Hz sample rate for telephony audio.",
34
+ };
35
+ }
36
+ return {
37
+ ok: true,
38
+ telephonyCodec,
39
+ voiceProviderCodec,
40
+ sampleRate,
41
+ };
42
+ }
@@ -0,0 +1,30 @@
1
+ import { ClawVoiceConfig } from "../config";
2
+ export interface WebhookVerificationResult {
3
+ valid: boolean;
4
+ provider: "telnyx" | "twilio";
5
+ reason?: string;
6
+ }
7
+ /**
8
+ * Verify Telnyx webhook signature using Ed25519 public-key cryptography.
9
+ * Telnyx sends `telnyx-signature-ed25519` and `telnyx-timestamp` headers.
10
+ * The signed payload is `timestamp|payload`.
11
+ * The `secret` parameter is the Ed25519 public key from the Telnyx dashboard.
12
+ */
13
+ export declare function verifyTelnyxSignature(payload: string, signatureHeader: string | undefined, timestampHeader: string | undefined, publicKey: string | undefined): WebhookVerificationResult;
14
+ /**
15
+ * Verify Twilio webhook signature using HMAC-SHA1.
16
+ * Twilio computes HMAC-SHA1 of the request URL + sorted POST params.
17
+ */
18
+ export declare function verifyTwilioSignature(url: string, params: Record<string, string>, signatureHeader: string | undefined, authToken: string | undefined): WebhookVerificationResult;
19
+ /**
20
+ * Get the appropriate verification function for the configured provider.
21
+ */
22
+ export declare function getVerifier(config: ClawVoiceConfig): {
23
+ verify: typeof verifyTelnyxSignature;
24
+ secret: string | undefined;
25
+ token?: undefined;
26
+ } | {
27
+ verify: typeof verifyTwilioSignature;
28
+ token: string | undefined;
29
+ secret?: undefined;
30
+ };