claude-phone-local 2.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 (96) hide show
  1. package/.env.example +108 -0
  2. package/Dockerfile +84 -0
  3. package/LICENSE +22 -0
  4. package/README.md +231 -0
  5. package/claude-api-server/package-lock.json +832 -0
  6. package/claude-api-server/package.json +20 -0
  7. package/claude-api-server/server.js +540 -0
  8. package/claude-api-server/structured.js +208 -0
  9. package/cli/README.md +231 -0
  10. package/cli/bin/check-publish-safety.js +53 -0
  11. package/cli/bin/claude-phone.js +23 -0
  12. package/cli/bin/cli-main.js +284 -0
  13. package/cli/bin/postinstall.js +20 -0
  14. package/cli/lib/commands/api-server.js +77 -0
  15. package/cli/lib/commands/backup.js +69 -0
  16. package/cli/lib/commands/config/path.js +30 -0
  17. package/cli/lib/commands/config/reset.js +73 -0
  18. package/cli/lib/commands/config/show.js +86 -0
  19. package/cli/lib/commands/device/add.js +143 -0
  20. package/cli/lib/commands/device/list.js +55 -0
  21. package/cli/lib/commands/device/remove.js +83 -0
  22. package/cli/lib/commands/doctor.js +422 -0
  23. package/cli/lib/commands/logs.js +186 -0
  24. package/cli/lib/commands/restore.js +172 -0
  25. package/cli/lib/commands/setup.js +1246 -0
  26. package/cli/lib/commands/start.js +346 -0
  27. package/cli/lib/commands/status.js +139 -0
  28. package/cli/lib/commands/stop.js +101 -0
  29. package/cli/lib/commands/uninstall.js +183 -0
  30. package/cli/lib/commands/update.js +205 -0
  31. package/cli/lib/config.js +113 -0
  32. package/cli/lib/docker.js +384 -0
  33. package/cli/lib/mcp-register.js +108 -0
  34. package/cli/lib/network.js +109 -0
  35. package/cli/lib/platform.js +85 -0
  36. package/cli/lib/port-check.js +135 -0
  37. package/cli/lib/prereqs/checks/compose.js +147 -0
  38. package/cli/lib/prereqs/checks/disk.js +89 -0
  39. package/cli/lib/prereqs/checks/docker.js +155 -0
  40. package/cli/lib/prereqs/checks/network.js +78 -0
  41. package/cli/lib/prereqs/checks/node.js +95 -0
  42. package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
  43. package/cli/lib/prereqs/installers/docker.js +229 -0
  44. package/cli/lib/prereqs/installers/node.js +254 -0
  45. package/cli/lib/prereqs/platform.js +175 -0
  46. package/cli/lib/prereqs/utils/execute.js +208 -0
  47. package/cli/lib/prereqs/utils/rollback.js +223 -0
  48. package/cli/lib/prereqs/utils/sudo.js +177 -0
  49. package/cli/lib/prereqs.js +228 -0
  50. package/cli/lib/prerequisites.js +115 -0
  51. package/cli/lib/process-manager.js +176 -0
  52. package/cli/lib/utils.js +78 -0
  53. package/cli/lib/validators.js +219 -0
  54. package/cli/lib/voice-downloader.js +79 -0
  55. package/docker/entrypoint.sh +110 -0
  56. package/docker/supervisord.conf +78 -0
  57. package/docker-compose.yml +40 -0
  58. package/docs/CLAUDE-CODE-SKILL.md +45 -0
  59. package/docs/LANGUAGES.md +113 -0
  60. package/docs/MCP-SERVER.md +111 -0
  61. package/docs/TROUBLESHOOTING.md +288 -0
  62. package/mcp-server/index.js +159 -0
  63. package/mcp-server/package-lock.json +1201 -0
  64. package/mcp-server/package.json +9 -0
  65. package/package.json +73 -0
  66. package/stt-local/Dockerfile +12 -0
  67. package/stt-local/server.py +45 -0
  68. package/tts-local/Dockerfile +9 -0
  69. package/tts-local/server.py +83 -0
  70. package/voice-app/API-QUERY-CONTRACT.md +238 -0
  71. package/voice-app/DEPLOYMENT.md +176 -0
  72. package/voice-app/Dockerfile +20 -0
  73. package/voice-app/README-OUTBOUND.md +316 -0
  74. package/voice-app/config/devices.json.example +18 -0
  75. package/voice-app/index.js +273 -0
  76. package/voice-app/lib/audio-fork.js +465 -0
  77. package/voice-app/lib/claude-bridge.js +113 -0
  78. package/voice-app/lib/connection-retry.js +58 -0
  79. package/voice-app/lib/conversation-loop.js +490 -0
  80. package/voice-app/lib/device-registry.js +171 -0
  81. package/voice-app/lib/http-server.js +242 -0
  82. package/voice-app/lib/logger.js +35 -0
  83. package/voice-app/lib/multi-registrar.js +133 -0
  84. package/voice-app/lib/outbound-handler.js +299 -0
  85. package/voice-app/lib/outbound-routes.js +417 -0
  86. package/voice-app/lib/outbound-session.js +324 -0
  87. package/voice-app/lib/query-routes.js +484 -0
  88. package/voice-app/lib/registrar.js +144 -0
  89. package/voice-app/lib/sip-handler.js +490 -0
  90. package/voice-app/lib/tts-service.js +205 -0
  91. package/voice-app/lib/whisper-client.js +140 -0
  92. package/voice-app/package.json +38 -0
  93. package/voice-app/static/gotit-beep.wav +0 -0
  94. package/voice-app/static/hold-music.wav +0 -0
  95. package/voice-app/static/ready-beep.wav +0 -0
  96. package/voice-app/test/freeswitch-retry.test.js +100 -0
