claude-phone-local 2.3.0 → 2.3.2

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.
package/README.md CHANGED
@@ -42,6 +42,26 @@ generated automatically on first run.
42
42
 
43
43
  ## Quick start
44
44
 
45
+ The npm CLI is the recommended path — it installs, prompts you through 3CX/
46
+ device config, and starts everything (including the host-side Claude wrapper)
47
+ for you:
48
+
49
+ ```bash
50
+ npm install -g claude-phone-local
51
+ claude-phone setup # interactive: 3CX domain, extension, speech mode, etc.
52
+ claude-phone start # builds + launches everything, including claude-api-server
53
+ ```
54
+
55
+ When you see `✓ All services running!` with your extension listed, call it.
56
+ Full prompt-by-prompt walkthrough (3CX extension → SBC → Docker → npm install
57
+ → setup → start): **[docs/SETUP.md](docs/SETUP.md)**.
58
+
59
+ <details>
60
+ <summary>Manual / dev setup (git clone + docker compose)</summary>
61
+
62
+ For local development on this repo, or if you'd rather manage `.env` and
63
+ `docker compose` by hand instead of the CLI:
64
+
45
65
  ```bash
46
66
  git clone <your-fork> claude-phone-local
47
67
  cd claude-phone-local
@@ -76,6 +96,8 @@ When you see this, call your extension:
76
96
  [MULTI-REGISTRAR] Maya SUCCESS - Registered as ext 17512
77
97
  ```
78
98
 
99
+ </details>
100
+
79
101
  ## Architecture
80
102
 
81
103
  One container, one host process:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-phone-local",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Local/offline fork of NetworkChuck's claude-phone: talk to Claude Code over 3CX/SIP with faster-whisper STT + Piper TTS in one Docker container.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -263,33 +263,6 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
263
263
  ? "Hello! I'm " + deviceConfig.name + ". How can I help you today?"
264
264
  : "Hello! I'm your server. How can I help you today?";
265
265
 
