claude-phone-local 2.2.2 → 2.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-phone-local",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
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,6 +263,33 @@ 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
+
266
293
  try {
267
294
  console.log('[' + new Date().toISOString() + '] CONVERSATION Starting (session: ' + callUuid + ', device: ' + deviceName + ', voice: ' + voiceId + ')...');
268
295
 
@@ -296,55 +323,40 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
296
323
  let turnCount = 0;
297
324
  const MAX_TURNS = 20;
298
325
  // Speech captured by a barge-in during the previous turn's playback -
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.
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.
302
329
  let pendingUtterance = null;
303
330
 
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
+
304
344
  while (turnCount < MAX_TURNS) {
305
345
  turnCount++;
306
346
  console.log('[' + new Date().toISOString() + '] CONVERSATION Turn ' + turnCount + '/' + MAX_TURNS);
307
347
 
308
- let utterance = null;
309
-
310
348
  if (pendingUtterance) {
311
349
  console.log('[' + new Date().toISOString() + '] LISTEN Using speech captured during barge-in: ' + pendingUtterance.audio.length + ' bytes');
312
350
  utterance = pendingUtterance;
313
351
  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);
333
352
  }
334
353
 
335
354
  if (!utterance) {
336
355
  const promptUrl = await ttsService.generateSpeech("I didn't hear anything. Are you still there?", turnVoice);
337
- await endpoint.play(promptUrl);
356
+ utterance = await speakThenListen(promptUrl);
338
357
  continue;
339
358
  }
340
359
 
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
-
348
360
  // Transcribe (language auto-detected unless STT_LANGUAGE pins one)
349
361
  const sttResult = await whisperClient.transcribeDetailed(utterance.audio, {
350
362
  format: 'pcm',
@@ -367,7 +379,7 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
367
379
 
368
380
  if (!transcript || transcript.trim().length < 2) {
369
381
  const clarifyUrl = await ttsService.generateSpeech("Sorry, I didn't catch that. Could you repeat?", turnVoice);
370
- await endpoint.play(clarifyUrl);
382
+ utterance = await speakThenListen(clarifyUrl);
371
383
  continue;
372
384
  }
373
385
 
@@ -438,30 +450,35 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
438
450
 
439
451
  console.log('[' + new Date().toISOString() + '] CLAUDE Response received');
440
452
 
453
+ utterance = null;
454
+
441
455
  if (pendingUtterance) {
442
456
  // Caller already interrupted during the wait and started saying
443
457
  // something new - the answer to their original question is now
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.
458
+ // moot. Skip playing it and pick this utterance straight up as the
459
+ // next turn's input, instead of talking over/past it.
446
460
  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;
447
474
  } else {
448
- // Extract and play voice line with device voice
475
+ // Extract and play voice line with device voice, then go straight
476
+ // into listening for the reply - no beep, no gap.
449
477
  const voiceLine = extractVoiceLine(claudeResponse);
450
478
  console.log('[' + new Date().toISOString() + '] VOICE: "' + voiceLine + '"');
451
479
 
452
480
  const responseUrl = await ttsService.generateSpeech(voiceLine, turnVoice);
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
- }
481
+ utterance = await speakThenListen(responseUrl);
465
482
  }
466
483
 
467
484
  console.log('[' + new Date().toISOString() + '] CONVERSATION Turn ' + turnCount + ' complete');