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 +1 -1
- package/voice-app/lib/sip-handler.js +65 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-phone-local",
|
|
3
|
-
"version": "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
|
|
300
|
-
//
|
|
301
|
-
//
|
|
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
|
|
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
|
|
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
|
|
445
|
-
//
|
|
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
|
-
|
|
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');
|