266
- // Play a clip, then go straight into listening - no beep, no separate
267
- // "ready" phase. The caller can start talking the instant she stops (or
268
- // interrupt her mid-clip; playInterruptible already supports that and
269
- // capture only turns on inside audio-fork the moment barge-in actually
270
- // fires, so her own voice mixed into the mono fork is never mistaken for
271
- // speech). This replaces the old push-to-talk-after-tone flow, where
272
- // capture was only ever enabled during one narrow beep-gated window and
273
- // every other second of the call - her talking, hold music, the dead gap
274
- // right after she finished - was silence the caller could not be heard in.
275
- async function speakThenListen(url, { timeoutMs = 30000 } = {}) {
276
- const barged = await playInterruptible(endpoint, session, url);
277
- if (barged) return barged;
278
-
279
- session.setCaptureEnabled(true);
280
- console.log('[' + new Date().toISOString() + '] LISTEN Waiting for speech...');
281
- try {
282
- const utterance = await session.waitForUtterance({ timeoutMs });
283
- console.log('[' + new Date().toISOString() + '] LISTEN Got: ' + utterance.audio.length + ' bytes');
284
- return utterance;
285
- } catch (err) {
286
- console.log('[' + new Date().toISOString() + '] LISTEN Timeout: ' + err.message);
287
- return null;
288
- } finally {
289
- session.setCaptureEnabled(false);
290
- }
291
- }
292
-
293
266
  try {
294
267
  console.log('[' + new Date().toISOString() + '] CONVERSATION Starting (session: ' + callUuid + ', device: ' + deviceName + ', voice: ' + voiceId + ')...');
295
268
 
@@ -323,40 +296,55 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
323
296
  let turnCount = 0;
324
297
  const MAX_TURNS = 20;
325
298
  // Speech captured by a barge-in during the previous turn's playback -
326
- // consumed as this turn's input directly instead of another listen
327
- // window, which would otherwise make the caller repeat themselves after
328
- // every interruption.
299
+ // consumed as this turn's input directly instead of a fresh ready-beep
300
+ // and listen window, which would otherwise make the caller repeat
301
+ // themselves after every interruption.
329
302
  let pendingUtterance = null;
330
303
 
331
- // First listen of the call - straight after the greeting, no beep.
332
- session.setCaptureEnabled(true);
333
- console.log('[' + new Date().toISOString() + '] LISTEN Waiting for speech...');
334
- let utterance = null;
335
- try {
336
- utterance = await session.waitForUtterance({ timeoutMs: 30000 });
337
- console.log('[' + new Date().toISOString() + '] LISTEN Got: ' + utterance.audio.length + ' bytes');
338
- } catch (err) {
339
- console.log('[' + new Date().toISOString() + '] LISTEN Timeout: ' + err.message);
340
- } finally {
341
- session.setCaptureEnabled(false);
342
- }
343
-
344
304
  while (turnCount < MAX_TURNS) {
345
305
  turnCount++;
346
306
  console.log('[' + new Date().toISOString() + '] CONVERSATION Turn ' + turnCount + '/' + MAX_TURNS);
347
307
 
308
+ let utterance = null;
309
+
348
310
  if (pendingUtterance) {
349
311
  console.log('[' + new Date().toISOString() + '] LISTEN Using speech captured during barge-in: ' + pendingUtterance.audio.length + ' bytes');
350
312
  utterance = pendingUtterance;
351
313
  pendingUtterance = null;
314
+ } else {
315
+ // READY BEEP
316
+ try {
317
+ await endpoint.play(READY_BEEP_URL);
318
+ } catch (e) {
319
+ console.log('[' + new Date().toISOString() + '] BEEP: Ready beep failed, continuing');
320
+ }
321
+
322
+ session.setCaptureEnabled(true);
323
+ console.log('[' + new Date().toISOString() + '] LISTEN Waiting for speech...');
324
+
325
+ try {
326
+ utterance = await session.waitForUtterance({ timeoutMs: 30000 });
327
+ console.log('[' + new Date().toISOString() + '] LISTEN Got: ' + utterance.audio.length + ' bytes');
328
+ } catch (err) {
329
+ console.log('[' + new Date().toISOString() + '] LISTEN Timeout: ' + err.message);
330
+ }
331
+
332
+ session.setCaptureEnabled(false);
352
333
  }
353
334
 
354
335
  if (!utterance) {
355
336
  const promptUrl = await ttsService.generateSpeech("I didn't hear anything. Are you still there?", turnVoice);
356
- utterance = await speakThenListen(promptUrl);
337
+ await endpoint.play(promptUrl);
357
338
  continue;
358
339
  }
359
340
 
341
+ // GOT-IT BEEP
342
+ try {
343
+ await endpoint.play(GOTIT_BEEP_URL);
344
+ } catch (e) {
345
+ console.log('[' + new Date().toISOString() + '] BEEP: Got-it beep failed, continuing');
346
+ }
347
+
360
348
  // Transcribe (language auto-detected unless STT_LANGUAGE pins one)
361
349
  const sttResult = await whisperClient.transcribeDetailed(utterance.audio, {
362
350
  format: 'pcm',
@@ -379,7 +367,7 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
379
367
 
380
368
  if (!transcript || transcript.trim().length < 2) {
381
369
  const clarifyUrl = await ttsService.generateSpeech("Sorry, I didn't catch that. Could you repeat?", turnVoice);
382
- utterance = await speakThenListen(clarifyUrl);
370
+ await endpoint.play(clarifyUrl);
383
371
  continue;
384
372
  }
385
373
 
@@ -450,35 +438,30 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
450
438
 
451
439
  console.log('[' + new Date().toISOString() + '] CLAUDE Response received');
452
440
 
453
- utterance = null;
454
-
455
441
  if (pendingUtterance) {
456
442
  // Caller already interrupted during the wait and started saying
457
443
  // something new - the answer to their original question is now
458
- // moot. Skip playing it and pick this utterance straight up as the
459
- // next turn's input, instead of talking over/past it.
444
+ // moot. Skip playing it and let the next loop iteration process
445
+ // what they're actually saying now, instead of talking over/past it.
460
446
  console.log('[' + new Date().toISOString() + '] VOICE: skipped (caller already interrupted with a new utterance)');
461
- utterance = pendingUtterance;
462
- pendingUtterance = null;
463
- } else if (hasEndCallMarker(claudeResponse)) {
464
- // Claude judged this a real sign-off from the caller's own words
465
- // (not just isGoodbye()'s fixed phrase match) - play her answer
466
- // (which already includes the goodbye) without listening again, then
467
- // end the call.
468
- const voiceLine = extractVoiceLine(claudeResponse);
469
- console.log('[' + new Date().toISOString() + '] VOICE: "' + voiceLine + '"');
470
- const responseUrl = await ttsService.generateSpeech(voiceLine, turnVoice);
471
- await endpoint.play(responseUrl);
472
- console.log('[' + new Date().toISOString() + '] CONVERSATION Claude signaled end of call');
473
- break;
474
447
  } else {
475
- // Extract and play voice line with device voice, then go straight
476
- // into listening for the reply - no beep, no gap.
448
+ // Extract and play voice line with device voice
477
449
  const voiceLine = extractVoiceLine(claudeResponse);
478
450
  console.log('[' + new Date().toISOString() + '] VOICE: "' + voiceLine + '"');
479
451
 
480
452
  const responseUrl = await ttsService.generateSpeech(voiceLine, turnVoice);
481
- utterance = await speakThenListen(responseUrl);
453
+ // Long answers are the usual thing people want to interrupt.
454
+ const responseBarge = await playInterruptible(endpoint, session, responseUrl);
455
+ if (responseBarge) {
456
+ pendingUtterance = responseBarge;
457
+ } else if (hasEndCallMarker(claudeResponse)) {
458
+ // Claude judged this a real sign-off from the caller's own words
459
+ // (not just isGoodbye()'s fixed phrase match) - she already said
460
+ // her goodbye as part of the normal response above, so just end
461
+ // the call rather than waiting for another turn.
462
+ console.log('[' + new Date().toISOString() + '] CONVERSATION Claude signaled end of call');
463
+ break;
464
+ }
482
465
  }
483
466
 
484
467
  console.log('[' + new Date().toISOString() + '] CONVERSATION Turn ' + turnCount + ' complete');