claude-phone-local 2.0.12 → 2.0.14
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.0.
|
|
3
|
+
"version": "2.0.14",
|
|
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": {
|
|
@@ -278,6 +278,14 @@ class AudioForkSession extends EventEmitter {
|
|
|
278
278
|
this.bargeInEnabled = false;
|
|
279
279
|
console.log('[AUDIO-DEBUG] BARGE-IN detected (RMS=' + Math.round(stats.rms) + ') for ' + this.callUuid);
|
|
280
280
|
this.emit('barge-in');
|
|
281
|
+
// Barge-in used to only stop playback - the words that triggered it,
|
|
282
|
+
// and everything the caller kept saying until the turn loop's next
|
|
283
|
+
// setCaptureEnabled(true), were silently discarded because capture
|
|
284
|
+
// stayed off the whole time. Turn capture on immediately so this
|
|
285
|
+
// chunk (already loud speech, just confirmed above) and what follows
|
|
286
|
+
// fall through into real utterance capture below instead of being
|
|
287
|
+
// dropped and forcing the caller to repeat themselves.
|
|
288
|
+
this.captureEnabled = true;
|
|
281
289
|
}
|
|
282
290
|
}
|
|
283
291
|
|
|
@@ -77,6 +77,10 @@ function getRandomThinkingPhrase() {
|
|
|
77
77
|
return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)];
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function getRandomWaitingPhrase() {
|
|
81
|
+
return WAITING_PHRASES[Math.floor(Math.random() * WAITING_PHRASES.length)];
|
|
82
|
+
}
|
|
83
|
+
|
|
80
84
|
function extractCallerId(req) {
|
|
81
85
|
var from = req.get("From") || "";
|
|
82
86
|
var match = from.match(/sip:([+\d]+)@/);
|
|
@@ -163,13 +167,20 @@ function extractVoiceLine(response) {
|
|
|
163
167
|
*
|
|
164
168
|
* FreeSWITCH plays to completion unless told otherwise, so to support barge-in
|
|
165
169
|
* we arm the detector, then issue uuid_break the moment the caller starts
|
|
166
|
-
* talking.
|
|
167
|
-
*
|
|
170
|
+
* talking. AudioForkSession turns capture on itself right when barge-in
|
|
171
|
+
* fires (see audio-fork.js), so the words that triggered the interruption are
|
|
172
|
+
* already becoming a real utterance - previously they were discarded (capture
|
|
173
|
+
* stayed off during playback) and the caller had to repeat themselves after a
|
|
174
|
+
* fresh ready-beep on the next turn.
|
|
175
|
+
*
|
|
176
|
+
* Returns the captured utterance if the caller interrupted (so the turn loop
|
|
177
|
+
* can use it as their next input directly, no beep/re-prompt needed), or
|
|
178
|
+
* `null` if playback completed without interruption.
|
|
168
179
|
*/
|
|
169
180
|
async function playInterruptible(endpoint, session, url) {
|
|
170
181
|
if (!session) {
|
|
171
182
|
await endpoint.play(url);
|
|
172
|
-
return
|
|
183
|
+
return null;
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
let barged = false;
|
|
@@ -187,10 +198,25 @@ async function playInterruptible(endpoint, session, url) {
|
|
|
187
198
|
session.setBargeInEnabled(false);
|
|
188
199
|
session.removeListener('barge-in', onBarge);
|
|
189
200
|
}
|
|
190
|
-
|
|
191
|
-
|
|
201
|
+
|
|
202
|
+
if (!barged) return null;
|
|
203
|
+
|
|
204
|
+
console.log('[' + new Date().toISOString() + '] BARGE-IN: caller interrupted, capturing what they said');
|
|
205
|
+
try {
|
|
206
|
+
// Capture already started the moment barge-in fired; this just waits for
|
|
207
|
+
// the utterance to finish (end-of-speech silence) rather than starting a
|
|
208
|
+
// fresh listen window that would miss the words already spoken.
|
|
209
|
+
const utterance = await session.waitForUtterance({ timeoutMs: 15000 });
|
|
210
|
+
// finalizeUtterance() resets utterance state but not captureEnabled -
|
|
211
|
+
// turn it off now, otherwise every chunk while we transcribe/query
|
|
212
|
+
// Claude keeps accumulating into a new stray utterance.
|
|
213
|
+
session.setCaptureEnabled(false);
|
|
214
|
+
return utterance;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.log('[' + new Date().toISOString() + '] BARGE-IN: capture failed (' + err.message + '), falling back to a fresh listen');
|
|
217
|
+
session.setCaptureEnabled(false);
|
|
218
|
+
return null;
|
|
192
219
|
}
|
|
193
|
-
return barged;
|
|
194
220
|
}
|
|
195
221
|
|
|
196
222
|
/**
|
|
@@ -253,30 +279,42 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
|
|
|
253
279
|
// Main conversation loop
|
|
254
280
|
let turnCount = 0;
|
|
255
281
|
const MAX_TURNS = 20;
|
|
282
|
+
// Speech captured by a barge-in during the previous turn's playback -
|
|
283
|
+
// consumed as this turn's input directly instead of a fresh ready-beep
|
|
284
|
+
// and listen window, which would otherwise make the caller repeat
|
|
285
|
+
// themselves after every interruption.
|
|
286
|
+
let pendingUtterance = null;
|
|
256
287
|
|
|
257
288
|
while (turnCount < MAX_TURNS) {
|
|
258
289
|
turnCount++;
|
|
259
290
|
console.log('[' + new Date().toISOString() + '] CONVERSATION Turn ' + turnCount + '/' + MAX_TURNS);
|
|
260
291
|
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
await endpoint.play(READY_BEEP_URL);
|
|
264
|
-
} catch (e) {
|
|
265
|
-
console.log('[' + new Date().toISOString() + '] BEEP: Ready beep failed, continuing');
|
|
266
|
-
}
|
|
292
|
+
let utterance = null;
|
|
267
293
|
|
|
268
|
-
|
|
269
|
-
|
|
294
|
+
if (pendingUtterance) {
|
|
295
|
+
console.log('[' + new Date().toISOString() + '] LISTEN Using speech captured during barge-in: ' + pendingUtterance.audio.length + ' bytes');
|
|
296
|
+
utterance = pendingUtterance;
|
|
297
|
+
pendingUtterance = null;
|
|
298
|
+
} else {
|
|
299
|
+
// READY BEEP
|
|
300
|
+
try {
|
|
301
|
+
await endpoint.play(READY_BEEP_URL);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
console.log('[' + new Date().toISOString() + '] BEEP: Ready beep failed, continuing');
|
|
304
|
+
}
|
|
270
305
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
306
|
+
session.setCaptureEnabled(true);
|
|
307
|
+
console.log('[' + new Date().toISOString() + '] LISTEN Waiting for speech...');
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
utterance = await session.waitForUtterance({ timeoutMs: 30000 });
|
|
311
|
+
console.log('[' + new Date().toISOString() + '] LISTEN Got: ' + utterance.audio.length + ' bytes');
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.log('[' + new Date().toISOString() + '] LISTEN Timeout: ' + err.message);
|
|
314
|
+
}
|
|
278
315
|
|
|
279
|
-
|
|
316
|
+
session.setCaptureEnabled(false);
|
|
317
|
+
}
|
|
280
318
|
|
|
281
319
|
if (!utterance) {
|
|
282
320
|
const promptUrl = await ttsService.generateSpeech("I didn't hear anything. Are you still there?", turnVoice);
|
|
@@ -355,8 +393,13 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
|
|
|
355
393
|
? await ttsService.generateSpeech(getRandomWaitingPhrase(), turnVoice)
|
|
356
394
|
: HOLD_MUSIC_URL;
|
|
357
395
|
if (!waiting) break;
|
|
358
|
-
// Caller can cut through the hold music / filler to add
|
|
359
|
-
|
|
396
|
+
// Caller can cut through the hold music / filler to add
|
|
397
|
+
// something - captured and queued as the next turn's input once
|
|
398
|
+
// the in-flight Claude query (already running, can't be
|
|
399
|
+
// cancelled mid-flight) finishes.
|
|
400
|
+
const barged = await playInterruptible(endpoint, session, clipUrl);
|
|
401
|
+
if (barged) {
|
|
402
|
+
pendingUtterance = barged;
|
|
360
403
|
waiting = false;
|
|
361
404
|
break;
|
|
362
405
|
}
|
|
@@ -377,13 +420,24 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
|
|
|
377
420
|
|
|
378
421
|
console.log('[' + new Date().toISOString() + '] CLAUDE Response received');
|
|
379
422
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
423
|
+
if (pendingUtterance) {
|
|
424
|
+
// Caller already interrupted during the wait and started saying
|
|
425
|
+
// something new - the answer to their original question is now
|
|
426
|
+
// moot. Skip playing it and let the next loop iteration process
|
|
427
|
+
// what they're actually saying now, instead of talking over/past it.
|
|
428
|
+
console.log('[' + new Date().toISOString() + '] VOICE: skipped (caller already interrupted with a new utterance)');
|
|
429
|
+
} else {
|
|
430
|
+
// Extract and play voice line with device voice
|
|
431
|
+
const voiceLine = extractVoiceLine(claudeResponse);
|
|
432
|
+
console.log('[' + new Date().toISOString() + '] VOICE: "' + voiceLine + '"');
|
|
433
|
+
|
|
434
|
+
const responseUrl = await ttsService.generateSpeech(voiceLine, turnVoice);
|
|
435
|
+
// Long answers are the usual thing people want to interrupt.
|
|
436
|
+
const responseBarge = await playInterruptible(endpoint, session, responseUrl);
|
|
437
|
+
if (responseBarge) {
|
|
438
|
+
pendingUtterance = responseBarge;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
387
441
|
|
|
388
442
|
console.log('[' + new Date().toISOString() + '] CONVERSATION Turn ' + turnCount + ' complete');
|
|
389
443
|
}
|