@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/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 = 92) {
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 || "chatterbox";
102
- const voiceLabel = config.active_pack || "sc2-adjutant";
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(92), ANSI.dim);
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), 92);
119
+ return centerLine(animatedLogoLine(line, phase, shimmerIndex), 59);
120
120
  });
121
121
  return [
122
122
  "",
123
123
  rule,
124
124
  ...logo,
125
- centerLine(glow, 92),
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
- let url;
42
- let options;
43
-
44
- const base = provider.baseUrl.replace(/\/+$/, "");
45
-
46
- if (provider.format === "anthropic") {
47
- // Anthropic: POST to /v1/messages with a tiny request
48
- url = new URL(`${base}/v1/messages`);
49
- const authHeaders = provider.authHeader(apiKey);
50
- const payload = JSON.stringify({
51
- model: provider.defaultModel,
52
- max_tokens: 1,
53
- messages: [{ role: "user", content: "hi" }],
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
- req.on("error", (err) => resolve({ ok: false, error: err.message }));
74
- req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
75
- req.write(payload);
76
- req.end();
77
- } else {
78
- // OpenAI-compatible: GET /models
79
- url = new URL(`${base}/models`);
80
- const authHeaders = provider.authHeader(apiKey);
81
- options = {
82
- method: "GET",
83
- headers: { ...authHeaders },
84
- timeout: 8000,
85
- };
86
- const reqFn = url.protocol === "https:" ? https.request : http.request;
87
- const req = reqFn(url, options, (res) => {
88
- let data = "";
89
- res.on("data", (chunk) => (data += chunk));
90
- res.on("end", () => {
91
- if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
92
- if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
93
- resolve({ ok: res.statusCode >= 200 && res.statusCode < 300 });
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
- req.on("error", (err) => resolve({ ok: false, error: err.message }));
97
- req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
98
- req.end();
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 config = loadConfig();
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? (downloaded from GitHub)",
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 a voice pack:",
395
+ message: "Choose default voice:",
366
396
  choices: packChoices,
367
- default: active || "sc2-adjutant",
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 (port 8004)... ");
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 (port 8100)... ");
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
- // --- Save config ---
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
- // --- Summary ---
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, port 8100)"
42
- : "Qwen TTS (recommended, more natural voice, port 8100)",
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, port 8004)"
48
- : "Chatterbox (port 8004)",
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 requestTtsAudio(config, backend, pack) {
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, pack_id: pack.id || "_default" }
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
- if (qwenUp && chatterboxUp) {
161
- return select({
162
- message: "Both TTS servers detected. Which one to use? Qwen TTS is recommended for a more natural voice.",
163
- choices: getTtsChoices(config.tts_backend),
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
- printSuccess("Using Chatterbox.");
175
- return "chatterbox";
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: "Choose the TTS backend you are setting up. Qwen TTS is recommended for a more natural voice.",
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. Finish that setup and come back here to try again.`);
257
+ printWarning(`${label} is not running yet.`);
197
258
  printStatus(`${label} docs`, docsUrl);
198
259
  console.log("");
199
- await confirm({
200
- message: `Press enter after ${label} is running to test again.`,
201
- default: true,
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. Keep the docs open, fix the server, and try again.`);
273
+ printWarning(`The ${label} test failed.`);
212
274
  printStatus(`${label} docs`, docsUrl);
213
275
  console.log("");
214
- await confirm({
215
- message: `Press enter to retry the ${label} test.`,
216
- default: true,
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: true },
225
- { name: "No", value: false },
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
- printWarning("No workaround here. Keep troubleshooting and retry until you hear the voice.");
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
  }