@@ -0,0 +1,465 @@
1
+ const { EventEmitter } = require('node:events');
2
+ const WebSocket = require('ws');
3
+
4
+ function pcmStats(buf, endian = 'LE') {
5
+ const sampleCount = Math.floor(buf.length / 2);
6
+ if (sampleCount <= 0) {
7
+ return { sampleCount: 0, rms: 0, maxAbs: 0, nearZeroRatio: 1 };
8
+ }
9
+
10
+ let sumSquares = 0;
11
+ let maxAbs = 0;
12
+ let nearZero = 0;
13
+ const read = endian === 'BE' ? Buffer.prototype.readInt16BE : Buffer.prototype.readInt16LE;
14
+
15
+ for (let i = 0; i < sampleCount; i++) {
16
+ const sample = read.call(buf, i * 2);
17
+ const abs = Math.abs(sample);
18
+ sumSquares += abs * abs;
19
+ if (abs > maxAbs) maxAbs = abs;
20
+ if (abs < 200) nearZero++;
21
+ }
22
+
23
+ const rms = Math.sqrt(sumSquares / sampleCount);
24
+ return { sampleCount, rms, maxAbs, nearZeroRatio: nearZero / sampleCount };
25
+ }
26
+
27
+ // VAD tuning. Defaults suit a PBX line with a noise floor; override in .env if
28
+ // speech is being missed (lower) or silence is never detected (raise).
29
+ const VAD_RMS_FLOOR = parseInt(process.env.VAD_RMS_FLOOR || '650', 10);
30
+ const VAD_MAX_FLOOR = parseInt(process.env.VAD_MAX_FLOOR || '2200', 10);
31
+ const VAD_NOISE_MULT = parseFloat(process.env.VAD_NOISE_MULT || '2.5');
32
+ const VAD_WINDOW = parseInt(process.env.VAD_WINDOW || '250', 10); // ~5s of frames
33
+ const VAD_FLOOR_PCT = parseFloat(process.env.VAD_FLOOR_PCT || '0.2'); // 20th percentile
34
+
35
+ // Barge-in is deliberately harder to trigger than normal speech detection: it
36
+ // aborts whatever the agent is saying, so a cough, a door, or any echo of our
37
+ // own audio must not fire it. Speech has to be louder than the normal threshold
38
+ // AND sustained, before we cut playback.
39
+ const BARGE_MULT = parseFloat(process.env.BARGE_MULT || '1.6');
40
+ const BARGE_MIN_MS = parseInt(process.env.BARGE_MIN_MS || '320', 10);
41
+
42
+ class AudioForkSession extends EventEmitter {
43
+ constructor({
44
+ ws,
45
+ callUuid,
46
+ sampleRate = 16000,
47
+ endSilenceMs = 1500,
48
+ minSpeechMs = 350,
49
+ maxUtteranceMs = 60000
50
+ }) {
51
+ super();
52
+ this.ws = ws;
53
+ this.callUuid = callUuid;
54
+ this.sampleRate = sampleRate;
55
+
56
+ this.endSilenceMs = endSilenceMs;
57
+ this.minSpeechMs = minSpeechMs;
58
+ this.maxUtteranceMs = maxUtteranceMs;
59
+
60
+ this.captureEnabled = true;
61
+ this._pcmEndian = null;
62
+
63
+ this._preRollChunks = [];
64
+ this._preRollBytes = 0;
65
+ this._preRollMaxBytes = Math.floor((this.sampleRate * 0.2) * 2);
66
+
67
+ this._inSpeech = false;
68
+ this._noiseFloor = 0; // learned from the quiet frames of this call
69
+ this.bargeInEnabled = false;
70
+ this._bargeMs = 0;
71
+ this._rmsHistory = [];
72
+ this._utteranceChunks = [];
73
+ this._utteranceBytes = 0;
74
+ this._speechBytes = 0;
75
+ this._silenceMs = 0;
76
+
77
+ // DEBUG: Track message counts
78
+ this._messageCount = 0;
79
+ this._binaryCount = 0;
80
+ this._lastLogTime = Date.now();
81
+
82
+ ws.on('message', (data) => this._onMessage(data));
83
+ ws.on('close', () => {
84
+ console.log('[AUDIO-DEBUG] WebSocket CLOSED for ' + callUuid + '. Total messages: ' + this._messageCount + ', binary: ' + this._binaryCount);
85
+ this.emit('close');
86
+ });
87
+ ws.on('error', (err) => {
88
+ console.log('[AUDIO-DEBUG] WebSocket ERROR for ' + callUuid + ': ' + err.message);
89
+ this.emit('error', err);
90
+ });
91
+
92
+ console.log('[AUDIO-DEBUG] AudioForkSession created for ' + callUuid);
93
+ }
94
+
95
+ setCaptureEnabled(enabled) {
96
+ const was = this.captureEnabled;
97
+ this.captureEnabled = Boolean(enabled);
98
+ console.log('[AUDIO-DEBUG] setCaptureEnabled: ' + was + ' -> ' + this.captureEnabled + ' for ' + this.callUuid);
99
+ if (!this.captureEnabled) this._resetUtterance();
100
+ }
101
+
102
+ _chunkDurationMs(byteLen) {
103
+ const samples = Math.floor(byteLen / 2);
104
+ return (samples / this.sampleRate) * 1000;
105
+ }
106
+
107
+ _rememberPreRoll(buf) {
108
+ if (buf.length === 0) return;
109
+ this._preRollChunks.push(buf);
110
+ this._preRollBytes += buf.length;
111
+ while (this._preRollBytes > this._preRollMaxBytes && this._preRollChunks.length > 1) {
112
+ const removed = this._preRollChunks.shift();
113
+ this._preRollBytes -= removed.length;
114
+ }
115
+ }
116
+
117
+ _startUtteranceWithPreRoll() {
118
+ this._inSpeech = true;
119
+ this._utteranceChunks = [];
120
+ this._utteranceBytes = 0;
121
+ this._speechBytes = 0;
122
+ this._silenceMs = 0;
123
+
124
+ if (this._preRollChunks.length) {
125
+ for (const chunk of this._preRollChunks) this._appendUtterance(chunk, false);
126
+ this._preRollChunks = [];
127
+ this._preRollBytes = 0;
128
+ }
129
+ console.log('[AUDIO-DEBUG] Started utterance with pre-roll for ' + this.callUuid);
130
+ }
131
+
132
+ _appendUtterance(buf, countsAsSpeech) {
133
+ this._utteranceChunks.push(buf);
134
+ this._utteranceBytes += buf.length;
135
+ if (countsAsSpeech) this._speechBytes += buf.length;
136
+ }
137
+
138
+ _resetUtterance() {
139
+ this._inSpeech = false;
140
+ this._utteranceChunks = [];
141
+ this._utteranceBytes = 0;
142
+ this._speechBytes = 0;
143
+ this._silenceMs = 0;
144
+ }
145
+
146
+ _finalizeUtterance(reason) {
147
+ const durationMs = this._chunkDurationMs(this._utteranceBytes);
148
+ const speechMs = this._chunkDurationMs(this._speechBytes);
149
+ const speechRatio = this._utteranceBytes > 0 ? this._speechBytes / this._utteranceBytes : 0;
150
+
151
+ const audio = Buffer.concat(this._utteranceChunks);
152
+ this._resetUtterance();
153
+
154
+ console.log('[AUDIO-DEBUG] Finalizing utterance: ' + audio.length + ' bytes, ' + Math.round(durationMs) + 'ms duration, ' + Math.round(speechMs) + 'ms speech, ratio=' + speechRatio.toFixed(2) + ', reason=' + reason);
155
+
156
+ // For DTMF-triggered finalization, be more lenient with requirements
157
+ const isDtmfTriggered = reason === 'dtmf_trigger';
158
+ const minSpeechRequired = isDtmfTriggered ? 100 : this.minSpeechMs;
159
+ const minRatioRequired = isDtmfTriggered ? 0.05 : 0.12;
160
+
161
+ if (speechMs < minSpeechRequired || speechRatio < minRatioRequired) {
162
+ console.log('[AUDIO-DEBUG] Utterance REJECTED: speechMs=' + Math.round(speechMs) + ' < ' + minSpeechRequired + ' OR speechRatio=' + speechRatio.toFixed(2) + ' < ' + minRatioRequired);
163
+ return false;
164
+ }
165
+ console.log('[AUDIO-DEBUG] Utterance ACCEPTED, emitting event');
166
+ this.emit('utterance', { callUuid: this.callUuid, audio, durationMs, speechMs, reason });
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * Force immediate finalization of current utterance (e.g., when # is pressed)
172
+ * Returns true if an utterance was finalized, false if there was nothing to finalize
173
+ */
174
+ forceFinalize() {
175
+ console.log('[AUDIO-DEBUG] forceFinalize called for ' + this.callUuid + ', inSpeech=' + this._inSpeech + ', bytes=' + this._utteranceBytes);
176
+
177
+ if (!this._inSpeech || this._utteranceBytes === 0) {
178
+ console.log('[AUDIO-DEBUG] forceFinalize: No speech to finalize');
179
+ return false;
180
+ }
181
+
182
+ return this._finalizeUtterance('dtmf_trigger');
183
+ }
184
+
185
+ _detectEndian(buf) {
186
+ const le = pcmStats(buf, 'LE');
187
+ const be = pcmStats(buf, 'BE');
188
+ const leScore = le.maxAbs + le.rms;
189
+ const beScore = be.maxAbs + be.rms;
190
+ const result = leScore >= beScore ? 'LE' : 'BE';
191
+ console.log('[AUDIO-DEBUG] Detected endian: ' + result + ' (LE score=' + Math.round(leScore) + ', BE score=' + Math.round(beScore) + ')');
192
+ return result;
193
+ }
194
+
195
+ setBargeInEnabled(on) {
196
+ this.bargeInEnabled = !!on;
197
+ this._bargeMs = 0;
198
+ }
199
+
200
+ _isSpeech(buf) {
201
+ if (!this._pcmEndian) this._pcmEndian = this._detectEndian(buf);
202
+ const stats = pcmStats(buf, this._pcmEndian);
203
+
204
+ // Fixed thresholds only work on a line that sends true digital silence
205
+ // (RMS 0) between words. Through a PBX/SBC the gaps carry a noise floor -
206
+ // often RMS 1000-2500 - which sits above a fixed 650 threshold, so every
207
+ // frame reads as speech, the end-of-speech silence never arrives and the
208
+ // utterance is never finalized.
209
+ //
210
+ // So track the floor and require speech to stand out from it.
211
+ // Rolling window of recent frame energies. Taking a low percentile of it
212
+ // finds the quiet moments even when most frames are loud, so the floor is
213
+ // learned no matter how the call starts. An EMA updated only on "silence"
214
+ // cannot bootstrap here: if the floor already exceeds the fixed threshold,
215
+ // every frame classifies as speech and the floor never updates at all.
216
+ this._rmsHistory.push(stats.rms);
217
+ if (this._rmsHistory.length > VAD_WINDOW) this._rmsHistory.shift();
218
+
219
+ let floor = 0;
220
+ if (this._rmsHistory.length >= 10) {
221
+ const sorted = this._rmsHistory.slice().sort((a, b) => a - b);
222
+ floor = sorted[Math.floor(sorted.length * VAD_FLOOR_PCT)];
223
+ }
224
+ this._noiseFloor = floor;
225
+
226
+ const rmsThreshold = Math.max(VAD_RMS_FLOOR, floor * VAD_NOISE_MULT);
227
+ const maxThreshold = Math.max(VAD_MAX_FLOOR, floor * VAD_NOISE_MULT * 3);
228
+
229
+ return stats.maxAbs >= maxThreshold || stats.rms >= rmsThreshold;
230
+ }
231
+
232
+ _onMessage(data) {
233
+ this._messageCount++;
234
+
235
+ if (typeof data === 'string') {
236
+ console.log('[AUDIO-DEBUG] Received STRING message #' + this._messageCount + ': ' + data.substring(0, 200));
237
+ try {
238
+ const meta = JSON.parse(data);
239
+ this.emit('metadata', meta);
240
+ if (meta && meta.sampleRate && Number.isFinite(Number(meta.sampleRate))) {
241
+ this.sampleRate = Number(meta.sampleRate);
242
+ this._preRollMaxBytes = Math.floor((this.sampleRate * 0.2) * 2);
243
+ console.log('[AUDIO-DEBUG] Updated sampleRate to ' + this.sampleRate);
244
+ }
245
+ } catch {
246
+ this.emit('metadata', data);
247
+ }
248
+ return;
249
+ }
250
+
251
+ if (!Buffer.isBuffer(data)) {
252
+ console.log('[AUDIO-DEBUG] Received non-buffer, non-string message type: ' + typeof data);
253
+ return;
254
+ }
255
+
256
+ this._binaryCount++;
257
+
258
+ // Log periodically (every 50 chunks or every 5 seconds)
259
+ const now = Date.now();
260
+ if (this._binaryCount % 50 === 1 || now - this._lastLogTime > 5000) {
261
+ const stats = pcmStats(data, this._pcmEndian || 'LE');
262
+ console.log('[AUDIO-DEBUG] Binary chunk #' + this._binaryCount + ': ' + data.length + ' bytes, RMS=' + Math.round(stats.rms) + ', max=' + stats.maxAbs + ', nearZero=' + (stats.nearZeroRatio*100).toFixed(1) + '%, captureEnabled=' + this.captureEnabled);
263
+ this._lastLogTime = now;
264
+ }
265
+
266
+ // Barge-in runs even while capture is disabled - that is the whole point,
267
+ // the caller is interrupting audio we are currently playing.
268
+ if (this.bargeInEnabled && data.length >= 2) {
269
+ const stats = pcmStats(data, this._pcmEndian || 'LE');
270
+ const loudEnough = stats.rms >= Math.max(VAD_RMS_FLOOR, this._noiseFloor * VAD_NOISE_MULT) * BARGE_MULT;
271
+ this._bargeMs = loudEnough ? this._bargeMs + this._chunkDurationMs(data.length) : 0;
272
+ if (this._bargeMs >= BARGE_MIN_MS) {
273
+ this._bargeMs = 0;
274
+ this.bargeInEnabled = false;
275
+ console.log('[AUDIO-DEBUG] BARGE-IN detected (RMS=' + Math.round(stats.rms) + ') for ' + this.callUuid);
276
+ this.emit('barge-in');
277
+ }
278
+ }
279
+
280
+ if (!this.captureEnabled) {
281
+ return;
282
+ }
283
+
284
+ if (data.length < 2) return;
285
+
286
+ const isSpeech = this._isSpeech(data);
287
+ const chunkMs = this._chunkDurationMs(data.length);
288
+
289
+ // Log speech detection periodically
290
+ if (this._binaryCount % 50 === 1) {
291
+ const stats = pcmStats(data, this._pcmEndian || 'LE');
292
+ console.log('[AUDIO-DEBUG] VAD: isSpeech=' + isSpeech + ', inSpeech=' + this._inSpeech + ', silenceMs=' + Math.round(this._silenceMs) + ', RMS=' + Math.round(stats.rms) + ', max=' + stats.maxAbs + ', floor=' + Math.round(this._noiseFloor || 0));
293
+ }
294
+
295
+ if (!this._inSpeech) {
296
+ this._rememberPreRoll(data);
297
+ if (!isSpeech) return;
298
+ this._startUtteranceWithPreRoll();
299
+ }
300
+
301
+ this._appendUtterance(data, isSpeech);
302
+
303
+ if (isSpeech) this._silenceMs = 0;
304
+ else this._silenceMs += chunkMs;
305
+
306
+ const utteranceMs = this._chunkDurationMs(this._utteranceBytes);
307
+ if (utteranceMs >= this.maxUtteranceMs) return this._finalizeUtterance('max_utterance');
308
+ if (this._silenceMs >= this.endSilenceMs) return this._finalizeUtterance('end_silence');
309
+ }
310
+
311
+ waitForUtterance({ timeoutMs = 30000 } = {}) {
312
+ console.log('[AUDIO-DEBUG] waitForUtterance called, timeoutMs=' + timeoutMs + ', captureEnabled=' + this.captureEnabled);
313
+ return new Promise((resolve, reject) => {
314
+ const onUtterance = (u) => {
315
+ cleanup();
316
+ console.log('[AUDIO-DEBUG] waitForUtterance resolved with ' + u.audio.length + ' bytes');
317
+ resolve(u);
318
+ };
319
+ const onClose = () => {
320
+ cleanup();
321
+ reject(new Error('AudioForkSession closed for call ' + this.callUuid));
322
+ };
323
+ const onError = (err) => {
324
+ cleanup();
325
+ reject(err);
326
+ };
327
+
328
+ const timer = setTimeout(() => {
329
+ cleanup();
330
+ console.log('[AUDIO-DEBUG] waitForUtterance TIMEOUT after ' + timeoutMs + 'ms. Binary chunks received: ' + this._binaryCount);
331
+ reject(new Error('Timed out waiting for utterance (' + timeoutMs + 'ms) for call ' + this.callUuid));
332
+ }, timeoutMs);
333
+
334
+ const cleanup = () => {
335
+ clearTimeout(timer);
336
+ this.off('utterance', onUtterance);
337
+ this.off('close', onClose);
338
+ this.off('error', onError);
339
+ };
340
+
341
+ this.on('utterance', onUtterance);
342
+ this.on('close', onClose);
343
+ this.on('error', onError);
344
+ });
345
+ }
346
+ }
347
+
348
+ class AudioForkServer extends EventEmitter {
349
+ constructor({ port = 3001, host = '0.0.0.0' } = {}) {
350
+ super();
351
+ this.port = port;
352
+ this.host = host;
353
+ this.wss = null;
354
+ this._pending = [];
355
+ this._sessions = new Map();
356
+ }
357
+
358
+ start() {
359
+ if (this.wss) return;
360
+ this.wss = new WebSocket.Server({ port: this.port, host: this.host });
361
+
362
+ this.wss.on('connection', (ws, req) => {
363
+ const url = (req && req.url) || '/';
364
+ console.log('[AUDIO-DEBUG] WebSocket connection received, URL: ' + url);
365
+
366
+ const path = url.split('?')[0] || '/';
367
+ const candidate = decodeURIComponent(path).replace(/^\/+/, '').trim();
368
+
369
+ const callUuidFromUrl = candidate.length ? candidate.split('/')[0] : '';
370
+ const callUuid = callUuidFromUrl || null;
371
+
372
+ console.log('[AUDIO-DEBUG] Extracted callUuid from URL: ' + callUuid);
373
+
374
+ if (callUuid) {
375
+ const idx = this._pending.findIndex((p) => p.callUuid === callUuid);
376
+ const pending = idx >= 0 ? this._pending.splice(idx, 1)[0] : null;
377
+ if (pending) {
378
+ clearTimeout(pending.timeout);
379
+ console.log('[AUDIO-DEBUG] Found pending expectation for ' + callUuid);
380
+ } else {
381
+ console.log('[AUDIO-DEBUG] No pending expectation for ' + callUuid + ', creating session anyway');
382
+ }
383
+
384
+ const session = new AudioForkSession({ ws, callUuid });
385
+ this._sessions.set(callUuid, session);
386
+ session.on('close', () => this._sessions.delete(callUuid));
387
+ session.on('error', () => this._sessions.delete(callUuid));
388
+
389
+ this.emit('session', session);
390
+ if (pending) pending.resolve(session);
391
+ return;
392
+ }
393
+
394
+ const pending = this._pending.shift();
395
+ if (!pending) {
396
+ console.log('[AUDIO-DEBUG] No pending session and no callUuid in URL, closing connection');
397
+ ws.close(1011, 'No pending audio session');
398
+ return;
399
+ }
400
+
401
+ clearTimeout(pending.timeout);
402
+ const session = new AudioForkSession({ ws, callUuid: pending.callUuid });
403
+ this._sessions.set(pending.callUuid, session);
404
+ session.on('close', () => this._sessions.delete(pending.callUuid));
405
+ session.on('error', () => this._sessions.delete(pending.callUuid));
406
+
407
+ this.emit('session', session);
408
+ pending.resolve(session);
409
+ });
410
+
411
+ this.wss.on('listening', () => {
412
+ console.log('[AUDIO-DEBUG] WebSocket server listening on ' + this.host + ':' + this.port);
413
+ this.emit('listening', { host: this.host, port: this.port });
414
+ });
415
+ this.wss.on('error', (err) => this.emit('error', err));
416
+ }
417
+
418
+ stop() {
419
+ if (!this.wss) return;
420
+ this.wss.close();
421
+ this.wss = null;
422
+ this._pending = [];
423
+ this._sessions.clear();
424
+ }
425
+
426
+ /**
427
+ * Cancel a pending session expectation (call this when a call ends before session connects)
428
+ */
429
+ cancelExpectation(callUuid) {
430
+ const idx = this._pending.findIndex((p) => p.callUuid === callUuid);
431
+ if (idx >= 0) {
432
+ const pending = this._pending.splice(idx, 1)[0];
433
+ clearTimeout(pending.timeout);
434
+ console.log('[AUDIO-DEBUG] Cancelled pending expectation for ' + callUuid);
435
+ return true;
436
+ }
437
+ return false;
438
+ }
439
+
440
+ expectSession(callUuid, { timeoutMs = 5000 } = {}) {
441
+ console.log('[AUDIO-DEBUG] expectSession called for ' + callUuid + ', timeoutMs=' + timeoutMs);
442
+ return new Promise((resolve, reject) => {
443
+ const timeout = setTimeout(() => {
444
+ const idx = this._pending.findIndex((p) => p.callUuid === callUuid);
445
+ if (idx >= 0) this._pending.splice(idx, 1);
446
+ console.log('[AUDIO-DEBUG] expectSession TIMEOUT for ' + callUuid + ' (this is handled, not a crash)');
447
+ reject(new Error('Timed out waiting for WebSocket audio session (' + timeoutMs + 'ms) for call ' + callUuid));
448
+ }, timeoutMs);
449
+
450
+ this._pending.push({ callUuid, resolve, reject, timeout });
451
+ });
452
+ }
453
+
454
+ getSession(callUuid) {
455
+ return this._sessions.get(callUuid);
456
+ }
457
+ }
458
+
459
+ // Add global unhandled rejection handler to prevent crashes
460
+ // This is a safety net - the actual fix is proper cleanup in conversation-loop.js
461
+ process.on('unhandledRejection', (reason, promise) => {
462
+ console.log('[AUDIO-DEBUG] Unhandled Rejection (caught, not crashing):', reason);
463
+ });
464
+
465
+ module.exports = { AudioForkServer, AudioForkSession };
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Claude HTTP API Bridge
3
+ * HTTP client for Claude API server with session management
4
+ */
5
+
6
+ const axios = require('axios');
7
+
8
+ const CLAUDE_API_URL = process.env.CLAUDE_API_URL || 'http://localhost:3333';
9
+
10
+ /**
11
+ * Query Claude via HTTP API with session support
12
+ * @param {string} prompt - The prompt/question to send to Claude
13
+ * @param {Object} options - Options including callId for session management
14
+ * @param {string} options.callId - Call UUID for maintaining conversation context
15
+ * @param {string} options.devicePrompt - Device-specific personality prompt
16
+ * @param {number} options.timeout - Timeout in seconds (default: CLAUDE_TIMEOUT env or 180)
17
+ * @returns {Promise<string>} Claude's response
18
+ */
19
+ async function query(prompt, options = {}) {
20
+ const DEFAULT_TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || '180', 10);
21
+ const { callId, devicePrompt, timeout = DEFAULT_TIMEOUT } = options;
22
+ const timestamp = new Date().toISOString();
23
+
24
+ try {
25
+ console.log(`[${timestamp}] CLAUDE Sending query to ${CLAUDE_API_URL}...`);
26
+ if (callId) {
27
+ console.log(`[${timestamp}] CLAUDE Session: ${callId}`);
28
+ }
29
+ if (devicePrompt) {
30
+ console.log(`[${timestamp}] CLAUDE Device prompt: ${devicePrompt.substring(0, 50)}...`);
31
+ }
32
+
33
+ const response = await axios.post(
34
+ `${CLAUDE_API_URL}/ask`,
35
+ { prompt, callId, devicePrompt },
36
+ {
37
+ timeout: timeout * 1000,
38
+ headers: { 'Content-Type': 'application/json' }
39
+ }
40
+ );
41
+
42
+ if (!response.data.success) {
43
+ throw new Error(response.data.error || 'Claude API returned failure');
44
+ }
45
+
46
+ console.log(`[${timestamp}] CLAUDE Response received (${response.data.duration_ms}ms)`);
47
+ if (response.data.sessionId) {
48
+ console.log(`[${timestamp}] CLAUDE Session ID: ${response.data.sessionId}`);
49
+ }
50
+ return response.data.response;
51
+
52
+ } catch (error) {
53
+ // AC26: API server unreachable during call - don't crash, return helpful message
54
+ if (error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH' || error.code === 'ENETUNREACH') {
55
+ console.warn(`[${timestamp}] CLAUDE API server unreachable (${error.code})`);
56
+ return "I'm having trouble connecting to my brain right now. The API server may be offline or unreachable. Please try again later.";
57
+ }
58
+
59
+ // AC27: Timeout with helpful error message
60
+ if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
61
+ console.error(`[${timestamp}] CLAUDE Timeout after ${timeout} seconds`);
62
+ return "I'm sorry, that request took too long. This might mean the API server is slow or there's a network issue. Try asking something simpler, or check that claude-phone api-server is running.";
63
+ }
64
+
65
+ console.error(`[${timestamp}] CLAUDE Error:`, error.message);
66
+ // AC26: Don't crash on unknown errors, return friendly message
67
+ return "I encountered an unexpected error. Please check that the API server is running claude-phone api-server and is on the same network.";
68
+ }
69
+ }
70
+
71
+ /**
72
+ * End a Claude session when a call ends
73
+ * @param {string} callId - The call UUID to end the session for
74
+ */
75
+ async function endSession(callId) {
76
+ if (!callId) return;
77
+
78
+ const timestamp = new Date().toISOString();
79
+
80
+ try {
81
+ await axios.post(
82
+ `${CLAUDE_API_URL}/end-session`,
83
+ { callId },
84
+ {
85
+ timeout: 5000,
86
+ headers: { 'Content-Type': 'application/json' }
87
+ }
88
+ );
89
+ console.log(`[${timestamp}] CLAUDE Session ended: ${callId}`);
90
+ } catch (error) {
91
+ // Non-critical, just log
92
+ console.warn(`[${timestamp}] CLAUDE Failed to end session: ${error.message}`);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check if Claude API is available
98
+ * @returns {Promise<boolean>} True if API is reachable
99
+ */
100
+ async function isAvailable() {
101
+ try {
102
+ await axios.get(`${CLAUDE_API_URL}/health`, { timeout: 5000 });
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ module.exports = {
110
+ query,
111
+ endSession,
112
+ isAvailable
113
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Connection Retry Utility
3
+ * Implements exponential backoff for FreeSWITCH connections
4
+ */
5
+
6
+ /**
7
+ * Connect with retry logic and exponential backoff
8
+ * @param {Function} connectFn - Function that returns a Promise for the connection
9
+ * @param {Object} options - Retry options
10
+ * @param {number} options.maxRetries - Maximum number of retry attempts (default: 10)
11
+ * @param {Array<number>} options.retryDelays - Array of delay times in ms for each retry
12
+ * @param {string} options.name - Name of the connection (for logging)
13
+ * @returns {Promise} - Resolves with connection object or rejects after max retries
14
+ */
15
+ function connectWithRetry(connectFn, options) {
16
+ options = options || {};
17
+ var maxRetries = options.maxRetries || 10;
18
+ var retryDelays = options.retryDelays || [1000, 2000, 3000, 5000, 5000, 5000, 10000, 10000, 10000, 10000];
19
+ var name = options.name || 'Connection';
20
+ var attempt = 0;
21
+
22
+ function tryConnect() {
23
+ attempt++;
24
+ var timestamp = new Date().toISOString();
25
+ console.log('[' + timestamp + '] ' + name + ' attempt ' + attempt + '/' + maxRetries);
26
+
27
+ return connectFn()
28
+ .then(function(result) {
29
+ var successTimestamp = new Date().toISOString();
30
+ console.log('[' + successTimestamp + '] ' + name + ' established successfully on attempt ' + attempt);
31
+ return result;
32
+ })
33
+ .catch(function(err) {
34
+ if (attempt >= maxRetries) {
35
+ var errorTimestamp = new Date().toISOString();
36
+ console.error('[' + errorTimestamp + '] ' + name + ' failed after ' + maxRetries + ' attempts');
37
+ throw new Error(name + ' failed after ' + maxRetries + ' attempts: ' + err.message);
38
+ }
39
+
40
+ var delay = retryDelays[attempt - 1] || retryDelays[retryDelays.length - 1];
41
+ var retryTimestamp = new Date().toISOString();
42
+ console.log('[' + retryTimestamp + '] ' + name + ' failed: ' + err.message);
43
+ console.log('[' + retryTimestamp + '] Retrying in ' + (delay / 1000) + 's... (attempt ' + (attempt + 1) + '/' + maxRetries + ')');
44
+
45
+ return new Promise(function(resolve) {
46
+ setTimeout(function() {
47
+ resolve(tryConnect());
48
+ }, delay);
49
+ });
50
+ });
51
+ }
52
+
53
+ return tryConnect();
54
+ }
55
+
56
+ module.exports = {
57
+ connectWithRetry: connectWithRetry
58
+ };