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 +7 -0
- package/claude-api-server/server.js +2 -2
- package/cli/bin/cli-main.js +5 -1
- package/mcp-server/index.js +20 -8
- package/package.json +1 -1
- package/voice-app/lib/audio-fork.js +5 -1
- package/voice-app/lib/sip-handler.js +35 -13
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
|
|
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.)
|
package/cli/bin/cli-main.js
CHANGED
|
@@ -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(
|
|
32
|
+
.version(pkg.version);
|
|
29
33
|
|
|
30
34
|
program
|
|
31
35
|
.command('setup')
|
package/mcp-server/index.js
CHANGED
|
@@ -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
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
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
|
|
134
|
-
`call_status with callId=${callId}
|
|
135
|
-
'
|
|
136
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 <=
|
|
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
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 {
|