claude-phone-local 2.0.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/.env.example +108 -0
- package/Dockerfile +84 -0
- package/LICENSE +22 -0
- package/README.md +231 -0
- package/claude-api-server/package-lock.json +832 -0
- package/claude-api-server/package.json +20 -0
- package/claude-api-server/server.js +540 -0
- package/claude-api-server/structured.js +208 -0
- package/cli/README.md +231 -0
- package/cli/bin/check-publish-safety.js +53 -0
- package/cli/bin/claude-phone.js +23 -0
- package/cli/bin/cli-main.js +284 -0
- package/cli/bin/postinstall.js +20 -0
- package/cli/lib/commands/api-server.js +77 -0
- package/cli/lib/commands/backup.js +69 -0
- package/cli/lib/commands/config/path.js +30 -0
- package/cli/lib/commands/config/reset.js +73 -0
- package/cli/lib/commands/config/show.js +86 -0
- package/cli/lib/commands/device/add.js +143 -0
- package/cli/lib/commands/device/list.js +55 -0
- package/cli/lib/commands/device/remove.js +83 -0
- package/cli/lib/commands/doctor.js +422 -0
- package/cli/lib/commands/logs.js +186 -0
- package/cli/lib/commands/restore.js +172 -0
- package/cli/lib/commands/setup.js +1246 -0
- package/cli/lib/commands/start.js +346 -0
- package/cli/lib/commands/status.js +139 -0
- package/cli/lib/commands/stop.js +101 -0
- package/cli/lib/commands/uninstall.js +183 -0
- package/cli/lib/commands/update.js +205 -0
- package/cli/lib/config.js +113 -0
- package/cli/lib/docker.js +384 -0
- package/cli/lib/mcp-register.js +108 -0
- package/cli/lib/network.js +109 -0
- package/cli/lib/platform.js +85 -0
- package/cli/lib/port-check.js +135 -0
- package/cli/lib/prereqs/checks/compose.js +147 -0
- package/cli/lib/prereqs/checks/disk.js +89 -0
- package/cli/lib/prereqs/checks/docker.js +155 -0
- package/cli/lib/prereqs/checks/network.js +78 -0
- package/cli/lib/prereqs/checks/node.js +95 -0
- package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
- package/cli/lib/prereqs/installers/docker.js +229 -0
- package/cli/lib/prereqs/installers/node.js +254 -0
- package/cli/lib/prereqs/platform.js +175 -0
- package/cli/lib/prereqs/utils/execute.js +208 -0
- package/cli/lib/prereqs/utils/rollback.js +223 -0
- package/cli/lib/prereqs/utils/sudo.js +177 -0
- package/cli/lib/prereqs.js +228 -0
- package/cli/lib/prerequisites.js +115 -0
- package/cli/lib/process-manager.js +176 -0
- package/cli/lib/utils.js +78 -0
- package/cli/lib/validators.js +219 -0
- package/cli/lib/voice-downloader.js +79 -0
- package/docker/entrypoint.sh +110 -0
- package/docker/supervisord.conf +78 -0
- package/docker-compose.yml +40 -0
- package/docs/CLAUDE-CODE-SKILL.md +45 -0
- package/docs/LANGUAGES.md +113 -0
- package/docs/MCP-SERVER.md +111 -0
- package/docs/TROUBLESHOOTING.md +288 -0
- package/mcp-server/index.js +159 -0
- package/mcp-server/package-lock.json +1201 -0
- package/mcp-server/package.json +9 -0
- package/package.json +73 -0
- package/stt-local/Dockerfile +12 -0
- package/stt-local/server.py +45 -0
- package/tts-local/Dockerfile +9 -0
- package/tts-local/server.py +83 -0
- package/voice-app/API-QUERY-CONTRACT.md +238 -0
- package/voice-app/DEPLOYMENT.md +176 -0
- package/voice-app/Dockerfile +20 -0
- package/voice-app/README-OUTBOUND.md +316 -0
- package/voice-app/config/devices.json.example +18 -0
- package/voice-app/index.js +273 -0
- package/voice-app/lib/audio-fork.js +465 -0
- package/voice-app/lib/claude-bridge.js +113 -0
- package/voice-app/lib/connection-retry.js +58 -0
- package/voice-app/lib/conversation-loop.js +490 -0
- package/voice-app/lib/device-registry.js +171 -0
- package/voice-app/lib/http-server.js +242 -0
- package/voice-app/lib/logger.js +35 -0
- package/voice-app/lib/multi-registrar.js +133 -0
- package/voice-app/lib/outbound-handler.js +299 -0
- package/voice-app/lib/outbound-routes.js +417 -0
- package/voice-app/lib/outbound-session.js +324 -0
- package/voice-app/lib/query-routes.js +484 -0
- package/voice-app/lib/registrar.js +144 -0
- package/voice-app/lib/sip-handler.js +490 -0
- package/voice-app/lib/tts-service.js +205 -0
- package/voice-app/lib/whisper-client.js +140 -0
- package/voice-app/package.json +38 -0
- package/voice-app/static/gotit-beep.wav +0 -0
- package/voice-app/static/hold-music.wav +0 -0
- package/voice-app/static/ready-beep.wav +0 -0
- package/voice-app/test/freeswitch-retry.test.js +100 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speech-to-Text Client
|
|
3
|
+
*
|
|
4
|
+
* Default mode ("local"): sends audio to the local faster-whisper sidecar
|
|
5
|
+
* container (stt-local) — no API key, fully offline.
|
|
6
|
+
*
|
|
7
|
+
* Optional mode ("cloud", set STT_MODE=cloud): original OpenAI Whisper API
|
|
8
|
+
* behavior, preserved for anyone who still wants it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const WaveFile = require("wavefile").WaveFile;
|
|
12
|
+
const axios = require("axios");
|
|
13
|
+
const FormData = require("form-data");
|
|
14
|
+
|
|
15
|
+
const STT_MODE = (process.env.STT_MODE || "local").toLowerCase();
|
|
16
|
+
// Docker Desktop bridge networking: reach the sidecar by service name.
|
|
17
|
+
const STT_LOCAL_URL = process.env.STT_LOCAL_URL || "http://stt-local:9001";
|
|
18
|
+
|
|
19
|
+
// Lazy-initialized OpenAI client (cloud mode only)
|
|
20
|
+
let openai = null;
|
|
21
|
+
|
|
22
|
+
function getOpenAIClient() {
|
|
23
|
+
if (!openai) {
|
|
24
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
25
|
+
console.warn("[WHISPER] OPENAI_API_KEY not set - cloud STT will not work");
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const OpenAI = require("openai");
|
|
29
|
+
openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
30
|
+
}
|
|
31
|
+
return openai;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert L16 PCM buffer to WAV format
|
|
36
|
+
* @param {Buffer} pcmBuffer - Raw L16 PCM audio data
|
|
37
|
+
* @param {number} sampleRate - Sample rate (default: 8000 Hz for telephony)
|
|
38
|
+
* @returns {Buffer} WAV file buffer
|
|
39
|
+
*/
|
|
40
|
+
function pcmToWav(pcmBuffer, sampleRate = 8000) {
|
|
41
|
+
const wav = new WaveFile();
|
|
42
|
+
const samples = new Int16Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.length / 2);
|
|
43
|
+
wav.fromScratch(1, sampleRate, "16", samples);
|
|
44
|
+
return Buffer.from(wav.toBuffer());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function transcribeLocal(wavBuffer, language) {
|
|
48
|
+
const form = new FormData();
|
|
49
|
+
form.append("file", wavBuffer, { filename: "audio.wav", contentType: "audio/wav" });
|
|
50
|
+
form.append("language", language);
|
|
51
|
+
|
|
52
|
+
const timestamp = new Date().toISOString();
|
|
53
|
+
const response = await axios.post(`${STT_LOCAL_URL}/transcribe`, form, {
|
|
54
|
+
headers: form.getHeaders(),
|
|
55
|
+
timeout: 30000
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const text = (response.data && response.data.text) ? response.data.text.trim() : "";
|
|
59
|
+
const detected = (response.data && response.data.language) ? response.data.language : null;
|
|
60
|
+
console.log(`[${timestamp}] WHISPER-LOCAL Transcribed [${detected || "?"}]: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`);
|
|
61
|
+
return { text, language: detected };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function transcribeCloud(wavBuffer, language) {
|
|
65
|
+
const fs = require("fs");
|
|
66
|
+
const path = require("path");
|
|
67
|
+
const client = getOpenAIClient();
|
|
68
|
+
if (!client) {
|
|
69
|
+
throw new Error("OpenAI API key not configured");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tempFile = path.join("/tmp", "whisper-" + Date.now() + ".wav");
|
|
73
|
+
fs.writeFileSync(tempFile, wavBuffer);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const transcription = await client.audio.transcriptions.create({
|
|
77
|
+
file: fs.createReadStream(tempFile),
|
|
78
|
+
model: "whisper-1",
|
|
79
|
+
language,
|
|
80
|
+
response_format: "text"
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const timestamp = new Date().toISOString();
|
|
84
|
+
console.log(`[${timestamp}] WHISPER-CLOUD Transcribed: ${transcription.substring(0, 100)}${transcription.length > 100 ? "..." : ""}`);
|
|
85
|
+
return transcription;
|
|
86
|
+
} finally {
|
|
87
|
+
try { fs.unlinkSync(tempFile); } catch (e) { /* ignore */ }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Transcribe audio (local by default, cloud if STT_MODE=cloud)
|
|
93
|
+
* @param {Buffer} audioBuffer - Audio data (either WAV or raw PCM)
|
|
94
|
+
* @param {Object} options - { format: "wav"|"pcm", sampleRate, language }
|
|
95
|
+
* @returns {Promise<string>} Transcribed text
|
|
96
|
+
*/
|
|
97
|
+
async function transcribe(audioBuffer, options = {}) {
|
|
98
|
+
const result = await transcribeDetailed(audioBuffer, options);
|
|
99
|
+
return result.text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Same as transcribe() but also reports the language Whisper detected.
|
|
104
|
+
* @returns {Promise<{text: string, language: string|null}>}
|
|
105
|
+
*/
|
|
106
|
+
async function transcribeDetailed(audioBuffer, options = {}) {
|
|
107
|
+
const {
|
|
108
|
+
format = "pcm",
|
|
109
|
+
sampleRate = 8000,
|
|
110
|
+
language = process.env.STT_LANGUAGE || "auto"
|
|
111
|
+
} = options;
|
|
112
|
+
|
|
113
|
+
const wavBuffer = format === "pcm" ? pcmToWav(audioBuffer, sampleRate) : audioBuffer;
|
|
114
|
+
|
|
115
|
+
if (STT_MODE === "cloud") {
|
|
116
|
+
// Cloud path has no detection contract; report the requested language.
|
|
117
|
+
const text = await transcribeCloud(wavBuffer, language === "auto" ? undefined : language);
|
|
118
|
+
return { text, language: language === "auto" ? null : language };
|
|
119
|
+
}
|
|
120
|
+
return transcribeLocal(wavBuffer, language);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if STT is configured and available
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
function isAvailable() {
|
|
128
|
+
if (STT_MODE === "cloud") {
|
|
129
|
+
return !!process.env.OPENAI_API_KEY;
|
|
130
|
+
}
|
|
131
|
+
return true; // local mode has no key requirement
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
transcribe,
|
|
136
|
+
transcribeDetailed,
|
|
137
|
+
pcmToWav,
|
|
138
|
+
isAvailable,
|
|
139
|
+
mode: STT_MODE
|
|
140
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-phone-voice-app",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Voice interface for Claude Code via SIP/3CX",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node index.js",
|
|
8
|
+
"dev": "node --watch index.js",
|
|
9
|
+
"lint": "cd .. && npm run lint:voice-app",
|
|
10
|
+
"lint:fix": "cd .. && eslint voice-app/ --fix"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"axios": "^1.6.0",
|
|
14
|
+
"debug": "^4.3.4",
|
|
15
|
+
"dotenv": "^16.3.1",
|
|
16
|
+
"drachtio-fsmrf": "^3.0.0",
|
|
17
|
+
"drachtio-srf": "^4.5.0",
|
|
18
|
+
"express": "^4.18.2",
|
|
19
|
+
"form-data": "^4.0.0",
|
|
20
|
+
"openai": "^4.20.0",
|
|
21
|
+
"uuid": "^9.0.0",
|
|
22
|
+
"wavefile": "^11.0.0",
|
|
23
|
+
"ws": "^8.14.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"voice",
|
|
30
|
+
"sip",
|
|
31
|
+
"claude",
|
|
32
|
+
"ai",
|
|
33
|
+
"3cx",
|
|
34
|
+
"drachtio"
|
|
35
|
+
],
|
|
36
|
+
"author": "Deepak Chougale",
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FreeSWITCH Retry Logic Test
|
|
3
|
+
*
|
|
4
|
+
* Tests the exponential backoff retry mechanism for FreeSWITCH connections.
|
|
5
|
+
* Run with: node test/freeswitch-retry.test.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
var assert = require('assert');
|
|
9
|
+
|
|
10
|
+
// Import the ACTUAL production code - not a copy!
|
|
11
|
+
var connectionRetry = require('../lib/connection-retry');
|
|
12
|
+
var connectWithRetry = connectionRetry.connectWithRetry;
|
|
13
|
+
|
|
14
|
+
// Mock FreeSWITCH connection function for testing
|
|
15
|
+
function createMockFreeswitchConnect(failCount) {
|
|
16
|
+
var attempts = 0;
|
|
17
|
+
return function() {
|
|
18
|
+
attempts++;
|
|
19
|
+
if (attempts <= failCount) {
|
|
20
|
+
return Promise.reject(new Error('Connection refused (attempt ' + attempts + ')'));
|
|
21
|
+
}
|
|
22
|
+
return Promise.resolve({ connection: 'mock-ms' });
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Test suite
|
|
27
|
+
function runTests() {
|
|
28
|
+
console.log('='.repeat(64));
|
|
29
|
+
console.log('FreeSWITCH Retry Logic Tests');
|
|
30
|
+
console.log('='.repeat(64));
|
|
31
|
+
|
|
32
|
+
// Use short delays for testing (10ms instead of 1000ms)
|
|
33
|
+
var testDelays = [10, 20, 30, 50, 50, 50, 100, 100, 100, 100];
|
|
34
|
+
var maxRetries = 10;
|
|
35
|
+
|
|
36
|
+
// Test 1: Succeed on first attempt
|
|
37
|
+
console.log('\n[TEST 1] Should succeed on first attempt');
|
|
38
|
+
var mockConnect1 = createMockFreeswitchConnect(0);
|
|
39
|
+
connectWithRetry(mockConnect1, {
|
|
40
|
+
maxRetries: maxRetries,
|
|
41
|
+
retryDelays: testDelays,
|
|
42
|
+
name: 'TEST'
|
|
43
|
+
})
|
|
44
|
+
.then(function(ms) {
|
|
45
|
+
assert.strictEqual(ms.connection, 'mock-ms', 'Should return mock connection');
|
|
46
|
+
console.log('[TEST 1] PASSED\n');
|
|
47
|
+
|
|
48
|
+
// Test 2: Succeed on 3rd attempt
|
|
49
|
+
console.log('[TEST 2] Should succeed on 3rd attempt after 2 failures');
|
|
50
|
+
var mockConnect2 = createMockFreeswitchConnect(2);
|
|
51
|
+
return connectWithRetry(mockConnect2, {
|
|
52
|
+
maxRetries: maxRetries,
|
|
53
|
+
retryDelays: testDelays,
|
|
54
|
+
name: 'TEST'
|
|
55
|
+
});
|
|
56
|
+
})
|
|
57
|
+
.then(function(ms) {
|
|
58
|
+
assert.strictEqual(ms.connection, 'mock-ms', 'Should return mock connection');
|
|
59
|
+
console.log('[TEST 2] PASSED\n');
|
|
60
|
+
|
|
61
|
+
// Test 3: Fail after max retries
|
|
62
|
+
console.log('[TEST 3] Should fail after max retries');
|
|
63
|
+
var mockConnect3 = createMockFreeswitchConnect(999); // Always fail
|
|
64
|
+
return connectWithRetry(mockConnect3, {
|
|
65
|
+
maxRetries: maxRetries,
|
|
66
|
+
retryDelays: testDelays,
|
|
67
|
+
name: 'TEST'
|
|
68
|
+
});
|
|
69
|
+
})
|
|
70
|
+
.catch(function(err) {
|
|
71
|
+
assert.ok(err.message.includes('failed after 10 attempts'), 'Should report max retries');
|
|
72
|
+
console.log('[TEST 3] PASSED - Correctly failed after max retries\n');
|
|
73
|
+
|
|
74
|
+
// Test 4: Verify default delay values from production code
|
|
75
|
+
console.log('[TEST 4] Verify default delay values match production');
|
|
76
|
+
// The production defaults are: [1000, 2000, 3000, 5000, 5000, 5000, 10000, 10000, 10000, 10000]
|
|
77
|
+
// We verify by testing that the module accepts empty options and uses defaults
|
|
78
|
+
var mockConnect4 = createMockFreeswitchConnect(0);
|
|
79
|
+
return connectWithRetry(mockConnect4, { name: 'DEFAULT-TEST' });
|
|
80
|
+
})
|
|
81
|
+
.then(function(ms) {
|
|
82
|
+
assert.strictEqual(ms.connection, 'mock-ms', 'Should work with default options');
|
|
83
|
+
console.log('[TEST 4] PASSED\n');
|
|
84
|
+
|
|
85
|
+
console.log('='.repeat(64));
|
|
86
|
+
console.log('All tests PASSED!');
|
|
87
|
+
console.log('='.repeat(64));
|
|
88
|
+
})
|
|
89
|
+
.catch(function(err) {
|
|
90
|
+
console.error('[TEST] FAILED: ' + err.message);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Run tests if executed directly
|
|
96
|
+
if (require.main === module) {
|
|
97
|
+
runTests();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { runTests: runTests };
|