@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 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. Voxlert still works without TTS — you'll get text notifications and fallback phrases. Need help? [Post in Setup help & troubleshooting](https://github.com/settinghead/voxlert/discussions/6).
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,10 @@ 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
+ ## Need help?
370
+
371
+ Having trouble with setup? Post in the [Setup help & troubleshooting Discussion](https://github.com/settinghead/voxlert/discussions/6).
372
+
351
373
  ## License
352
374
 
353
375
  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.7",
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,29 @@ 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
+ const args = process.argv.slice(2);
27
+ const nonInteractive = args.includes("--yes") || args.includes("-y");
28
+ if (nonInteractive) {
29
+ const { runSetup } = await import("./setup.js");
30
+ await runSetup({ nonInteractive: true });
31
+ return true;
32
+ }
33
+ console.log("Welcome to Voxlert! First time here?\n");
34
+ const select = (await import("@inquirer/select")).default;
35
+ const action = await select({
36
+ message: "What would you like to do?",
37
+ choices: [
38
+ { name: "Run setup", value: "setup", description: "Configure LLM, voice packs, TTS, and hooks" },
39
+ { name: "Show command list", value: "help", description: "See all available commands" },
40
+ ],
41
+ default: "setup",
42
+ });
43
+ if (action === "help") {
44
+ console.log("");
45
+ console.log(createHelpText());
46
+ return true;
47
+ }
48
+ console.log("");
27
49
  const { runSetup } = await import("./setup.js");
28
50
  await runSetup();
29
51
  return true;
@@ -37,13 +37,21 @@ 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("Need help? https://github.com/settinghead/voxlert/discussions/6");
52
+ } else {
53
+ console.log("Done.");
54
+ }
47
55
  }
48
56
 
49
57
  export function packList() {
@@ -3,11 +3,13 @@ export const setupCommand = {
3
3
  aliases: [],
4
4
  help: [
5
5
  " voxlert setup Interactive setup wizard (LLM, voice, TTS, hooks)",
6
+ " voxlert setup --yes Accept all defaults non-interactively",
6
7
  ],
7
8
  skipSetupWizard: true,
8
9
  skipUpgradeCheck: false,
9
- async run() {
10
+ async run({ args }) {
11
+ const nonInteractive = args.includes("--yes") || args.includes("-y");
10
12
  const { runSetup } = await import("../setup.js");
11
- await runSetup();
13
+ await runSetup({ nonInteractive });
12
14
  },
13
15
  };
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,