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.
Files changed (96) hide show
  1. package/.env.example +108 -0
  2. package/Dockerfile +84 -0
  3. package/LICENSE +22 -0
  4. package/README.md +231 -0
  5. package/claude-api-server/package-lock.json +832 -0
  6. package/claude-api-server/package.json +20 -0
  7. package/claude-api-server/server.js +540 -0
  8. package/claude-api-server/structured.js +208 -0
  9. package/cli/README.md +231 -0
  10. package/cli/bin/check-publish-safety.js +53 -0
  11. package/cli/bin/claude-phone.js +23 -0
  12. package/cli/bin/cli-main.js +284 -0
  13. package/cli/bin/postinstall.js +20 -0
  14. package/cli/lib/commands/api-server.js +77 -0
  15. package/cli/lib/commands/backup.js +69 -0
  16. package/cli/lib/commands/config/path.js +30 -0
  17. package/cli/lib/commands/config/reset.js +73 -0
  18. package/cli/lib/commands/config/show.js +86 -0
  19. package/cli/lib/commands/device/add.js +143 -0
  20. package/cli/lib/commands/device/list.js +55 -0
  21. package/cli/lib/commands/device/remove.js +83 -0
  22. package/cli/lib/commands/doctor.js +422 -0
  23. package/cli/lib/commands/logs.js +186 -0
  24. package/cli/lib/commands/restore.js +172 -0
  25. package/cli/lib/commands/setup.js +1246 -0
  26. package/cli/lib/commands/start.js +346 -0
  27. package/cli/lib/commands/status.js +139 -0
  28. package/cli/lib/commands/stop.js +101 -0
  29. package/cli/lib/commands/uninstall.js +183 -0
  30. package/cli/lib/commands/update.js +205 -0
  31. package/cli/lib/config.js +113 -0
  32. package/cli/lib/docker.js +384 -0
  33. package/cli/lib/mcp-register.js +108 -0
  34. package/cli/lib/network.js +109 -0
  35. package/cli/lib/platform.js +85 -0
  36. package/cli/lib/port-check.js +135 -0
  37. package/cli/lib/prereqs/checks/compose.js +147 -0
  38. package/cli/lib/prereqs/checks/disk.js +89 -0
  39. package/cli/lib/prereqs/checks/docker.js +155 -0
  40. package/cli/lib/prereqs/checks/network.js +78 -0
  41. package/cli/lib/prereqs/checks/node.js +95 -0
  42. package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
  43. package/cli/lib/prereqs/installers/docker.js +229 -0
  44. package/cli/lib/prereqs/installers/node.js +254 -0
  45. package/cli/lib/prereqs/platform.js +175 -0
  46. package/cli/lib/prereqs/utils/execute.js +208 -0
  47. package/cli/lib/prereqs/utils/rollback.js +223 -0
  48. package/cli/lib/prereqs/utils/sudo.js +177 -0
  49. package/cli/lib/prereqs.js +228 -0
  50. package/cli/lib/prerequisites.js +115 -0
  51. package/cli/lib/process-manager.js +176 -0
  52. package/cli/lib/utils.js +78 -0
  53. package/cli/lib/validators.js +219 -0
  54. package/cli/lib/voice-downloader.js +79 -0
  55. package/docker/entrypoint.sh +110 -0
  56. package/docker/supervisord.conf +78 -0
  57. package/docker-compose.yml +40 -0
  58. package/docs/CLAUDE-CODE-SKILL.md +45 -0
  59. package/docs/LANGUAGES.md +113 -0
  60. package/docs/MCP-SERVER.md +111 -0
  61. package/docs/TROUBLESHOOTING.md +288 -0
  62. package/mcp-server/index.js +159 -0
  63. package/mcp-server/package-lock.json +1201 -0
  64. package/mcp-server/package.json +9 -0
  65. package/package.json +73 -0
  66. package/stt-local/Dockerfile +12 -0
  67. package/stt-local/server.py +45 -0
  68. package/tts-local/Dockerfile +9 -0
  69. package/tts-local/server.py +83 -0
  70. package/voice-app/API-QUERY-CONTRACT.md +238 -0
  71. package/voice-app/DEPLOYMENT.md +176 -0
  72. package/voice-app/Dockerfile +20 -0
  73. package/voice-app/README-OUTBOUND.md +316 -0
  74. package/voice-app/config/devices.json.example +18 -0
  75. package/voice-app/index.js +273 -0
  76. package/voice-app/lib/audio-fork.js +465 -0
  77. package/voice-app/lib/claude-bridge.js +113 -0
  78. package/voice-app/lib/connection-retry.js +58 -0
  79. package/voice-app/lib/conversation-loop.js +490 -0
  80. package/voice-app/lib/device-registry.js +171 -0
  81. package/voice-app/lib/http-server.js +242 -0
  82. package/voice-app/lib/logger.js +35 -0
  83. package/voice-app/lib/multi-registrar.js +133 -0
  84. package/voice-app/lib/outbound-handler.js +299 -0
  85. package/voice-app/lib/outbound-routes.js +417 -0
  86. package/voice-app/lib/outbound-session.js +324 -0
  87. package/voice-app/lib/query-routes.js +484 -0
  88. package/voice-app/lib/registrar.js +144 -0
  89. package/voice-app/lib/sip-handler.js +490 -0
  90. package/voice-app/lib/tts-service.js +205 -0
  91. package/voice-app/lib/whisper-client.js +140 -0
  92. package/voice-app/package.json +38 -0
  93. package/voice-app/static/gotit-beep.wav +0 -0
  94. package/voice-app/static/hold-music.wav +0 -0
  95. package/voice-app/static/ready-beep.wav +0 -0
  96. package/voice-app/test/freeswitch-retry.test.js +100 -0
