@settinghead/voxlert 0.3.5 → 0.3.6

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <a href="https://youtu.be/-aiSZnGNyE4">
2
+ <a href="https://youtu.be/5xFXGijwJuk?utm_source=github&utm_medium=readme&utm_campaign=phase1">
3
3
  <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/demo-thumbnail.png" alt="Voxlert Demo" width="100%" />
4
4
  </a>
5
5
  </p>
@@ -20,10 +20,23 @@ Existing notification chimes (like [peon-ping](https://github.com/PeonPing/peon-
20
20
 
21
21
  Voxlert makes each session speak in a distinct character voice with its own tone and vocabulary. You hear *"Query efficiency restored to nominal"* from the HEV Suit in one window and *"Pathetic test suite for code validation processed"* from SHODAN in another, and you know immediately what changed. Because phrases are generated by an LLM instead of picked from a tiny fixed set, they stay varied instead of becoming wallpaper.
22
22
 
23
+ ## Who this is for
24
+
25
+ Voxlert is for users who:
26
+
27
+ - Run two or more AI coding agent sessions concurrently (Claude Code, Cursor, Codex, OpenClaw)
28
+ - Get interrupted by notification chimes but can't tell which window needs attention
29
+ - Want ambient audio feedback that doesn't require looking at a screen
30
+ - Are comfortable installing local tooling (Node.js, optionally Python for TTS)
31
+
32
+ If you run a single agent session and it's always in focus, Voxlert adds personality but not much utility. If you run several at once and context-switch between them, it's meaningfully useful.
33
+
23
34
  ## Quick Start
24
35
 
25
36
  ### 1. Install prerequisites
26
37
 
38
+ **Minimum:** Node.js 18+ and `afplay` (macOS built-in) or [FFmpeg](docs/installing-ffmpeg.md) (Windows/Linux). That's enough to get started; TTS and SoX are optional.
39
+
27
40
  | Aspect | macOS | Windows | Linux |
28
41
  |--------|-------|---------|-------|
29
42
  | **Node.js 18+** | [nodejs.org](https://nodejs.org) or `brew install node` | [nodejs.org](https://nodejs.org) or `winget install OpenJS.NodeJS` | [nodejs.org](https://nodejs.org) or distro package (for example `sudo apt install nodejs`) |
@@ -44,6 +57,8 @@ You will also want:
44
57
 
45
58
  The setup wizard auto-detects running TTS backends. If none are running yet, setup still completes, but you will only get text notifications and fallback phrases until you start one and rerun setup.
46
59
 
60
+ > **Can't run local TTS?** Both backends require a GPU or Apple Silicon. If that's a blocker, [request early access to Pipevox](https://settinghead.github.io/pipevox-signup) — a hosted option that needs no local TTS setup.
61
+
47
62
  ### 2. Install and run setup
48
63
 
49
64
  ```bash
@@ -107,13 +122,16 @@ npm run changeset
107
122
 
108
123
  ## Supported Voices
109
124
 
125
+ The `sc1-adjutant` preview below uses the animated in-game portrait GIF from `assets/sc1-adjutant.gif`.
126
+
110
127
  | | Pack ID | Voice | Source | Status |
111
128
  |---|---------|-------|--------|--------|
112
129
  | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/sc1-adjutant.gif" width="48" height="48" /> | `sc1-adjutant` | **SC1 Adjutant** | StarCraft | ✅ Available |
113
130
  | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/sc2-adjutant.jpg" width="48" height="48" /> | `sc2-adjutant` | **SC2 Adjutant** | StarCraft II | ✅ Available |
114
131
  | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/red-alert-eva.png" width="48" height="48" /> | `red-alert-eva` | **EVA** | Command & Conquer: Red Alert | ✅ Available |
115
132
  | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/sc1-kerrigan.gif" width="48" height="48" /> | `sc1-kerrigan` | **SC1 Kerrigan** | StarCraft | ✅ Available |
116
- | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/sc2-kerrigan.jpg" width="48" height="48" /> | `sc2-kerrigan` | **SC2 Kerrigan** | StarCraft II | ✅ Available |
133
+ | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/sc1-kerrigan-infested.jpg" width="48" height="48" /> | `sc1-kerrigan-infested` | **SC1 Infested Kerrigan** | StarCraft | ✅ Available |
134
+ | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/sc2-kerrigan-infested.jpg" width="48" height="48" /> | `sc2-kerrigan-infested` | **SC2 Infested Kerrigan** | StarCraft II | ✅ Available |
117
135
  | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/sc1-protoss-advisor.jpg" width="48" height="48" /> | `sc1-protoss-advisor` | **Protoss Advisor** | StarCraft | ✅ Available |
118
136
  | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/ss1-shodan.png" width="48" height="48" /> | `ss1-shodan` | **SHODAN** | System Shock | ✅ Available |
119
137
  | <img src="https://raw.githubusercontent.com/settinghead/voxlert/main/assets/hl-hev-suit.png" width="48" height="48" /> | `hl-hev-suit` | **HEV Suit** | Half-Life | ✅ Available |
@@ -236,8 +254,8 @@ Run `voxlert config path` to find `config.json`. You can edit it directly or use
236
254
  | `openrouter_api_key` | string \| null | `null` | Legacy alias used when `llm_backend` is `openrouter` and `llm_api_key` is empty |
237
255
  | `openrouter_model` | string \| null | `null` | Legacy alias used when `llm_model` is empty and backend is `openrouter` |
238
256
  | `chatterbox_url` | string | `"http://localhost:8004"` | Chatterbox TTS server URL |
239
- | `tts_backend` | string | `"chatterbox"` | TTS backend: `chatterbox` or `qwen` |
240
- | `active_pack` | string | `"sc2-adjutant"` | Active voice pack ID |
257
+ | `tts_backend` | string | `"qwen"` | TTS backend: `qwen` or `chatterbox` |
258
+ | `active_pack` | string | `"sc1-kerrigan-infested"` | Active voice pack ID |
241
259
  | `volume` | number | `1.0` | Playback volume (0.0-1.0) |
242
260
  | `categories` | object | — | Per-category enable/disable settings |
243
261
  | `logging` | boolean | `true` | Activity log in `~/.voxlert/voxlert.log` |
@@ -348,6 +366,12 @@ See [Creating Voice Packs](docs/creating-voice-packs.md) for building your own c
348
366
 
349
367
  - **Protoss Advisor** voice pack inspired by [openclaw/protoss-voice](https://playbooks.com/skills/openclaw/skills/protoss-voice)
350
368
 
369
+ ## Considering a hosted version?
370
+
371
+ If the local TTS setup is a blocker, [vote or comment in this Discussion](https://github.com/settinghead/voxlert/discussions/5). A hosted API (no local Python or model required) is on the roadmap if demand is there.
372
+
373
+ Having trouble with setup? Post in the [Setup help & troubleshooting Discussion](https://github.com/settinghead/voxlert/discussions/6).
374
+
351
375
  ## License
352
376
 
353
377
  MIT - see [LICENSE](LICENSE).
Binary file
Binary file
Binary file
@@ -13,9 +13,9 @@
13
13
  "timeout": 15000
14
14
  },
15
15
  "chatterbox_url": "http://localhost:8004",
16
- "tts_backend": "chatterbox",
16
+ "tts_backend": "qwen",
17
17
  "qwen_tts_url": "http://localhost:8100",
18
- "active_pack": "sc2-adjutant",
18
+ "active_pack": "sc1-kerrigan-infested",
19
19
  "volume": 1.0,
20
20
  "logging": true,
21
21
  "error_log": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@settinghead/voxlert",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "LLM-generated voice notifications for Claude Code, Cursor, OpenAI Codex, and OpenClaw, spoken by game characters like the StarCraft Adjutant, Kerrigan, C&C EVA, SHODAN, and more.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,69 +1,68 @@
1
1
  {
2
2
  "name": "StarCraft 1 Kerrigan",
3
- "overlay_colors": [[0.5, 0.1, 0.6], [0.3, 0.0, 0.4]],
3
+ "overlay_colors": [[0.4, 0.5, 0.7], [0.2, 0.3, 0.5]],
4
4
  "voice": "voice.wav",
5
5
  "echo": false,
6
6
  "llm_temperature": 0.85,
7
- "style": "Sharp, commanding, cunning. Lethally efficient. Mix military precision with predatory superiority.",
7
+ "style": "Sharp, commanding, cunning. A Ghost operative military precision with dry wit. Confident and no-nonsense.",
8
8
  "examples": [
9
9
  {
10
10
  "content": "Fixed auth bug",
11
11
  "format": "status-report",
12
- "response": "Auth weakness for user access eliminated."
12
+ "response": "Auth weakness patched. Won't happen again."
13
13
  },
14
14
  {
15
15
  "content": "Switched TTS endpoint from /v1/audio/speech to /tts",
16
16
  "format": "status-report",
17
- "response": "Voice relay for swarm communications rerouted."
17
+ "response": "Comm relay rerouted. Signal's clean now."
18
18
  },
19
19
  {
20
20
  "content": "Added module-level external store using useSyncExternalStore for React re-renders",
21
21
  "format": "status-report",
22
- "response": "State dominion for render control seized."
22
+ "response": "State control locked in. Renders fall in line."
23
23
  }
24
24
  ],
25
25
  "fallback_phrases": {
26
26
  "session.start": [
27
- "The swarm awakens",
28
- "I am here",
29
- "Ready to evolve",
30
- "The hive stirs",
31
- "Awaiting your command"
27
+ "Kerrigan here",
28
+ "I've got a job to do",
29
+ "Ready for orders",
30
+ "Ghost reporting",
31
+ "Standing by"
32
32
  ],
33
33
  "task.complete": [
34
- "It is done",
35
- "The swarm has delivered",
36
- "Objective consumed",
37
- "Evolution complete",
38
- "Another victory claimed",
39
- "Mission accomplished commander"
34
+ "Job's done",
35
+ "Target neutralized",
36
+ "Mission complete",
37
+ "That's a wrap",
38
+ "Objective secured"
40
39
  ],
41
40
  "task.acknowledge": [
42
- "I hear you",
43
- "The swarm obeys",
41
+ "Copy that",
42
+ "Understood",
44
43
  "Acknowledged",
45
- "As you wish",
44
+ "On it",
46
45
  "Consider it done"
47
46
  ],
48
47
  "input.required": [
49
- "Speak your mind",
50
- "The swarm awaits your command",
51
- "What would you have me do",
52
- "Awaiting your directive",
53
- "Choose carefully"
48
+ "What's the play",
49
+ "Waiting on your call",
50
+ "Your orders",
51
+ "Speak up",
52
+ "I'm listening"
54
53
  ],
55
54
  "resource.limit": [
56
- "We require more resources",
57
- "The swarm hungers",
58
- "Our forces are stretched thin",
59
- "Reserves nearly depleted",
60
- "We need more time"
55
+ "We're running low",
56
+ "Resources are tight",
57
+ "Need more to work with",
58
+ "Supplies are thin",
59
+ "We're stretched"
61
60
  ],
62
61
  "notification": [
63
- "Something stirs",
64
- "I sense a disturbance",
65
- "Alert commander"
62
+ "Heads up",
63
+ "Something's coming",
64
+ "Eyes open"
66
65
  ]
67
66
  },
68
- "ref_text": "You may have time to play games, but I've got a job to do. Ahh! Yes, Cerebrate? What is it now? I'm listening."
67
+ "ref_text": "You may have time to play games, but I've got a job to do."
69
68
  }
Binary file
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "StarCraft 1 Infested Kerrigan",
3
+ "overlay_colors": [[0.5, 0.1, 0.6], [0.3, 0.0, 0.4]],
4
+ "voice": "voice.wav",
5
+ "echo": true,
6
+ "llm_temperature": 0.9,
7
+ "style": "Dark, predatory, commanding. Speaks as the Queen of Blades — cold superiority laced with alien menace. Mix swarm metaphors with lethal authority.",
8
+ "examples": [
9
+ {
10
+ "content": "Fixed auth bug",
11
+ "format": "status-report",
12
+ "response": "Auth weakness consumed by the swarm."
13
+ },
14
+ {
15
+ "content": "Switched TTS endpoint from /v1/audio/speech to /tts",
16
+ "format": "status-report",
17
+ "response": "Voice relay rerouted through the hivemind."
18
+ },
19
+ {
20
+ "content": "Added module-level external store using useSyncExternalStore for React re-renders",
21
+ "format": "status-report",
22
+ "response": "State control assimilated into the swarm."
23
+ }
24
+ ],
25
+ "fallback_phrases": {
26
+ "session.start": [
27
+ "The swarm is eternal",
28
+ "I have returned",
29
+ "All shall serve the swarm",
30
+ "The hivemind awakens",
31
+ "Evolution begins anew"
32
+ ],
33
+ "task.complete": [
34
+ "It is finished",
35
+ "The swarm delivers",
36
+ "All has been consumed",
37
+ "Evolution is complete",
38
+ "The cycle ends",
39
+ "My will made manifest"
40
+ ],
41
+ "task.acknowledge": [
42
+ "The hivemind hears all",
43
+ "Your will aligns with mine",
44
+ "So be it",
45
+ "The swarm answers",
46
+ "I have foreseen this"
47
+ ],
48
+ "input.required": [
49
+ "Speak mortal",
50
+ "The swarm awaits",
51
+ "Choose your path carefully",
52
+ "Your decision is required",
53
+ "Even queens must listen"
54
+ ],
55
+ "resource.limit": [
56
+ "The swarm hungers for more",
57
+ "Our essence grows thin",
58
+ "More must be consumed",
59
+ "Resources are finite even for gods",
60
+ "The hive requires sustenance"
61
+ ],
62
+ "notification": [
63
+ "I sense it",
64
+ "The hivemind stirs",
65
+ "Something approaches"
66
+ ]
67
+ },
68
+ "ref_text": "Yes, Cerebrate? What is it now? I'm listening."
69
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "StarCraft 2 Kerrigan",
2
+ "name": "StarCraft 2 Infested Kerrigan",
3
3
  "overlay_colors": [[0.5, 0.1, 0.6], [0.3, 0.0, 0.4]],
4
4
  "voice": "voice.wav",
5
5
  "echo": true,
package/src/audio.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  statSync,
10
10
  utimesSync,
11
11
  } from "fs";
12
- import { join } from "path";
12
+ import { join, dirname, extname } from "path";
13
13
  import { createHash } from "crypto";
14
14
  import { spawn, execSync } from "child_process";
15
15
  import { request as httpsRequest } from "https";
@@ -18,6 +18,94 @@ import { CACHE_DIR, QUEUE_DIR, LOCK_FILE } from "./paths.js";
18
18
 
19
19
  const DEFAULT_MAX_CACHE = 150;
20
20
 
21
+ // voice_id cache: voicePath -> voice_id (avoids re-uploading every call)
22
+ const _voiceIdCache = new Map();
23
+
24
+ function _buildMultipart(fields, files) {
25
+ const boundary = `----VoxlertBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
26
+ const parts = [];
27
+ for (const [name, value] of Object.entries(fields)) {
28
+ parts.push(Buffer.from(
29
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
30
+ ));
31
+ }
32
+ for (const { name, filename, contentType, data } of files) {
33
+ parts.push(Buffer.from(
34
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
35
+ `Content-Type: ${contentType}\r\n\r\n`,
36
+ ));
37
+ parts.push(data);
38
+ parts.push(Buffer.from("\r\n"));
39
+ }
40
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
41
+ return { boundary, body: Buffer.concat(parts) };
42
+ }
43
+
44
+ function registerVoiceWithQwen(config, voicePath, refText) {
45
+ if (_voiceIdCache.has(voicePath)) {
46
+ return Promise.resolve(_voiceIdCache.get(voicePath));
47
+ }
48
+ if (!voicePath || !existsSync(voicePath) || !refText) {
49
+ return Promise.resolve(null);
50
+ }
51
+ return new Promise((resolve) => {
52
+ const qwenUrl = config.qwen_tts_url || "http://localhost:8100";
53
+ const endpoint = `${qwenUrl}/voices`;
54
+
55
+ let audioData;
56
+ try {
57
+ audioData = readFileSync(voicePath);
58
+ } catch {
59
+ return resolve(null);
60
+ }
61
+
62
+ const { boundary, body } = _buildMultipart(
63
+ { ref_text: refText },
64
+ [{ name: "audio", filename: "voice.wav", contentType: "audio/wav", data: audioData }],
65
+ );
66
+
67
+ const url = new URL(endpoint);
68
+ const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
69
+
70
+ const req = requestFn(
71
+ endpoint,
72
+ {
73
+ method: "POST",
74
+ headers: {
75
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
76
+ "Content-Length": body.length,
77
+ },
78
+ timeout: 15000,
79
+ },
80
+ (res) => {
81
+ if (res.statusCode < 200 || res.statusCode >= 300) {
82
+ res.resume();
83
+ return resolve(null);
84
+ }
85
+ const chunks = [];
86
+ res.on("data", (chunk) => chunks.push(chunk));
87
+ res.on("end", () => {
88
+ try {
89
+ const result = JSON.parse(Buffer.concat(chunks).toString());
90
+ if (result.voice_id) {
91
+ _voiceIdCache.set(voicePath, result.voice_id);
92
+ }
93
+ resolve(result.voice_id || null);
94
+ } catch {
95
+ resolve(null);
96
+ }
97
+ });
98
+ res.on("error", () => resolve(null));
99
+ },
100
+ );
101
+
102
+ req.on("error", () => resolve(null));
103
+ req.on("timeout", () => { req.destroy(); resolve(null); });
104
+ req.write(body);
105
+ req.end();
106
+ });
107
+ }
108
+
21
109
  function evictCache(cacheDir, maxEntries) {
22
110
  let files;
23
111
  try {
@@ -250,12 +338,14 @@ function downloadChatterbox(phrase, cachePath, config, voicePath, ttsParams) {
250
338
  });
251
339
  }
252
340
 
253
- function downloadQwen(phrase, cachePath, config, packId) {
341
+ function downloadQwen(phrase, cachePath, config, voiceId) {
254
342
  return new Promise((resolve) => {
255
343
  const qwenUrl = config.qwen_tts_url || "http://localhost:8100";
256
344
  const endpoint = `${qwenUrl}/tts`;
257
345
 
258
- const payload = JSON.stringify({ text: phrase, pack_id: packId });
346
+ const body = { text: phrase };
347
+ if (voiceId) body.voice_id = voiceId;
348
+ const payload = JSON.stringify(body);
259
349
 
260
350
  const url = new URL(endpoint);
261
351
  const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
@@ -299,9 +389,10 @@ function downloadQwen(phrase, cachePath, config, packId) {
299
389
  });
300
390
  }
301
391
 
302
- function downloadToCache(phrase, cachePath, config, voicePath, ttsParams, packId) {
392
+ async function downloadToCache(phrase, cachePath, config, voicePath, ttsParams, packId, refText) {
303
393
  if (config.tts_backend === "qwen") {
304
- return downloadQwen(phrase, cachePath, config, packId);
394
+ const voiceId = await registerVoiceWithQwen(config, voicePath, refText);
395
+ return downloadQwen(phrase, cachePath, config, voiceId);
305
396
  }
306
397
  return downloadChatterbox(phrase, cachePath, config, voicePath, ttsParams);
307
398
  }
@@ -329,8 +420,12 @@ function normalizeVolume(cachePath) {
329
420
  function postProcess(cachePath, command) {
330
421
  if (!command || !existsSync(cachePath)) return;
331
422
  const tmpOut = cachePath + ".tmp.wav";
332
- // Replace $INPUT and $OUTPUT placeholders in command
333
- const cmd = command.replace(/\$INPUT/g, cachePath).replace(/\$OUTPUT/g, tmpOut);
423
+ // Support both $INPUT and ${INPUT} placeholder styles.
424
+ const cmd = command
425
+ .replace(/\$\{INPUT\}/g, cachePath)
426
+ .replace(/\$INPUT/g, cachePath)
427
+ .replace(/\$\{OUTPUT\}/g, tmpOut)
428
+ .replace(/\$OUTPUT/g, tmpOut);
334
429
  try {
335
430
  execSync(cmd, { timeout: 15000, stdio: "ignore" });
336
431
  if (existsSync(tmpOut)) {
@@ -343,6 +438,66 @@ function postProcess(cachePath, command) {
343
438
  }
344
439
  }
345
440
 
441
+ function convertAudioFile(inputPath, outputPath) {
442
+ const ext = extname(outputPath).toLowerCase();
443
+ try {
444
+ if (ext === ".mp3") {
445
+ execSync(
446
+ `ffmpeg -y -i "${inputPath}" -ar 44100 -ac 1 -c:a libmp3lame -b:a 96k "${outputPath}"`,
447
+ { timeout: 15000, stdio: "ignore" },
448
+ );
449
+ return true;
450
+ }
451
+
452
+ execSync(
453
+ `ffmpeg -y -i "${inputPath}" -ar 44100 -ac 1 -c:a pcm_s16le "${outputPath}"`,
454
+ { timeout: 15000, stdio: "ignore" },
455
+ );
456
+ return true;
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+
462
+ export async function renderPhraseToFile(phrase, outputPath, config, pack) {
463
+ const outDir = dirname(outputPath);
464
+ mkdirSync(outDir, { recursive: true });
465
+
466
+ const tempBase = join(outDir, `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
467
+ const rawPath = `${tempBase}.raw.wav`;
468
+ const workingPath = `${tempBase}.wav`;
469
+ const packId = (pack && pack.id) || "_default";
470
+ const voicePath = (pack && pack.voicePath) || config.voice || "default.wav";
471
+ const ttsParams = pack ? pack.tts_params : null;
472
+ const refText = (pack && pack.ref_text) || null;
473
+ const customAudioFilter = (pack && pack.audio_filter) || null;
474
+ const postProcessCmd = (pack && pack.post_process) || null;
475
+ const echo = pack ? pack.echo !== false : true;
476
+
477
+ try {
478
+ await downloadToCache(phrase, rawPath, config, voicePath, ttsParams, packId, refText);
479
+ if (!existsSync(rawPath)) return false;
480
+
481
+ renameSync(rawPath, workingPath);
482
+
483
+ if (postProcessCmd) postProcess(workingPath, postProcessCmd);
484
+ if (customAudioFilter || echo) applyEcho(workingPath, customAudioFilter);
485
+ normalizeVolume(workingPath);
486
+
487
+ return convertAudioFile(workingPath, outputPath);
488
+ } finally {
489
+ for (const path of [rawPath, workingPath]) {
490
+ if (existsSync(path)) {
491
+ try {
492
+ unlinkSync(path);
493
+ } catch {
494
+ // ignore cleanup failures
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
500
+
346
501
  // --- Public API ---
347
502
 
348
503
  export async function speakPhrase(phrase, config, pack) {
@@ -351,7 +506,7 @@ export async function speakPhrase(phrase, config, pack) {
351
506
  mkdirSync(packCacheDir, { recursive: true });
352
507
 
353
508
  const ttsParams = pack ? pack.tts_params : null;
354
- const backend = config.tts_backend || "chatterbox";
509
+ const backend = config.tts_backend || "qwen";
355
510
  const cacheKey = createHash("md5")
356
511
  .update(backend + ":" + phrase.toLowerCase() + (ttsParams ? JSON.stringify(ttsParams) : ""))
357
512
  .digest("hex");
@@ -360,6 +515,7 @@ export async function speakPhrase(phrase, config, pack) {
360
515
  const maxCache = config.max_cache_entries ?? DEFAULT_MAX_CACHE;
361
516
  const echo = pack ? pack.echo !== false : true;
362
517
  const voicePath = (pack && pack.voicePath) || config.voice || "default.wav";
518
+ const refText = (pack && pack.ref_text) || null;
363
519
  const customAudioFilter = (pack && pack.audio_filter) || null;
364
520
  const postProcessCmd = (pack && pack.post_process) || null;
365
521
 
@@ -367,8 +523,8 @@ export async function speakPhrase(phrase, config, pack) {
367
523
  if (existsSync(cachePath)) {
368
524
  touchFile(cachePath);
369
525
  } else {
370
- await downloadToCache(phrase, cachePath, config, voicePath, ttsParams, packId);
371
- if (!existsSync(cachePath)) return; // download failed
526
+ await downloadToCache(phrase, cachePath, config, voicePath, ttsParams, packId, refText);
527
+ if (!existsSync(cachePath)) return false; // download failed
372
528
  if (postProcessCmd) postProcess(cachePath, postProcessCmd);
373
529
  if (customAudioFilter || echo) applyEcho(cachePath, customAudioFilter);
374
530
  normalizeVolume(cachePath);
package/src/cli.js CHANGED
@@ -23,7 +23,22 @@ function createHelpText() {
23
23
 
24
24
  async function maybeRunSetup(command) {
25
25
  if (command.skipSetupWizard || existsSync(STATE_DIR)) return false;
26
- console.log("Welcome to Voxlert! Let's get you set up.\n");
26
+ console.log("Welcome to Voxlert! First time here?\n");
27
+ const select = (await import("@inquirer/select")).default;
28
+ const action = await select({
29
+ message: "What would you like to do?",
30
+ choices: [
31
+ { name: "Run setup", value: "setup", description: "Configure LLM, voice packs, TTS, and hooks" },
32
+ { name: "Show command list", value: "help", description: "See all available commands" },
33
+ ],
34
+ default: "setup",
35
+ });
36
+ if (action === "help") {
37
+ console.log("");
38
+ console.log(createHelpText());
39
+ return true;
40
+ }
41
+ console.log("");
27
42
  const { runSetup } = await import("./setup.js");
28
43
  await runSetup();
29
44
  return true;
@@ -37,13 +37,22 @@ export async function testPipeline(text, pack) {
37
37
  showOverlay(phrase, {
38
38
  category: "notification",
39
39
  packName: activePack.name,
40
- packId: activePack.id || (config.active_pack || "sc2-adjutant"),
40
+ packId: activePack.id || (config.active_pack || "sc1-kerrigan-infested"),
41
41
  prefix: "Test",
42
42
  config,
43
43
  overlayColors: activePack.overlay_colors,
44
44
  });
45
- await speakPhrase(phrase, config, activePack);
46
- console.log("Done.");
45
+ const ok = await speakPhrase(phrase, config, activePack);
46
+ if (ok === false) {
47
+ console.log("");
48
+ console.log("TTS failed — no audio was produced.");
49
+ console.log("Make sure your TTS server is running (see: voxlert setup).");
50
+ console.log("");
51
+ console.log("Can't run local TTS? Request hosted access:");
52
+ console.log(" https://settinghead.github.io/pipevox-signup");
53
+ } else {
54
+ console.log("Done.");
55
+ }
47
56
  }
48
57
 
49
58
  export function packList() {
package/src/cost.js CHANGED
@@ -99,7 +99,10 @@ export async function formatCost() {
99
99
  totalTokens += e.total_tokens || 0;
100
100
  }
101
101
 
102
- const lines = ["Usage Summary", "=".repeat(40)];
102
+ const earliest = entries.reduce((min, e) => e.timestamp && e.timestamp < min ? e.timestamp : min, entries[0].timestamp || "");
103
+ const sinceLabel = earliest ? `Since ${new Date(earliest).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}` : "";
104
+
105
+ const lines = ["Usage Summary" + (sinceLabel ? ` (${sinceLabel})` : ""), "=".repeat(40)];
103
106
 
104
107
  let grandCost = 0;
105
108
  let totalEstimated = 0;
@@ -13,7 +13,8 @@ export const PACK_REGISTRY = [
13
13
  { id: "sc1-adjutant", name: "StarCraft 1 Adjutant" },
14
14
  { id: "red-alert-eva", name: "Red Alert EVA" },
15
15
  { id: "sc1-kerrigan", name: "SC1 Kerrigan" },
16
- { id: "sc2-kerrigan", name: "SC2 Kerrigan" },
16
+ { id: "sc1-kerrigan-infested", name: "SC1 Infested Kerrigan" },
17
+ { id: "sc2-kerrigan-infested", name: "SC2 Infested Kerrigan" },
17
18
  { id: "sc1-protoss-advisor", name: "Protoss Advisor (SC1)" },
18
19
  { id: "sc2-protoss-advisor", name: "Protoss Advisor (SC2)" },
19
20
  { id: "ss1-shodan", name: "SHODAN" },
package/src/packs.js CHANGED
@@ -146,6 +146,7 @@ export function loadPack(config) {
146
146
  voicePath,
147
147
  volumeOffsetDb: getVolumeOffsetDb(voicePath, packId),
148
148
  tts_params: packData.tts_params || null,
149
+ ref_text: packData.ref_text || null,
149
150
  audio_filter: packData.audio_filter || null,
150
151
  post_process: packData.post_process || null,
151
152
  style: packData.style || packData.system_prompt || null,
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
  }
@@ -184,6 +186,21 @@ export async function runSetup() {
184
186
  mkdirSync(CACHE_DIR, { recursive: true });
185
187
 
186
188
  const config = loadConfig();
189
+
190
+ // Save partial progress on Ctrl+C so completed steps are preserved
191
+ const savePartial = () => {
192
+ try {
193
+ saveConfig(config);
194
+ console.log("");
195
+ printWarning("Setup interrupted — progress saved. Run 'voxlert setup' to resume.");
196
+ console.log("");
197
+ } catch {
198
+ // ignore write errors during exit
199
+ }
200
+ };
201
+ process.on("SIGINT", () => { savePartial(); process.exit(130); });
202
+
203
+ try {
187
204
  const currentBackend = config.llm_backend || "openrouter";
188
205
  const currentProvider = getProvider(currentBackend);
189
206
  const currentModel = config.llm_model || currentProvider?.defaultModel || "default";
@@ -238,7 +255,7 @@ export async function runSetup() {
238
255
  ? `${existingKey.slice(0, 4)}…${existingKey.slice(-4)}`
239
256
  : "";
240
257
 
241
- apiKey = await input({
258
+ apiKey = (await input({
242
259
  message: "Paste your API key:",
243
260
  default: existingKey || undefined,
244
261
  transformer: (val) => {
@@ -247,7 +264,7 @@ export async function runSetup() {
247
264
  if (val.length <= 8) return "****";
248
265
  return val.slice(0, 4) + "…" + val.slice(-4);
249
266
  },
250
- });
267
+ })).trim();
251
268
 
252
269
  if (apiKey) {
253
270
  process.stdout.write(" Validating key... ");
@@ -323,7 +340,7 @@ export async function runSetup() {
323
340
  }));
324
341
 
325
342
  const toDownload = await checkbox({
326
- message: "Which voice packs do you want to install? (downloaded from GitHub)",
343
+ message: "Which voice packs do you want to install? (space = toggle, enter = confirm)",
327
344
  choices: packChoices,
328
345
  required: false,
329
346
  });
@@ -362,9 +379,9 @@ export async function runSetup() {
362
379
  ];
363
380
 
364
381
  const chosenPack = await select({
365
- message: "Choose a voice pack:",
382
+ message: "Choose default voice:",
366
383
  choices: packChoices,
367
- default: active || "sc2-adjutant",
384
+ default: active || "random",
368
385
  });
369
386
  config.active_pack = chosenPack;
370
387
  } else {
@@ -378,15 +395,13 @@ export async function runSetup() {
378
395
 
379
396
  printStatus("Recommended", "Qwen TTS for a more natural voice");
380
397
  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
398
  console.log("");
384
399
 
385
- process.stdout.write(" Checking Chatterbox (port 8004)... ");
400
+ process.stdout.write(" Checking Chatterbox... ");
386
401
  const chatterboxUp = await probeTtsBackend(config, "chatterbox");
387
402
  console.log(chatterboxUp ? "detected!" : "not running");
388
403
 
389
- process.stdout.write(" Checking Qwen TTS (port 8100)... ");
404
+ process.stdout.write(" Checking Qwen TTS... ");
390
405
  const qwenUp = await probeTtsBackend(config, "qwen");
391
406
  console.log(qwenUp ? "detected!" : "not running");
392
407
 
@@ -419,7 +434,7 @@ export async function runSetup() {
419
434
  ];
420
435
 
421
436
  const selectedPlatforms = await checkbox({
422
- message: "Which platforms do you want to install hooks for?",
437
+ message: "Which platforms do you want to install hooks for? (space = toggle, enter = confirm)",
423
438
  choices: platformChoices,
424
439
  required: false,
425
440
  });
@@ -501,4 +516,13 @@ export async function runSetup() {
501
516
  }
502
517
  printStatus("Reconfigure", "voxlert setup");
503
518
  console.log("");
519
+
520
+ } catch (err) {
521
+ // Inquirer throws on Ctrl+C (ExitPromptError); save partial progress
522
+ if (err && (err.name === "ExitPromptError" || err.message === "Prompt was canceled")) {
523
+ savePartial();
524
+ return;
525
+ }
526
+ throw err;
527
+ }
504
528
  }
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,19 @@ 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. If that's a blocker:");
226
+ printStatus("Hosted option", "https://settinghead.github.io/pipevox-signup");
227
+ console.log("");
176
228
  }
177
229
 
178
230
  return select({
179
- message: "Choose the TTS backend you are setting up. Qwen TTS is recommended for a more natural voice.",
231
+ message: `${hint}Choose the TTS backend. Qwen TTS is recommended for a more natural voice.`,
180
232
  choices: getTtsChoices(config.tts_backend),
181
233
  default: config.tts_backend || "qwen",
182
234
  });
@@ -186,20 +238,31 @@ export async function verifyTtsSetup(config, backend) {
186
238
  const label = getTtsLabel(backend);
187
239
  const docsUrl = getTtsDocsUrl(backend);
188
240
 
241
+ const retryOrSkip = [
242
+ { name: "I have set up the TTS server. Try again.", value: "retry" },
243
+ { name: "Skip setup (you won't hear any voice!)", value: "skip" },
244
+ ];
245
+
246
+ let attempt = 0;
189
247
  while (true) {
248
+ attempt++;
249
+ if (attempt > 1) {
250
+ console.log("\n ── Retry #" + (attempt - 1) + " ──");
251
+ }
190
252
  console.log("");
191
253
  process.stdout.write(` Checking ${label}... `);
192
254
  const backendUp = await probeTtsBackend(config, backend);
193
255
  console.log(backendUp ? "detected!" : "not running");
194
256
 
195
257
  if (!backendUp) {
196
- printWarning(`${label} is not running yet. Finish that setup and come back here to try again.`);
258
+ printWarning(`${label} is not running yet.`);
197
259
  printStatus(`${label} docs`, docsUrl);
198
260
  console.log("");
199
- await confirm({
200
- message: `Press enter after ${label} is running to test again.`,
201
- default: true,
202
- });
261
+ const action = await select({ message: "What would you like to do?", choices: retryOrSkip });
262
+ if (action === "skip") {
263
+ printWarning("Skipped TTS verification. Voice notifications won't work until the server is running.");
264
+ return;
265
+ }
203
266
  continue;
204
267
  }
205
268
 
@@ -208,36 +271,39 @@ export async function verifyTtsSetup(config, backend) {
208
271
  console.log(ok ? "played." : "failed.");
209
272
 
210
273
  if (!ok) {
211
- printWarning(`The ${label} test failed. Keep the docs open, fix the server, and try again.`);
274
+ printWarning(`The ${label} test failed.`);
212
275
  printStatus(`${label} docs`, docsUrl);
213
276
  console.log("");
214
- await confirm({
215
- message: `Press enter to retry the ${label} test.`,
216
- default: true,
217
- });
277
+ const action = await select({ message: "What would you like to do?", choices: retryOrSkip });
278
+ if (action === "skip") {
279
+ printWarning("Skipped TTS verification. Voice notifications won't work until the server is fixed.");
280
+ return;
281
+ }
218
282
  continue;
219
283
  }
220
284
 
221
285
  const heardVoice = await select({
222
286
  message: `Did you hear the ${label} voice test?`,
223
287
  choices: [
224
- { name: "Yes", value: true },
225
- { name: "No", value: false },
288
+ { name: "Yes", value: "yes" },
289
+ { name: "No, try again", value: "retry" },
290
+ { name: "Skip (you won't hear any voice!)", value: "skip" },
226
291
  ],
227
- default: true,
228
292
  });
229
293
 
230
- if (heardVoice) {
294
+ if (heardVoice === "yes") {
231
295
  printSuccess(`${label} verified.`);
232
296
  return;
233
297
  }
234
298
 
235
- printWarning("No workaround here. Keep troubleshooting and retry until you hear the voice.");
299
+ if (heardVoice === "skip") {
300
+ printWarning("Skipped TTS verification. Voice notifications won't work until the server is fixed.");
301
+ return;
302
+ }
303
+
304
+ printWarning("Still not working? Local TTS requires specific hardware (Apple Silicon or NVIDIA GPU).");
305
+ printStatus("Hosted option", "https://settinghead.github.io/pipevox-signup — no local TTS needed");
236
306
  printStatus(`${label} docs`, docsUrl);
237
307
  console.log("");
238
- await confirm({
239
- message: "Press enter to play the test again.",
240
- default: true,
241
- });
242
308
  }
243
309
  }
package/src/voxlert.js CHANGED
@@ -155,7 +155,7 @@ export async function processHookEvent(eventData) {
155
155
  }
156
156
  }
157
157
 
158
- const packId = config.active_pack || "sc2-adjutant";
158
+ const packId = config.active_pack || "sc1-kerrigan-infested";
159
159
  const phraseOneLine = phrase.replace(/\s+/g, " ").slice(0, 120);
160
160
  debugLog("processHookEvent speaking", { source, phrase: phraseOneLine });
161
161
  appendLog(