claude-phone-local 2.0.0 → 2.0.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/.env.example CHANGED
@@ -73,6 +73,13 @@ AUDIO_BASE_URL=http://voice-app:3000
73
73
  PIPER_VOICE=en_US-lessac-medium
74
74
  WHISPER_MODEL=small
75
75
 
76
+ # ---- voice activity detection (VAD) ----
77
+ # How long the caller must be silent before we finalize their utterance and
78
+ # start transcribing. Every ms here is dead air the caller hears on every
79
+ # single turn. Lower = snappier, but too low risks cutting off a mid-sentence
80
+ # pause as if they'd finished talking.
81
+ VAD_END_SILENCE_MS=700
82
+
76
83
  # ---- cloud mode settings (only needed if STT_MODE/TTS_MODE=cloud) ----
77
84
  # ELEVENLABS_API_KEY=your-elevenlabs-api-key
78
85
  # ELEVENLABS_VOICE_ID=your-default-voice-id
@@ -210,11 +210,11 @@ function runClaudeOnce({ fullPrompt, callId, timestamp }) {
210
210
  const VOICE_CONTEXT = `[VOICE CALL CONTEXT]
211
211
  This query comes via voice call. You MUST include BOTH of these lines in your response:
212
212
 
213
- 🗣️ VOICE_RESPONSE: [Your conversational answer in 40 words or less. This is what gets spoken aloud via TTS. Be natural and helpful, like talking to a friend.]
213
+ 🗣️ VOICE_RESPONSE: [Your conversational answer, spoken aloud via TTS. Be natural and helpful, like talking to a friend. Keep it as tight as the answer allows - a yes/no or a quick fact might be one sentence, but if the caller asked something that genuinely needs more (multiple steps, several items, an explanation), take the space to answer it properly instead of truncating. Don't pad it, but don't cut off partway through a real answer either - target roughly 2-4 sentences for anything non-trivial, more if the content actually requires it.]
214
214
 
215
215
  🎯 COMPLETED: [Status summary in 12 words or less. This is for logging only.]
216
216
 
217
- IMPORTANT: The VOICE_RESPONSE line is what the caller HEARS. Make it conversational and complete - don't just say "Done" or "Task completed". Actually answer their question or confirm what you did in a natural way.
217
+ IMPORTANT: The VOICE_RESPONSE line is what the caller HEARS. Make it conversational and complete - don't just say "Done" or "Task completed". Actually answer their question or confirm what you did in a natural way. A short question deserves a short answer, but never sacrifice a complete answer just to hit a word count.
218
218
 
219
219
  SLACK DELIVERY: When the caller requests delivery to Slack (phrases like "send to Slack", "post to #channel", "message me when done"):
220
220
  1. Do the requested work (research, generate content, analyze, etc.)
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
+ import { createRequire } from 'node:module';
3
4
  import { setupCommand } from '../lib/commands/setup.js';
4
5
  import { startCommand } from '../lib/commands/start.js';
5
6
  import { stopCommand } from '../lib/commands/stop.js';
@@ -20,12 +21,15 @@ import { uninstallCommand } from '../lib/commands/uninstall.js';
20
21
  import { registerMcpServerWithOutput, mcpServerPath } from '../lib/mcp-register.js';
21
22
  import { loadConfig as _loadCfgForMcp } from '../lib/config.js';
22
23
 
24
+ const require = createRequire(import.meta.url);
25
+ const pkg = require('../../package.json');
26
+
23
27
  const program = new Command();
24
28
 
25
29
  program
26
30
  .name('claude-phone')
27
31
  .description('Voice interface for Claude Code via SIP - Call your AI, and your AI can call you')
28
- .version('1.0.0');
32
+ .version(pkg.version);
29
33
 
30
34
  program
31
35
  .command('setup')
@@ -37,10 +37,19 @@ const TOOLS = [
37
37
  'hit a blocker, or finished something they asked to be told about.\n\n' +
38
38
  'RETURNS IMMEDIATELY - it does not wait for the call to finish, so keep working ' +
39
39
  'on the task while the phone rings.\n\n' +
40
- 'mode="conversation" keeps the line open so they can answer back and the voice ' +
41
- 'agent talks with them. To hear what they said, call call_status with the ' +
42
- 'returned callId once you reach a point where their answer matters. If they do ' +
43
- 'not pick up or say nothing, the call ends by itself and call_status reports it.',
40
+ 'CHOOSING THE MODE - this is not optional, pick correctly:\n' +
41
+ '- mode="announce": speaks the message and hangs up. NO reply is ever collected, ' +
42
+ 'not even if your message ends in a question. Only use this for pure FYI ' +
43
+ 'notifications where you genuinely do not need anything back ("the build finished").\n' +
44
+ '- mode="conversation": keeps the line open so they can answer back and the voice ' +
45
+ 'agent talks with them. REQUIRED whenever your message asks a question, requests a ' +
46
+ 'decision, says "let me know", or the task you are calling about is not actually ' +
47
+ 'finished until they respond.\n\n' +
48
+ 'If you use mode="conversation", the call is NOT done when this tool returns - ' +
49
+ 'you MUST poll call_status with the returned callId (a few times, spaced out, while ' +
50
+ 'continuing other work) until state is COMPLETED, then read conversationHistory for ' +
51
+ 'what they said before treating the task as finished. Calling call_me and moving on ' +
52
+ 'without ever checking call_status silently discards their answer - never do that.',
44
53
  inputSchema: {
45
54
  type: 'object',
46
55
  properties: {
@@ -130,10 +139,13 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
130
139
  `Calling ${to} now (callId=${callId}, status=${out.status || 'started'}). ` +
131
140
  'This returned immediately - carry on with the task while it rings.' +
132
141
  (mode === 'conversation'
133
- ? `\n\nConversation mode: they can reply. When you need their answer, call ` +
134
- `call_status with callId=${callId} and read conversationHistory. If they ` +
135
- 'do not answer or stay silent, the call ends on its own.'
136
- : '\n\nAnnounce mode: it speaks the message and hangs up. No reply is collected.'),
142
+ ? `\n\nConversation mode - this task is NOT finished yet. You must poll ` +
143
+ `call_status with callId=${callId} until state is COMPLETED, then read ` +
144
+ 'conversationHistory for their reply before considering this done. Do not ' +
145
+ 'stop after this message - come back to call_status.'
146
+ : '\n\nAnnounce mode: it speaks the message and hangs up. No reply is collected. ' +
147
+ 'If you actually needed an answer, you used the wrong mode - call call_me ' +
148
+ 'again with mode="conversation".'),
137
149
  }],
138
150
  };
139
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-phone-local",
3
- "version": "2.0.0",
3
+ "version": "2.0.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": {
@@ -44,7 +44,11 @@ class AudioForkSession extends EventEmitter {
44
44
  ws,
45
45
  callUuid,
46
46
  sampleRate = 16000,
47
- endSilenceMs = 1500,
47
+ // Silence after speech before we finalize the utterance and start
48
+ // transcribing. This is pure dead air on every single turn, so it's kept
49
+ // as short as the VAD noise-floor tracking reliably tolerates - too low
50
+ // and a mid-sentence breath gets mistaken for end-of-speech.
51
+ endSilenceMs = parseInt(process.env.VAD_END_SILENCE_MS || '700', 10),
48
52
  minSpeechMs = 350,
49
53
  maxUtteranceMs = 60000
50
54
  }) {
@@ -121,11 +121,15 @@ function isGoodbye(transcript) {
121
121
  * Priority: VOICE_RESPONSE > CUSTOM COMPLETED > COMPLETED > first sentence
122
122
  */
123
123
  function extractVoiceLine(response) {
124
- // Priority 1: VOICE_RESPONSE (new format)
125
- var voiceMatch = response.match(/🗣️\s*VOICE_RESPONSE:\s*([^\n]+)/im);
124
+ // Priority 1: VOICE_RESPONSE (new format). Stops at the next labeled line
125
+ // (COMPLETED, or another 🗣️/🎯 marker) rather than the first newline, so a
126
+ // longer answer that wraps onto multiple lines isn't truncated. The word cap
127
+ // is a sanity ceiling against a runaway response, not a target length - real
128
+ // answers that need more room than a one-liner are expected and fine.
129
+ var voiceMatch = response.match(/🗣️\s*VOICE_RESPONSE:\s*([\s\S]+?)(?=\n\s*🎯|\n\s*🗣️|$)/im);
126
130
  if (voiceMatch) {
127
131
  var text = voiceMatch[1].trim().replace(/\*+/g, '').replace(/\[.*?\]/g, '').trim();
128
- if (text && text.split(/\s+/).length <= 60) {
132
+ if (text && text.split(/\s+/).length <= 200) {
129
133
  return text;
130
134
  }
131
135
  }
@@ -241,6 +245,11 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
241
245
  session = await sessionPromise;
242
246
  console.log('[' + new Date().toISOString() + '] AUDIO Fork connected');
243
247
 
248
+ // Pre-render the goodbye clip in the background so hanging up doesn't
249
+ // wait on a fresh Piper round-trip right when the caller wants off the
250
+ // line. Regenerated per detected language below since turnVoice can change.
251
+ let goodbyeUrlPromise = ttsService.generateSpeech("Goodbye! Call again anytime.", turnVoice);
252
+
244
253
  // Main conversation loop
245
254
  let turnCount = 0;
246
255
  const MAX_TURNS = 20;
@@ -292,10 +301,16 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
292
301
  const detectedLang = sttResult.language;
293
302
 
294
303
  // Answer in whatever language the caller just used.
304
+ const previousTurnVoice = turnVoice;
295
305
  turnVoice = voiceForLanguage(detectedLang, voiceId);
296
306
  console.log('[' + new Date().toISOString() + '] WHISPER [' + (detectedLang || '?') +
297
307
  ' -> voice ' + turnVoice + ']: "' + transcript + '"');
298
308
 
309
+ // Re-render the pre-warmed goodbye clip if the caller's language changed.
310
+ if (turnVoice !== previousTurnVoice) {
311
+ goodbyeUrlPromise = ttsService.generateSpeech("Goodbye! Call again anytime.", turnVoice);
312
+ }
313
+
299
314
  if (!transcript || transcript.trim().length < 2) {
300
315
  const clarifyUrl = await ttsService.generateSpeech("Sorry, I didn't catch that. Could you repeat?", turnVoice);
301
316
  await endpoint.play(clarifyUrl);
@@ -303,11 +318,21 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
303
318
  }
304
319
 
305
320
  if (isGoodbye(transcript)) {
306
- const byeUrl = await ttsService.generateSpeech("Goodbye! Call again anytime.", turnVoice);
321
+ const byeUrl = await goodbyeUrlPromise;
307
322
  await endpoint.play(byeUrl);
308
323
  break;
309
324
  }
310
325
 
326
+ // Fire the Claude query immediately - everything else in this block
327
+ // (thinking phrase, hold music/filler loop) runs concurrently with it
328
+ // instead of blocking it, since generating+playing the thinking phrase
329
+ // used to add its own TTS round-trip before the query even started.
330
+ console.log('[' + new Date().toISOString() + '] CLAUDE Querying (device: ' + deviceName + ')...');
331
+ const claudeQueryPromise = claudeBridge.query(
332
+ transcript,
333
+ { callId: callUuid, devicePrompt: devicePrompt }
334
+ );
335
+
311
336
  // THINKING FEEDBACK
312
337
  const thinkingPhrase = getRandomThinkingPhrase();
313
338
  console.log('[' + new Date().toISOString() + '] THINKING: "' + thinkingPhrase + '"');
@@ -342,14 +367,9 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
342
367
  }
343
368
  })();
344
369
 
345
- // Query Claude with device-specific prompt
346
- console.log('[' + new Date().toISOString() + '] CLAUDE Querying (device: ' + deviceName + ')...');
347
370
  let claudeResponse;
348
371
  try {
349
- claudeResponse = await claudeBridge.query(
350
- transcript,
351
- { callId: callUuid, devicePrompt: devicePrompt }
352
- );
372
+ claudeResponse = await claudeQueryPromise;
353
373
  } finally {
354
374
  waiting = false;
355
375
  try { await keepAlive; } catch (e) {}
@@ -383,9 +403,11 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
383
403
  } finally {
384
404
  console.log('[' + new Date().toISOString() + '] CONVERSATION Cleanup...');
385
405
 
386
- try {
387
- await claudeBridge.endSession(callUuid);
388
- } catch (e) {}
406
+ // Fire-and-forget: this is a host-side HTTP bookkeeping call with its own
407
+ // multi-second timeout. Awaiting it here used to leave the call connected
408
+ // and silent for up to 5s after the caller said goodbye, before the SIP
409
+ // dialog was actually torn down.
410
+ claudeBridge.endSession(callUuid).catch(function () {});
389
411
 
390
412
  if (forkRunning) {
391
413
  try {