@@ -0,0 +1,115 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ /**
7
+ * Check if Docker is installed and accessible
8
+ * @returns {Promise<object>} Prerequisite check result
9
+ * @property {boolean} installed - True if Docker is available
10
+ * @property {string} [version] - Docker version if installed
11
+ * @property {string} [error] - Error message if check failed
12
+ * @property {string} [installUrl] - Installation URL if not installed
13
+ */
14
+ export async function checkDocker() {
15
+ try {
16
+ // Check if docker command exists
17
+ const { stdout: whichOutput } = await execAsync('which docker');
18
+
19
+ if (!whichOutput.trim()) {
20
+ return {
21
+ installed: false,
22
+ installUrl: 'https://docs.docker.com/engine/install/'
23
+ };
24
+ }
25
+
26
+ // Get Docker version
27
+ const { stdout: versionOutput } = await execAsync('docker --version');
28
+ const version = versionOutput.trim();
29
+
30
+ return {
31
+ installed: true,
32
+ version: version
33
+ };
34
+ } catch (error) {
35
+ // Docker not found or not executable
36
+ return {
37
+ installed: false,
38
+ error: error.message,
39
+ installUrl: 'https://docs.docker.com/engine/install/'
40
+ };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Check if docker-compose or docker compose is available
46
+ * @returns {Promise<object>} Prerequisite check result
47
+ * @property {boolean} installed - True if compose is available
48
+ * @property {string} [version] - Compose version if installed
49
+ * @property {string} [method] - 'plugin' or 'standalone'
50
+ * @property {string} [error] - Error message if check failed
51
+ * @property {string} [installUrl] - Installation URL if not installed
52
+ */
53
+ export async function checkDockerCompose() {
54
+ // Try docker compose (plugin) first
55
+ try {
56
+ const { stdout } = await execAsync('docker compose version');
57
+ const version = stdout.trim();
58
+
59
+ return {
60
+ installed: true,
61
+ version: version,
62
+ method: 'plugin'
63
+ };
64
+ } catch (pluginError) {
65
+ // Plugin not found, try standalone docker-compose
66
+ try {
67
+ const { stdout } = await execAsync('docker-compose --version');
68
+ const version = stdout.trim();
69
+
70
+ return {
71
+ installed: true,
72
+ version: version,
73
+ method: 'standalone'
74
+ };
75
+ } catch (standaloneError) {
76
+ // Neither available
77
+ return {
78
+ installed: false,
79
+ error: 'Neither docker compose nor docker-compose found',
80
+ installUrl: 'https://docs.docker.com/compose/install/'
81
+ };
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check all prerequisites for Raspberry Pi deployment
88
+ * @returns {Promise<Array>} Array of prerequisite check results
89
+ */
90
+ export async function checkPiPrerequisites() {
91
+ const checks = [];
92
+
93
+ // Check Docker
94
+ const dockerResult = await checkDocker();
95
+ checks.push({
96
+ name: 'Docker',
97
+ installed: dockerResult.installed,
98
+ version: dockerResult.version,
99
+ error: dockerResult.error,
100
+ installUrl: dockerResult.installUrl
101
+ });
102
+
103
+ // Check Docker Compose
104
+ const composeResult = await checkDockerCompose();
105
+ checks.push({
106
+ name: 'Docker Compose',
107
+ installed: composeResult.installed,
108
+ version: composeResult.version,
109
+ method: composeResult.method,
110
+ error: composeResult.error,
111
+ installUrl: composeResult.installUrl
112
+ });
113
+
114
+ return checks;
115
+ }
@@ -0,0 +1,176 @@
1
+ import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { getPidPath, getConfigDir } from './config.js';
5
+
6
+ /**
7
+ * Get the server PID from the PID file
8
+ * @param {string} [pidPath] - Optional PID file path (for testing)
9
+ * @returns {number|null} PID or null if not found
10
+ */
11
+ export function getServerPid(pidPath = null) {
12
+ pidPath = pidPath || getPidPath();
13
+
14
+ if (!fs.existsSync(pidPath)) {
15
+ return null;
16
+ }
17
+
18
+ try {
19
+ const pidStr = fs.readFileSync(pidPath, 'utf8').trim();
20
+ return parseInt(pidStr, 10);
21
+ } catch (error) {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Check if a process is running
28
+ * @param {number} pid - Process ID
29
+ * @returns {boolean} True if running
30
+ */
31
+ function isProcessRunning(pid) {
32
+ try {
33
+ // Sending signal 0 checks if process exists without killing it
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch (error) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Check if the server is running
43
+ * @param {string} [pidPath] - Optional PID file path (for testing)
44
+ * @returns {Promise<boolean>} True if running
45
+ */
46
+ export async function isServerRunning(pidPath = null) {
47
+ const pid = getServerPid(pidPath);
48
+
49
+ if (!pid) {
50
+ return false;
51
+ }
52
+
53
+ return isProcessRunning(pid);
54
+ }
55
+
56
+ /**
57
+ * Start the claude-api-server
58
+ * @param {string} serverPath - Path to claude-api-server directory
59
+ * @param {number} port - Port to listen on
60
+ * @param {string} [pidPath] - Optional PID file path (for testing)
61
+ * @returns {Promise<number>} Process PID
62
+ */
63
+ export async function startServer(serverPath, port, pidPath = null) {
64
+ pidPath = pidPath || getPidPath();
65
+
66
+ // Check if already running
67
+ if (await isServerRunning(pidPath)) {
68
+ const pid = getServerPid(pidPath);
69
+ throw new Error(`Server already running (PID: ${pid})`);
70
+ }
71
+
72
+ return new Promise((resolve, reject) => {
73
+ // Spawn detached process
74
+ const child = spawn('node', ['server.js'], {
75
+ cwd: serverPath,
76
+ detached: true,
77
+ stdio: 'ignore',
78
+ env: {
79
+ ...process.env,
80
+ PORT: port
81
+ }
82
+ });
83
+
84
+ // Don't wait for child process
85
+ child.unref();
86
+
87
+ // Write PID file
88
+ try {
89
+ fs.writeFileSync(pidPath, child.pid.toString(), { mode: 0o600 });
90
+ resolve(child.pid);
91
+ } catch (error) {
92
+ // Kill the child if we can't write PID file
93
+ try {
94
+ process.kill(child.pid, 'SIGTERM');
95
+ } catch (killError) {
96
+ // Ignore kill errors
97
+ }
98
+ reject(new Error(`Failed to write PID file: ${error.message}`));
99
+ }
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Stop the claude-api-server
105
+ * @param {string} [pidPath] - Optional PID file path (for testing)
106
+ * @returns {Promise<void>}
107
+ */
108
+ export async function stopServer(pidPath = null) {
109
+ pidPath = pidPath || getPidPath();
110
+ const pid = getServerPid(pidPath);
111
+
112
+ if (!pid) {
113
+ // Remove stale PID file if it exists
114
+ if (fs.existsSync(pidPath)) {
115
+ fs.unlinkSync(pidPath);
116
+ }
117
+ return;
118
+ }
119
+
120
+ if (!isProcessRunning(pid)) {
121
+ // Process not running, remove PID file
122
+ fs.unlinkSync(pidPath);
123
+ return;
124
+ }
125
+
126
+ // Try graceful shutdown first
127
+ try {
128
+ process.kill(pid, 'SIGTERM');
129
+
130
+ // Wait up to 5 seconds for graceful shutdown
131
+ const startTime = Date.now();
132
+ while (isProcessRunning(pid) && (Date.now() - startTime) < 5000) {
133
+ await new Promise(resolve => setTimeout(resolve, 100));
134
+ }
135
+
136
+ // Force kill if still running
137
+ if (isProcessRunning(pid)) {
138
+ process.kill(pid, 'SIGKILL');
139
+ // Wait a bit more
140
+ await new Promise(resolve => setTimeout(resolve, 500));
141
+ }
142
+ } catch (error) {
143
+ // Process might already be dead
144
+ }
145
+
146
+ // Remove PID file
147
+ if (fs.existsSync(pidPath)) {
148
+ fs.unlinkSync(pidPath);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Save a PID file for a named service
154
+ * @param {string} name - Service name (e.g., 'claude-api-server')
155
+ * @param {number} pid - Process ID
156
+ */
157
+ export function savePid(name, pid) {
158
+ const configDir = getConfigDir();
159
+ if (!fs.existsSync(configDir)) {
160
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
161
+ }
162
+ const pidFile = path.join(configDir, `${name}.pid`);
163
+ fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 });
164
+ }
165
+
166
+ /**
167
+ * Remove a PID file for a named service
168
+ * @param {string} name - Service name (e.g., 'claude-api-server')
169
+ */
170
+ export function removePid(name) {
171
+ const configDir = getConfigDir();
172
+ const pidFile = path.join(configDir, `${name}.pid`);
173
+ if (fs.existsSync(pidFile)) {
174
+ fs.unlinkSync(pidFile);
175
+ }
176
+ }
@@ -0,0 +1,78 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ /**
10
+ * Get the project root directory (where voice-app and claude-api-server live)
11
+ * This resolves from the CLI package location, not process.cwd()
12
+ *
13
+ * Directory structure:
14
+ * claude-phone/ <- project root (returned)
15
+ * ├── cli/
16
+ * │ └── lib/
17
+ * │ └── utils.js <- this file
18
+ * ├── voice-app/
19
+ * └── claude-api-server/
20
+ *
21
+ * @returns {string} Absolute path to project root
22
+ */
23
+ export function getProjectRoot() {
24
+ // utils.js is at cli/lib/utils.js
25
+ // Project root is two levels up: cli/lib -> cli -> project root
26
+ return path.resolve(__dirname, '..', '..');
27
+ }
28
+
29
+ /**
30
+ * Get local IP address (best guess)
31
+ * @returns {string} Local IP address or 'auto'
32
+ */
33
+ export function getLocalIP() {
34
+ const interfaces = os.networkInterfaces();
35
+
36
+ // Look for non-internal IPv4 addresses
37
+ for (const name of Object.keys(interfaces)) {
38
+ for (const iface of interfaces[name]) {
39
+ // Skip internal (loopback) and IPv6
40
+ if (iface.family === 'IPv4' && !iface.internal) {
41
+ return iface.address;
42
+ }
43
+ }
44
+ }
45
+
46
+ return 'auto';
47
+ }
48
+
49
+ /**
50
+ * Check if Claude CLI is installed
51
+ * @returns {Promise<boolean>}
52
+ */
53
+ export async function isClaudeInstalled() {
54
+ return new Promise((resolve) => {
55
+ const check = spawn('which', ['claude']);
56
+ check.on('close', (code) => {
57
+ resolve(code === 0);
58
+ });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Sleep for specified milliseconds
64
+ * @param {number} ms - Milliseconds to sleep
65
+ * @returns {Promise<void>}
66
+ */
67
+ export function sleep(ms) {
68
+ return new Promise(resolve => setTimeout(resolve, ms));
69
+ }
70
+
71
+ /**
72
+ * Format device config for display
73
+ * @param {object} device - Device configuration
74
+ * @returns {string} Formatted device info
75
+ */
76
+ export function formatDevice(device) {
77
+ return `${device.name} (ext ${device.extension})`;
78
+ }
@@ -0,0 +1,219 @@
1
+ import axios from 'axios';
2
+
3
+ /**
4
+ * Validate ElevenLabs API key by making a test request
5
+ * @param {string} apiKey - ElevenLabs API key
6
+ * @returns {Promise<{valid: boolean, error?: string}>} Validation result
7
+ */
8
+ export async function validateElevenLabsKey(apiKey) {
9
+ if (!apiKey || apiKey.trim() === '') {
10
+ return {
11
+ valid: false,
12
+ error: 'API key cannot be empty'
13
+ };
14
+ }
15
+
16
+ try {
17
+ const response = await axios.get('https://api.elevenlabs.io/v1/voices', {
18
+ headers: {
19
+ 'xi-api-key': apiKey
20
+ },
21
+ timeout: 10000
22
+ });
23
+
24
+ if (response.status === 200) {
25
+ return { valid: true };
26
+ }
27
+
28
+ return {
29
+ valid: false,
30
+ error: `Unexpected status: ${response.status}`
31
+ };
32
+ } catch (error) {
33
+ if (error.response) {
34
+ if (error.response.status === 401) {
35
+ return {
36
+ valid: false,
37
+ error: 'Invalid API key (401 Unauthorized)'
38
+ };
39
+ }
40
+ return {
41
+ valid: false,
42
+ error: `API error: ${error.response.status} ${error.response.statusText}`
43
+ };
44
+ }
45
+
46
+ if (error.code === 'ECONNABORTED') {
47
+ return {
48
+ valid: false,
49
+ error: 'Request timeout - check your internet connection'
50
+ };
51
+ }
52
+
53
+ return {
54
+ valid: false,
55
+ error: `Network error: ${error.message}`
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Validate OpenAI API key by making a test request
62
+ * @param {string} apiKey - OpenAI API key
63
+ * @returns {Promise<{valid: boolean, error?: string}>} Validation result
64
+ */
65
+ export async function validateOpenAIKey(apiKey) {
66
+ if (!apiKey || apiKey.trim() === '') {
67
+ return {
68
+ valid: false,
69
+ error: 'API key cannot be empty'
70
+ };
71
+ }
72
+
73
+ try {
74
+ const response = await axios.get('https://api.openai.com/v1/models', {
75
+ headers: {
76
+ 'Authorization': `Bearer ${apiKey}`
77
+ },
78
+ timeout: 10000
79
+ });
80
+
81
+ if (response.status === 200) {
82
+ return { valid: true };
83
+ }
84
+
85
+ return {
86
+ valid: false,
87
+ error: `Unexpected status: ${response.status}`
88
+ };
89
+ } catch (error) {
90
+ if (error.response) {
91
+ if (error.response.status === 401) {
92
+ return {
93
+ valid: false,
94
+ error: 'Invalid API key (401 Unauthorized)'
95
+ };
96
+ }
97
+ return {
98
+ valid: false,
99
+ error: `API error: ${error.response.status} ${error.response.statusText}`
100
+ };
101
+ }
102
+
103
+ if (error.code === 'ECONNABORTED') {
104
+ return {
105
+ valid: false,
106
+ error: 'Request timeout - check your internet connection'
107
+ };
108
+ }
109
+
110
+ return {
111
+ valid: false,
112
+ error: `Network error: ${error.message}`
113
+ };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Validate SIP extension format
119
+ * @param {string} extension - SIP extension number
120
+ * @returns {boolean} True if valid
121
+ */
122
+ export function validateExtension(extension) {
123
+ return /^\d{4,5}$/.test(extension);
124
+ }
125
+
126
+ /**
127
+ * Validate IP address format
128
+ * @param {string} ip - IP address
129
+ * @returns {boolean} True if valid
130
+ */
131
+ export function validateIP(ip) {
132
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
133
+ if (!ipv4Regex.test(ip)) {
134
+ return false;
135
+ }
136
+
137
+ const parts = ip.split('.');
138
+ return parts.every(part => {
139
+ const num = parseInt(part, 10);
140
+ return num >= 0 && num <= 255;
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Validate hostname format
146
+ * @param {string} hostname - Hostname or FQDN
147
+ * @returns {boolean} True if valid
148
+ */
149
+ export function validateHostname(hostname) {
150
+ const hostnameRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i;
151
+ return hostnameRegex.test(hostname);
152
+ }
153
+
154
+ /**
155
+ * Validate ElevenLabs voice ID
156
+ * @param {string} apiKey - ElevenLabs API key
157
+ * @param {string} voiceId - Voice ID to validate
158
+ * @returns {Promise<{valid: boolean, name?: string, error?: string}>} Validation result
159
+ */
160
+ export async function validateVoiceId(apiKey, voiceId) {
161
+ if (!voiceId || voiceId.trim() === '') {
162
+ return {
163
+ valid: false,
164
+ error: 'Voice ID cannot be empty'
165
+ };
166
+ }
167
+
168
+ try {
169
+ const response = await axios.get(`https://api.elevenlabs.io/v1/voices/${voiceId}`, {
170
+ headers: {
171
+ 'xi-api-key': apiKey
172
+ },
173
+ timeout: 10000
174
+ });
175
+
176
+ if (response.status === 200 && response.data.name) {
177
+ return {
178
+ valid: true,
179
+ name: response.data.name
180
+ };
181
+ }
182
+
183
+ return {
184
+ valid: false,
185
+ error: `Unexpected response: ${response.status}`
186
+ };
187
+ } catch (error) {
188
+ if (error.response) {
189
+ if (error.response.status === 404) {
190
+ return {
191
+ valid: false,
192
+ error: 'Voice ID not found'
193
+ };
194
+ }
195
+ if (error.response.status === 401) {
196
+ return {
197
+ valid: false,
198
+ error: 'Invalid API key (cannot validate voice ID)'
199
+ };
200
+ }
201
+ return {
202
+ valid: false,
203
+ error: `API error: ${error.response.status} ${error.response.statusText}`
204
+ };
205
+ }
206
+
207
+ if (error.code === 'ECONNABORTED') {
208
+ return {
209
+ valid: false,
210
+ error: 'Request timeout - check your internet connection'
211
+ };
212
+ }
213
+
214
+ return {
215
+ valid: false,
216
+ error: `Network error: ${error.message}`
217
+ };
218
+ }
219
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import https from 'https';
4
+
5
+ // Curated list of common Piper voices with their known-good download URLs.
6
+ // If a user picks one of these during setup, we fetch it automatically.
7
+ // Anyone who wants a different voice from the full catalog at
8
+ // huggingface.co/rhasspy/piper-voices can still drop files into
9
+ // <ttsLocal>/voices/ manually — that path stays supported.
10
+ const KNOWN_VOICES = {
11
+ 'en_US-lessac-medium': 'en/en_US/lessac/medium',
12
+ 'en_US-amy-medium': 'en/en_US/amy/medium',
13
+ 'en_US-ryan-high': 'en/en_US/ryan/high',
14
+ 'en_GB-alan-medium': 'en/en_GB/alan/medium'
15
+ };
16
+
17
+ const BASE_URL = 'https://huggingface.co/rhasspy/piper-voices/resolve/main';
18
+
19
+ function downloadFile(url, destPath) {
20
+ return new Promise((resolve, reject) => {
21
+ const file = fs.createWriteStream(destPath);
22
+ https.get(url, (response) => {
23
+ if (response.statusCode === 302 || response.statusCode === 301) {
24
+ // follow redirect
25
+ https.get(response.headers.location, (redirected) => {
26
+ redirected.pipe(file);
27
+ file.on('finish', () => file.close(resolve));
28
+ }).on('error', reject);
29
+ return;
30
+ }
31
+ if (response.statusCode !== 200) {
32
+ reject(new Error(`HTTP ${response.statusCode} for ${url}`));
33
+ return;
34
+ }
35
+ response.pipe(file);
36
+ file.on('finish', () => file.close(resolve));
37
+ }).on('error', (err) => {
38
+ fs.unlink(destPath, () => {});
39
+ reject(err);
40
+ });
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Ensure a Piper voice model is present locally, downloading it if it's a
46
+ * known voice and not already there. Silently does nothing for unknown
47
+ * voice names (those are assumed to be manually placed by the user).
48
+ * @param {string} voiceName - e.g. "en_US-lessac-medium"
49
+ * @param {string} voicesDir - path to tts-local/voices
50
+ * @returns {Promise<{downloaded: boolean, skipped: boolean, error?: string}>}
51
+ */
52
+ export async function ensureVoiceModel(voiceName, voicesDir) {
53
+ const modelPath = path.join(voicesDir, `${voiceName}.onnx`);
54
+ const configPath = path.join(voicesDir, `${voiceName}.onnx.json`);
55
+
56
+ if (fs.existsSync(modelPath) && fs.existsSync(configPath)) {
57
+ return { downloaded: false, skipped: true };
58
+ }
59
+
60
+ const subPath = KNOWN_VOICES[voiceName];
61
+ if (!subPath) {
62
+ return {
63
+ downloaded: false,
64
+ skipped: false,
65
+ error: `Unknown voice "${voiceName}" — not in the auto-download list. ` +
66
+ `Download it manually from huggingface.co/rhasspy/piper-voices ` +
67
+ `into ${voicesDir}/`
68
+ };
69
+ }
70
+
71
+ fs.mkdirSync(voicesDir, { recursive: true });
72
+
73
+ await downloadFile(`${BASE_URL}/${subPath}/${voiceName}.onnx`, modelPath);
74
+ await downloadFile(`${BASE_URL}/${subPath}/${voiceName}.onnx.json`, configPath);
75
+
76
+ return { downloaded: true, skipped: false };
77
+ }
78
+
79
+ export { KNOWN_VOICES };