@settinghead/voxlert 0.3.5 → 0.3.7
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/README.md +26 -4
- package/assets/demo-thumbnail.png +0 -0
- package/assets/sc1-adjutant.gif +0 -0
- package/assets/sc1-kerrigan-infested.jpg +0 -0
- package/config.default.json +2 -2
- package/package.json +1 -1
- package/packs/sc1-kerrigan/pack.json +32 -33
- package/packs/sc1-kerrigan/voice.wav +0 -0
- package/packs/sc1-kerrigan-infested/pack.json +69 -0
- package/packs/sc1-kerrigan-infested/voice.wav +0 -0
- package/packs/{sc2-kerrigan → sc2-kerrigan-infested}/pack.json +1 -1
- package/src/audio.js +166 -10
- package/src/cli.js +23 -1
- package/src/commands/pack-helpers.js +11 -3
- package/src/commands/setup.js +4 -2
- package/src/cost.js +4 -1
- package/src/pack-registry.js +2 -1
- package/src/packs.js +1 -0
- package/src/setup-ui.js +12 -12
- package/src/setup.js +198 -73
- package/src/tts-test.js +108 -43
- package/src/voxlert.js +1 -1
- /package/assets/{sc2-kerrigan.jpg → sc2-kerrigan-infested.jpg} +0 -0
- /package/packs/{sc2-kerrigan → sc2-kerrigan-infested}/voice.wav +0 -0
package/src/setup-ui.js
CHANGED
|
@@ -14,12 +14,12 @@ const ANSI = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
const LOGO_LINES = [
|
|
17
|
-
"██╗ ██╗ ██████╗ ██╗
|
|
18
|
-
"██║
|
|
19
|
-
"██║ ██║██║
|
|
20
|
-
"╚██╗ ██╔╝██║
|
|
21
|
-
" ╚████╔╝
|
|
22
|
-
" ╚═══╝ ╚═════╝ ╚═╝
|
|
17
|
+
"██╗ ██╗ ██████╗ ██╗ ██╗██╗ ███████╗██████╗ ████████╗",
|
|
18
|
+
"██║ ██║██╔═══██╗╚██╗██╔╝██║ ██╔════╝██╔══██╗╚══██╔══╝",
|
|
19
|
+
"██║ ██║██║ ██║ ╚███╔╝ ██║ █████╗ ██████╔╝ ██║ ",
|
|
20
|
+
"╚██╗ ██╔╝██║ ██║ ██╔██╗ ██║ ██╔══╝ ██╔══██╗ ██║ ",
|
|
21
|
+
" ╚████╔╝ ╚██████╔╝██╔╝ ██╗███████╗███████╗██║ ██║ ██║ ",
|
|
22
|
+
" ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
function color(text, code) {
|
|
@@ -84,7 +84,7 @@ function animatedLogoLine(text, phase = 0, shimmerIndex = -1) {
|
|
|
84
84
|
}).join("") + ANSI.reset;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function centerLine(text, width =
|
|
87
|
+
function centerLine(text, width = 59) {
|
|
88
88
|
const padding = Math.max(0, Math.floor((width - text.length) / 2));
|
|
89
89
|
return `${" ".repeat(padding)}${text}`;
|
|
90
90
|
}
|
|
@@ -98,8 +98,8 @@ function formatCurrentConfig(config, installedPlatforms) {
|
|
|
98
98
|
const providerLabel = config.llm_api_key
|
|
99
99
|
? `${provider ? provider.name : (config.llm_backend || "openrouter")} (${config.llm_model || provider?.defaultModel || "default"})`
|
|
100
100
|
: "Fallback only";
|
|
101
|
-
const ttsLabel = config.tts_backend || "
|
|
102
|
-
const voiceLabel = config.active_pack || "
|
|
101
|
+
const ttsLabel = config.tts_backend || "qwen";
|
|
102
|
+
const voiceLabel = config.active_pack || "sc1-kerrigan-infested";
|
|
103
103
|
const platforms = installedPlatforms.filter(Boolean);
|
|
104
104
|
return [
|
|
105
105
|
`${color("Current", ANSI.dim)} ${providerLabel}`,
|
|
@@ -111,18 +111,18 @@ function formatCurrentConfig(config, installedPlatforms) {
|
|
|
111
111
|
|
|
112
112
|
function renderLogoFrame(config, installedPlatforms, shimmerStep = -1) {
|
|
113
113
|
const current = formatCurrentConfig(config, installedPlatforms).map((line) => ` ${line}`);
|
|
114
|
-
const rule = color("┈".repeat(
|
|
114
|
+
const rule = color("┈".repeat(59), ANSI.dim);
|
|
115
115
|
const glow = color("SYNTHETIC VOICE NOTIFICATIONS FOR AGENT WORKFLOWS", ANSI.cyan);
|
|
116
116
|
const logo = LOGO_LINES.map((line, index) => {
|
|
117
117
|
const shimmerIndex = shimmerStep >= 0 ? shimmerStep - index * 3 : -1;
|
|
118
118
|
const phase = shimmerStep >= 0 ? shimmerStep * 0.015 + index * 0.02 : index * 0.02;
|
|
119
|
-
return centerLine(animatedLogoLine(line, phase, shimmerIndex),
|
|
119
|
+
return centerLine(animatedLogoLine(line, phase, shimmerIndex), 59);
|
|
120
120
|
});
|
|
121
121
|
return [
|
|
122
122
|
"",
|
|
123
123
|
rule,
|
|
124
124
|
...logo,
|
|
125
|
-
centerLine(glow,
|
|
125
|
+
centerLine(glow, 59),
|
|
126
126
|
rule,
|
|
127
127
|
"",
|
|
128
128
|
...current,
|
package/src/setup.js
CHANGED
|
@@ -23,8 +23,6 @@ import { registerCursorHooks, unregisterCursorHooks, hasCursorHooks } from "./cu
|
|
|
23
23
|
import { registerCodexNotify, getCodexConfigPath, unregisterCodexNotify, hasCodexNotify } from "./codex-config.js";
|
|
24
24
|
import { printSetupHeader, printStep, printStatus, printSuccess, printWarning, highlight } from "./setup-ui.js";
|
|
25
25
|
import {
|
|
26
|
-
QWEN_DOCS_URL,
|
|
27
|
-
CHATTERBOX_DOCS_URL,
|
|
28
26
|
probeTtsBackend,
|
|
29
27
|
chooseTtsBackend,
|
|
30
28
|
verifyTtsSetup,
|
|
@@ -38,64 +36,68 @@ function validateApiKey(providerId, apiKey) {
|
|
|
38
36
|
const provider = getProvider(providerId);
|
|
39
37
|
if (!provider) return resolve({ ok: false, error: "Unknown provider" });
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
options = {
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: {
|
|
58
|
-
...authHeaders,
|
|
59
|
-
"Content-Type": "application/json",
|
|
60
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
61
|
-
},
|
|
62
|
-
timeout: 8000,
|
|
63
|
-
};
|
|
64
|
-
const req = https.request(url, options, (res) => {
|
|
65
|
-
let data = "";
|
|
66
|
-
res.on("data", (chunk) => (data += chunk));
|
|
67
|
-
res.on("end", () => {
|
|
68
|
-
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
69
|
-
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
70
|
-
resolve({ ok: res.statusCode < 500 });
|
|
39
|
+
try {
|
|
40
|
+
let url;
|
|
41
|
+
let options;
|
|
42
|
+
|
|
43
|
+
const base = provider.baseUrl.replace(/\/+$/, "");
|
|
44
|
+
|
|
45
|
+
if (provider.format === "anthropic") {
|
|
46
|
+
// Anthropic: POST to /v1/messages with a tiny request
|
|
47
|
+
url = new URL(`${base}/v1/messages`);
|
|
48
|
+
const authHeaders = provider.authHeader(apiKey);
|
|
49
|
+
const payload = JSON.stringify({
|
|
50
|
+
model: provider.defaultModel,
|
|
51
|
+
max_tokens: 1,
|
|
52
|
+
messages: [{ role: "user", content: "hi" }],
|
|
71
53
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
54
|
+
options = {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
...authHeaders,
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
60
|
+
},
|
|
61
|
+
timeout: 8000,
|
|
62
|
+
};
|
|
63
|
+
const req = https.request(url, options, (res) => {
|
|
64
|
+
let data = "";
|
|
65
|
+
res.on("data", (chunk) => (data += chunk));
|
|
66
|
+
res.on("end", () => {
|
|
67
|
+
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
68
|
+
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
69
|
+
resolve({ ok: res.statusCode < 500 });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
req.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
73
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
|
|
74
|
+
req.write(payload);
|
|
75
|
+
req.end();
|
|
76
|
+
} else {
|
|
77
|
+
// OpenAI-compatible: GET /models
|
|
78
|
+
url = new URL(`${base}/models`);
|
|
79
|
+
const authHeaders = provider.authHeader(apiKey);
|
|
80
|
+
options = {
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: { ...authHeaders },
|
|
83
|
+
timeout: 8000,
|
|
84
|
+
};
|
|
85
|
+
const reqFn = url.protocol === "https:" ? https.request : http.request;
|
|
86
|
+
const req = reqFn(url, options, (res) => {
|
|
87
|
+
let data = "";
|
|
88
|
+
res.on("data", (chunk) => (data += chunk));
|
|
89
|
+
res.on("end", () => {
|
|
90
|
+
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
91
|
+
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
92
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300 });
|
|
93
|
+
});
|
|
94
94
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
req.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
96
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
|
|
97
|
+
req.end();
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
resolve({ ok: false, error: err.message });
|
|
99
101
|
}
|
|
100
102
|
});
|
|
101
103
|
}
|
|
@@ -177,13 +179,36 @@ function ensurePacks() {
|
|
|
177
179
|
}
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
export async function runSetup() {
|
|
182
|
+
export async function runSetup({ nonInteractive = false } = {}) {
|
|
181
183
|
// Ensure config exists
|
|
182
184
|
ensureConfig();
|
|
183
185
|
ensurePacks();
|
|
184
186
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
185
187
|
|
|
186
|
-
const
|
|
188
|
+
const rawConfig = loadConfig();
|
|
189
|
+
|
|
190
|
+
// Auto-persist: any property write saves to disk immediately
|
|
191
|
+
const config = new Proxy(rawConfig, {
|
|
192
|
+
set(target, prop, value) {
|
|
193
|
+
target[prop] = value;
|
|
194
|
+
try { saveConfig(target); } catch { /* ignore */ }
|
|
195
|
+
return true;
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
process.on("SIGINT", () => {
|
|
200
|
+
console.log("");
|
|
201
|
+
printWarning("Setup interrupted — progress saved. Run 'voxlert setup' to resume.");
|
|
202
|
+
console.log("");
|
|
203
|
+
process.exit(130);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
|
|
208
|
+
if (nonInteractive) {
|
|
209
|
+
return await runNonInteractiveSetup(config);
|
|
210
|
+
}
|
|
211
|
+
|
|
187
212
|
const currentBackend = config.llm_backend || "openrouter";
|
|
188
213
|
const currentProvider = getProvider(currentBackend);
|
|
189
214
|
const currentModel = config.llm_model || currentProvider?.defaultModel || "default";
|
|
@@ -225,6 +250,7 @@ export async function runSetup() {
|
|
|
225
250
|
|
|
226
251
|
if (chosenProvider !== "skip") {
|
|
227
252
|
config.llm_backend = chosenProvider;
|
|
253
|
+
|
|
228
254
|
const provider = getProvider(chosenProvider);
|
|
229
255
|
|
|
230
256
|
// --- Step 2: API Key ---
|
|
@@ -238,7 +264,7 @@ export async function runSetup() {
|
|
|
238
264
|
? `${existingKey.slice(0, 4)}…${existingKey.slice(-4)}`
|
|
239
265
|
: "";
|
|
240
266
|
|
|
241
|
-
apiKey = await input({
|
|
267
|
+
apiKey = (await input({
|
|
242
268
|
message: "Paste your API key:",
|
|
243
269
|
default: existingKey || undefined,
|
|
244
270
|
transformer: (val) => {
|
|
@@ -247,7 +273,7 @@ export async function runSetup() {
|
|
|
247
273
|
if (val.length <= 8) return "****";
|
|
248
274
|
return val.slice(0, 4) + "…" + val.slice(-4);
|
|
249
275
|
},
|
|
250
|
-
});
|
|
276
|
+
})).trim();
|
|
251
277
|
|
|
252
278
|
if (apiKey) {
|
|
253
279
|
process.stdout.write(" Validating key... ");
|
|
@@ -271,26 +297,30 @@ export async function runSetup() {
|
|
|
271
297
|
|
|
272
298
|
if (apiKey) {
|
|
273
299
|
config.llm_api_key = apiKey;
|
|
274
|
-
// Clear legacy field if using the new unified field
|
|
275
300
|
if (chosenProvider === "openrouter") {
|
|
276
301
|
config.openrouter_api_key = apiKey;
|
|
277
302
|
}
|
|
303
|
+
|
|
278
304
|
} else {
|
|
279
305
|
config.llm_api_key = null;
|
|
280
306
|
config.openrouter_api_key = null;
|
|
307
|
+
|
|
281
308
|
}
|
|
282
309
|
} else {
|
|
283
310
|
config.llm_api_key = null;
|
|
284
311
|
config.openrouter_api_key = null;
|
|
312
|
+
|
|
285
313
|
}
|
|
286
314
|
|
|
287
315
|
// Set default model for chosen provider
|
|
288
316
|
if (!config.llm_model && !config.openrouter_model) {
|
|
289
317
|
config.llm_model = provider.defaultModel;
|
|
318
|
+
|
|
290
319
|
}
|
|
291
320
|
} else {
|
|
292
321
|
config.llm_api_key = null;
|
|
293
322
|
config.openrouter_api_key = null;
|
|
323
|
+
|
|
294
324
|
console.log("");
|
|
295
325
|
printWarning("Using fallback phrases from the voice pack.");
|
|
296
326
|
console.log("");
|
|
@@ -323,7 +353,7 @@ export async function runSetup() {
|
|
|
323
353
|
}));
|
|
324
354
|
|
|
325
355
|
const toDownload = await checkbox({
|
|
326
|
-
message: "Which voice packs do you want to install? (
|
|
356
|
+
message: "Which voice packs do you want to install? (space = toggle, enter = confirm)",
|
|
327
357
|
choices: packChoices,
|
|
328
358
|
required: false,
|
|
329
359
|
});
|
|
@@ -362,11 +392,12 @@ export async function runSetup() {
|
|
|
362
392
|
];
|
|
363
393
|
|
|
364
394
|
const chosenPack = await select({
|
|
365
|
-
message: "Choose
|
|
395
|
+
message: "Choose default voice:",
|
|
366
396
|
choices: packChoices,
|
|
367
|
-
default: active || "
|
|
397
|
+
default: active || "random",
|
|
368
398
|
});
|
|
369
399
|
config.active_pack = chosenPack;
|
|
400
|
+
|
|
370
401
|
} else {
|
|
371
402
|
printWarning("No voice packs found. Using default.");
|
|
372
403
|
console.log("");
|
|
@@ -378,15 +409,13 @@ export async function runSetup() {
|
|
|
378
409
|
|
|
379
410
|
printStatus("Recommended", "Qwen TTS for a more natural voice");
|
|
380
411
|
printStatus("Voice test", `Uses the voice you picked in Step 4 (${config.active_pack || "default"})`);
|
|
381
|
-
printStatus("Qwen TTS setup docs", QWEN_DOCS_URL);
|
|
382
|
-
printStatus("Chatterbox setup docs", CHATTERBOX_DOCS_URL);
|
|
383
412
|
console.log("");
|
|
384
413
|
|
|
385
|
-
process.stdout.write(" Checking Chatterbox
|
|
414
|
+
process.stdout.write(" Checking Chatterbox... ");
|
|
386
415
|
const chatterboxUp = await probeTtsBackend(config, "chatterbox");
|
|
387
416
|
console.log(chatterboxUp ? "detected!" : "not running");
|
|
388
417
|
|
|
389
|
-
process.stdout.write(" Checking Qwen TTS
|
|
418
|
+
process.stdout.write(" Checking Qwen TTS... ");
|
|
390
419
|
const qwenUp = await probeTtsBackend(config, "qwen");
|
|
391
420
|
console.log(qwenUp ? "detected!" : "not running");
|
|
392
421
|
|
|
@@ -419,7 +448,7 @@ export async function runSetup() {
|
|
|
419
448
|
];
|
|
420
449
|
|
|
421
450
|
const selectedPlatforms = await checkbox({
|
|
422
|
-
message: "Which platforms do you want to install hooks for?",
|
|
451
|
+
message: "Which platforms do you want to install hooks for? (space = toggle, enter = confirm)",
|
|
423
452
|
choices: platformChoices,
|
|
424
453
|
required: false,
|
|
425
454
|
});
|
|
@@ -472,10 +501,106 @@ export async function runSetup() {
|
|
|
472
501
|
printWarning("No platforms selected. Run 'voxlert setup' again to install hooks later.");
|
|
473
502
|
}
|
|
474
503
|
|
|
475
|
-
// ---
|
|
504
|
+
// --- Summary ---
|
|
505
|
+
printSetupSummary(config, "skip", []);
|
|
506
|
+
|
|
507
|
+
} catch (err) {
|
|
508
|
+
// Inquirer throws on Ctrl+C (ExitPromptError); progress already persisted
|
|
509
|
+
if (err && (err.name === "ExitPromptError" || err.message === "Prompt was canceled")) {
|
|
510
|
+
|
|
511
|
+
console.log("");
|
|
512
|
+
printWarning("Setup interrupted — progress saved. Run 'voxlert setup' to resume.");
|
|
513
|
+
console.log("");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
throw err;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Non-interactive setup: accept all defaults, skip prompts.
|
|
522
|
+
* Useful for CI, Docker, and automated testing.
|
|
523
|
+
*/
|
|
524
|
+
async function runNonInteractiveSetup(config) {
|
|
525
|
+
console.log("Running non-interactive setup (--yes)...\n");
|
|
526
|
+
|
|
527
|
+
// Step 1–2: LLM — skip (fallback phrases only)
|
|
528
|
+
printStep(1, "LLM Provider");
|
|
529
|
+
printStatus("LLM", "Skipped (fallback phrases only)");
|
|
530
|
+
config.llm_api_key = null;
|
|
531
|
+
config.openrouter_api_key = null;
|
|
532
|
+
console.log("");
|
|
533
|
+
|
|
534
|
+
// Step 3: Download default voice packs
|
|
535
|
+
printStep(3, "Download voice packs");
|
|
536
|
+
mkdirSync(PACKS_DIR, { recursive: true });
|
|
537
|
+
|
|
538
|
+
const existingPackIds = new Set();
|
|
539
|
+
try {
|
|
540
|
+
for (const entry of readdirSync(PACKS_DIR, { withFileTypes: true })) {
|
|
541
|
+
if (entry.isDirectory() && existsSync(join(PACKS_DIR, entry.name, "pack.json"))) {
|
|
542
|
+
existingPackIds.add(entry.name);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
// PACKS_DIR may not exist yet
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const baseUrl = getPackRegistryBaseUrl();
|
|
550
|
+
for (const packId of DEFAULT_DOWNLOAD_PACK_IDS) {
|
|
551
|
+
if (existingPackIds.has(packId)) continue;
|
|
552
|
+
const pack = PACK_REGISTRY.find((p) => p.id === packId);
|
|
553
|
+
const label = pack ? pack.name : packId;
|
|
554
|
+
process.stdout.write(` Downloading ${label}... `);
|
|
555
|
+
try {
|
|
556
|
+
await downloadPack(packId, baseUrl);
|
|
557
|
+
console.log("done.");
|
|
558
|
+
} catch (err) {
|
|
559
|
+
console.log(`failed (${err.message}).`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
console.log("");
|
|
563
|
+
|
|
564
|
+
// Step 4: Voice — random
|
|
565
|
+
printStep(4, "Voice Pack");
|
|
566
|
+
config.active_pack = "random";
|
|
567
|
+
printStatus("Voice", "random");
|
|
568
|
+
console.log("");
|
|
569
|
+
|
|
570
|
+
// Step 5: TTS — detect and pick best available, skip verification
|
|
571
|
+
printStep(5, "TTS Server");
|
|
572
|
+
|
|
573
|
+
process.stdout.write(" Checking Chatterbox... ");
|
|
574
|
+
const chatterboxUp = await probeTtsBackend(config, "chatterbox");
|
|
575
|
+
console.log(chatterboxUp ? "detected!" : "not running");
|
|
576
|
+
|
|
577
|
+
process.stdout.write(" Checking Qwen TTS... ");
|
|
578
|
+
const qwenUp = await probeTtsBackend(config, "qwen");
|
|
579
|
+
console.log(qwenUp ? "detected!" : "not running");
|
|
580
|
+
|
|
581
|
+
if (qwenUp) {
|
|
582
|
+
config.tts_backend = "qwen";
|
|
583
|
+
} else if (chatterboxUp) {
|
|
584
|
+
config.tts_backend = "chatterbox";
|
|
585
|
+
} else {
|
|
586
|
+
config.tts_backend = config.tts_backend || "qwen";
|
|
587
|
+
}
|
|
588
|
+
printStatus("TTS", config.tts_backend + (qwenUp || chatterboxUp ? "" : " (not running — text notifications only)"));
|
|
589
|
+
console.log("");
|
|
590
|
+
|
|
591
|
+
// Step 6: Hooks — skip
|
|
592
|
+
printStep(6, "Hooks");
|
|
593
|
+
printStatus("Hooks", "Skipped (run 'voxlert setup' to install hooks later)");
|
|
594
|
+
console.log("");
|
|
595
|
+
|
|
596
|
+
// Save config
|
|
476
597
|
saveConfig(config);
|
|
477
598
|
|
|
478
|
-
//
|
|
599
|
+
// Summary
|
|
600
|
+
printSetupSummary(config, "skip", []);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function printSetupSummary(config, chosenProvider, selectedPlatforms) {
|
|
479
604
|
console.log("");
|
|
480
605
|
console.log(highlight("=== Setup Complete ==="));
|
|
481
606
|
console.log("");
|
package/src/tts-test.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { writeFileSync, unlinkSync, existsSync } from "fs";
|
|
1
|
+
import { writeFileSync, readFileSync, unlinkSync, existsSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { request as httpsRequest } from "https";
|
|
4
4
|
import { request as httpRequest } from "http";
|
|
5
|
-
import confirm from "@inquirer/confirm";
|
|
6
5
|
import select from "@inquirer/select";
|
|
7
6
|
import { playFile } from "./audio.js";
|
|
8
7
|
import { loadPack } from "./packs.js";
|
|
@@ -38,15 +37,17 @@ export function getTtsChoices(currentBackend) {
|
|
|
38
37
|
return [
|
|
39
38
|
{
|
|
40
39
|
name: currentBackend === "qwen"
|
|
41
|
-
? "Qwen TTS (recommended, current, more natural voice
|
|
42
|
-
: "Qwen TTS (recommended, more natural voice
|
|
40
|
+
? "Qwen TTS (recommended, current, more natural voice)"
|
|
41
|
+
: "Qwen TTS (recommended, more natural voice)",
|
|
43
42
|
value: "qwen",
|
|
43
|
+
description: `Setup docs: ${QWEN_DOCS_URL}`,
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
name: currentBackend === "chatterbox"
|
|
47
|
-
? "Chatterbox (current
|
|
48
|
-
: "Chatterbox
|
|
47
|
+
? "Chatterbox (current)"
|
|
48
|
+
: "Chatterbox",
|
|
49
49
|
value: "chatterbox",
|
|
50
|
+
description: `Setup docs: ${CHATTERBOX_DOCS_URL}`,
|
|
50
51
|
},
|
|
51
52
|
];
|
|
52
53
|
}
|
|
@@ -78,11 +79,69 @@ export function probeTtsBackend(config, backend, timeoutMs = 2000) {
|
|
|
78
79
|
});
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function
|
|
82
|
+
function _registerVoiceForTest(config, pack) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const voicePath = pack.voicePath;
|
|
85
|
+
const refText = pack.ref_text;
|
|
86
|
+
if (!voicePath || !existsSync(voicePath) || !refText) return resolve(null);
|
|
87
|
+
|
|
88
|
+
const qwenUrl = config.qwen_tts_url || "http://localhost:8100";
|
|
89
|
+
const endpoint = `${qwenUrl}/voices`;
|
|
90
|
+
|
|
91
|
+
let audioData;
|
|
92
|
+
try { audioData = readFileSync(voicePath); } catch { return resolve(null); }
|
|
93
|
+
|
|
94
|
+
const boundary = `----VoxlertBoundary${Date.now()}`;
|
|
95
|
+
const parts = [];
|
|
96
|
+
parts.push(Buffer.from(
|
|
97
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="ref_text"\r\n\r\n${refText}\r\n`,
|
|
98
|
+
));
|
|
99
|
+
parts.push(Buffer.from(
|
|
100
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="voice.wav"\r\n` +
|
|
101
|
+
`Content-Type: audio/wav\r\n\r\n`,
|
|
102
|
+
));
|
|
103
|
+
parts.push(audioData);
|
|
104
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
105
|
+
const body = Buffer.concat(parts);
|
|
106
|
+
|
|
107
|
+
const url = new URL(endpoint);
|
|
108
|
+
const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
109
|
+
const req = requestFn(endpoint, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
113
|
+
"Content-Length": body.length,
|
|
114
|
+
},
|
|
115
|
+
timeout: 15000,
|
|
116
|
+
}, (res) => {
|
|
117
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); return resolve(null); }
|
|
118
|
+
const chunks = [];
|
|
119
|
+
res.on("data", (c) => chunks.push(c));
|
|
120
|
+
res.on("end", () => {
|
|
121
|
+
try {
|
|
122
|
+
const result = JSON.parse(Buffer.concat(chunks).toString());
|
|
123
|
+
resolve(result.voice_id || null);
|
|
124
|
+
} catch { resolve(null); }
|
|
125
|
+
});
|
|
126
|
+
res.on("error", () => resolve(null));
|
|
127
|
+
});
|
|
128
|
+
req.on("error", () => resolve(null));
|
|
129
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
130
|
+
req.write(body);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function requestTtsAudio(config, backend, pack) {
|
|
136
|
+
let voiceId = null;
|
|
137
|
+
if (backend === "qwen") {
|
|
138
|
+
voiceId = await _registerVoiceForTest(config, pack);
|
|
139
|
+
}
|
|
140
|
+
|
|
82
141
|
return new Promise((resolve) => {
|
|
83
142
|
const endpoint = getTtsEndpoint(config, backend);
|
|
84
143
|
const body = backend === "qwen"
|
|
85
|
-
? { text: TTS_TEST_PHRASE,
|
|
144
|
+
? { text: TTS_TEST_PHRASE, ...(voiceId ? { voice_id: voiceId } : {}) }
|
|
86
145
|
: {
|
|
87
146
|
text: TTS_TEST_PHRASE,
|
|
88
147
|
voice_mode: "predefined",
|
|
@@ -157,26 +216,18 @@ export async function runTtsSample(config, backend) {
|
|
|
157
216
|
}
|
|
158
217
|
|
|
159
218
|
export async function chooseTtsBackend(config, { qwenUp, chatterboxUp }) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
default: config.tts_backend || "qwen",
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (qwenUp) {
|
|
169
|
-
printSuccess("Using Qwen TTS.");
|
|
170
|
-
return "qwen";
|
|
171
|
-
}
|
|
219
|
+
const detected = [qwenUp && "Qwen TTS", chatterboxUp && "Chatterbox"].filter(Boolean);
|
|
220
|
+
const hint = detected.length > 0
|
|
221
|
+
? `Detected: ${detected.join(", ")}. `
|
|
222
|
+
: "";
|
|
172
223
|
|
|
173
|
-
if (chatterboxUp) {
|
|
174
|
-
|
|
175
|
-
|
|
224
|
+
if (!qwenUp && !chatterboxUp) {
|
|
225
|
+
printStatus("Note", "Local TTS needs a GPU or Apple Silicon. Setup still works — you'll get text notifications until TTS is running.");
|
|
226
|
+
console.log("");
|
|
176
227
|
}
|
|
177
228
|
|
|
178
229
|
return select({
|
|
179
|
-
message:
|
|
230
|
+
message: `${hint}Choose the TTS backend. Qwen TTS is recommended for a more natural voice.`,
|
|
180
231
|
choices: getTtsChoices(config.tts_backend),
|
|
181
232
|
default: config.tts_backend || "qwen",
|
|
182
233
|
});
|
|
@@ -186,20 +237,31 @@ export async function verifyTtsSetup(config, backend) {
|
|
|
186
237
|
const label = getTtsLabel(backend);
|
|
187
238
|
const docsUrl = getTtsDocsUrl(backend);
|
|
188
239
|
|
|
240
|
+
const retryOrSkip = [
|
|
241
|
+
{ name: "I have set up the TTS server. Try again.", value: "retry" },
|
|
242
|
+
{ name: "Skip setup (you won't hear any voice!)", value: "skip" },
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
let attempt = 0;
|
|
189
246
|
while (true) {
|
|
247
|
+
attempt++;
|
|
248
|
+
if (attempt > 1) {
|
|
249
|
+
console.log("\n ── Retry #" + (attempt - 1) + " ──");
|
|
250
|
+
}
|
|
190
251
|
console.log("");
|
|
191
252
|
process.stdout.write(` Checking ${label}... `);
|
|
192
253
|
const backendUp = await probeTtsBackend(config, backend);
|
|
193
254
|
console.log(backendUp ? "detected!" : "not running");
|
|
194
255
|
|
|
195
256
|
if (!backendUp) {
|
|
196
|
-
printWarning(`${label} is not running yet
|
|
257
|
+
printWarning(`${label} is not running yet.`);
|
|
197
258
|
printStatus(`${label} docs`, docsUrl);
|
|
198
259
|
console.log("");
|
|
199
|
-
await
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
260
|
+
const action = await select({ message: "What would you like to do?", choices: retryOrSkip });
|
|
261
|
+
if (action === "skip") {
|
|
262
|
+
printWarning("Skipped TTS verification. Voice notifications won't work until the server is running.");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
203
265
|
continue;
|
|
204
266
|
}
|
|
205
267
|
|
|
@@ -208,36 +270,39 @@ export async function verifyTtsSetup(config, backend) {
|
|
|
208
270
|
console.log(ok ? "played." : "failed.");
|
|
209
271
|
|
|
210
272
|
if (!ok) {
|
|
211
|
-
printWarning(`The ${label} test failed
|
|
273
|
+
printWarning(`The ${label} test failed.`);
|
|
212
274
|
printStatus(`${label} docs`, docsUrl);
|
|
213
275
|
console.log("");
|
|
214
|
-
await
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
276
|
+
const action = await select({ message: "What would you like to do?", choices: retryOrSkip });
|
|
277
|
+
if (action === "skip") {
|
|
278
|
+
printWarning("Skipped TTS verification. Voice notifications won't work until the server is fixed.");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
218
281
|
continue;
|
|
219
282
|
}
|
|
220
283
|
|
|
221
284
|
const heardVoice = await select({
|
|
222
285
|
message: `Did you hear the ${label} voice test?`,
|
|
223
286
|
choices: [
|
|
224
|
-
{ name: "Yes", value:
|
|
225
|
-
{ name: "No", value:
|
|
287
|
+
{ name: "Yes", value: "yes" },
|
|
288
|
+
{ name: "No, try again", value: "retry" },
|
|
289
|
+
{ name: "Skip (you won't hear any voice!)", value: "skip" },
|
|
226
290
|
],
|
|
227
|
-
default: true,
|
|
228
291
|
});
|
|
229
292
|
|
|
230
|
-
if (heardVoice) {
|
|
293
|
+
if (heardVoice === "yes") {
|
|
231
294
|
printSuccess(`${label} verified.`);
|
|
232
295
|
return;
|
|
233
296
|
}
|
|
234
297
|
|
|
235
|
-
|
|
298
|
+
if (heardVoice === "skip") {
|
|
299
|
+
printWarning("Skipped TTS verification. Voice notifications won't work until the server is fixed.");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
printWarning("Still not working? Local TTS requires specific hardware (Apple Silicon or NVIDIA GPU).");
|
|
304
|
+
printStatus("Setup help", "https://github.com/settinghead/voxlert/discussions/6");
|
|
236
305
|
printStatus(`${label} docs`, docsUrl);
|
|
237
306
|
console.log("");
|
|
238
|
-
await confirm({
|
|
239
|
-
message: "Press enter to play the test again.",
|
|
240
|
-
default: true,
|
|
241
|
-
});
|
|
242
307
|
}
|
|
243
308
|
}
|