@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 +28 -4
- package/assets/demo-thumbnail.png +0 -0
- package/assets/sc1-adjutant.gif +0 -0
- package/assets/sc1-kerrigan-infested.jpg +0 -0
- package/config.default.json +2 -2
- package/package.json +1 -1
- package/packs/sc1-kerrigan/pack.json +32 -33
- package/packs/sc1-kerrigan/voice.wav +0 -0
- package/packs/sc1-kerrigan-infested/pack.json +69 -0
- package/packs/sc1-kerrigan-infested/voice.wav +0 -0
- package/packs/{sc2-kerrigan → sc2-kerrigan-infested}/pack.json +1 -1
- package/src/audio.js +166 -10
- package/src/cli.js +16 -1
- package/src/commands/pack-helpers.js +12 -3
- package/src/cost.js +4 -1
- package/src/pack-registry.js +2 -1
- package/src/packs.js +1 -0
- package/src/setup-ui.js +12 -12
- package/src/setup.js +92 -68
- package/src/tts-test.js +109 -43
- package/src/voxlert.js +1 -1
- /package/assets/{sc2-kerrigan.jpg → sc2-kerrigan-infested.jpg} +0 -0
- /package/packs/{sc2-kerrigan → sc2-kerrigan-infested}/voice.wav +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<a href="https://youtu.be
|
|
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/
|
|
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 | `"
|
|
240
|
-
| `active_pack` | string | `"
|
|
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
|
package/assets/sc1-adjutant.gif
CHANGED
|
Binary file
|
|
Binary file
|
package/config.default.json
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"timeout": 15000
|
|
14
14
|
},
|
|
15
15
|
"chatterbox_url": "http://localhost:8004",
|
|
16
|
-
"tts_backend": "
|
|
16
|
+
"tts_backend": "qwen",
|
|
17
17
|
"qwen_tts_url": "http://localhost:8100",
|
|
18
|
-
"active_pack": "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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": "
|
|
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
|
|
22
|
+
"response": "State control locked in. Renders fall in line."
|
|
23
23
|
}
|
|
24
24
|
],
|
|
25
25
|
"fallback_phrases": {
|
|
26
26
|
"session.start": [
|
|
27
|
-
"
|
|
28
|
-
"I
|
|
29
|
-
"Ready
|
|
30
|
-
"
|
|
31
|
-
"
|
|
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
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
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
|
-
"
|
|
43
|
-
"
|
|
41
|
+
"Copy that",
|
|
42
|
+
"Understood",
|
|
44
43
|
"Acknowledged",
|
|
45
|
-
"
|
|
44
|
+
"On it",
|
|
46
45
|
"Consider it done"
|
|
47
46
|
],
|
|
48
47
|
"input.required": [
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
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
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"We
|
|
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
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
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.
|
|
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
|
+
}
|
|
Binary file
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
333
|
-
const cmd = command
|
|
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 || "
|
|
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!
|
|
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 || "
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/pack-registry.js
CHANGED
|
@@ -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: "
|
|
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 =
|
|
87
|
+
function centerLine(text, width = 59) {
|
|
88
88
|
const padding = Math.max(0, Math.floor((width - text.length) / 2));
|
|
89
89
|
return `${" ".repeat(padding)}${text}`;
|
|
90
90
|
}
|
|
@@ -98,8 +98,8 @@ function formatCurrentConfig(config, installedPlatforms) {
|
|
|
98
98
|
const providerLabel = config.llm_api_key
|
|
99
99
|
? `${provider ? provider.name : (config.llm_backend || "openrouter")} (${config.llm_model || provider?.defaultModel || "default"})`
|
|
100
100
|
: "Fallback only";
|
|
101
|
-
const ttsLabel = config.tts_backend || "
|
|
102
|
-
const voiceLabel = config.active_pack || "
|
|
101
|
+
const ttsLabel = config.tts_backend || "qwen";
|
|
102
|
+
const voiceLabel = config.active_pack || "sc1-kerrigan-infested";
|
|
103
103
|
const platforms = installedPlatforms.filter(Boolean);
|
|
104
104
|
return [
|
|
105
105
|
`${color("Current", ANSI.dim)} ${providerLabel}`,
|
|
@@ -111,18 +111,18 @@ function formatCurrentConfig(config, installedPlatforms) {
|
|
|
111
111
|
|
|
112
112
|
function renderLogoFrame(config, installedPlatforms, shimmerStep = -1) {
|
|
113
113
|
const current = formatCurrentConfig(config, installedPlatforms).map((line) => ` ${line}`);
|
|
114
|
-
const rule = color("┈".repeat(
|
|
114
|
+
const rule = color("┈".repeat(59), ANSI.dim);
|
|
115
115
|
const glow = color("SYNTHETIC VOICE NOTIFICATIONS FOR AGENT WORKFLOWS", ANSI.cyan);
|
|
116
116
|
const logo = LOGO_LINES.map((line, index) => {
|
|
117
117
|
const shimmerIndex = shimmerStep >= 0 ? shimmerStep - index * 3 : -1;
|
|
118
118
|
const phase = shimmerStep >= 0 ? shimmerStep * 0.015 + index * 0.02 : index * 0.02;
|
|
119
|
-
return centerLine(animatedLogoLine(line, phase, shimmerIndex),
|
|
119
|
+
return centerLine(animatedLogoLine(line, phase, shimmerIndex), 59);
|
|
120
120
|
});
|
|
121
121
|
return [
|
|
122
122
|
"",
|
|
123
123
|
rule,
|
|
124
124
|
...logo,
|
|
125
|
-
centerLine(glow,
|
|
125
|
+
centerLine(glow, 59),
|
|
126
126
|
rule,
|
|
127
127
|
"",
|
|
128
128
|
...current,
|
package/src/setup.js
CHANGED
|
@@ -23,8 +23,6 @@ import { registerCursorHooks, unregisterCursorHooks, hasCursorHooks } from "./cu
|
|
|
23
23
|
import { registerCodexNotify, getCodexConfigPath, unregisterCodexNotify, hasCodexNotify } from "./codex-config.js";
|
|
24
24
|
import { printSetupHeader, printStep, printStatus, printSuccess, printWarning, highlight } from "./setup-ui.js";
|
|
25
25
|
import {
|
|
26
|
-
QWEN_DOCS_URL,
|
|
27
|
-
CHATTERBOX_DOCS_URL,
|
|
28
26
|
probeTtsBackend,
|
|
29
27
|
chooseTtsBackend,
|
|
30
28
|
verifyTtsSetup,
|
|
@@ -38,64 +36,68 @@ function validateApiKey(providerId, apiKey) {
|
|
|
38
36
|
const provider = getProvider(providerId);
|
|
39
37
|
if (!provider) return resolve({ ok: false, error: "Unknown provider" });
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
options = {
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: {
|
|
58
|
-
...authHeaders,
|
|
59
|
-
"Content-Type": "application/json",
|
|
60
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
61
|
-
},
|
|
62
|
-
timeout: 8000,
|
|
63
|
-
};
|
|
64
|
-
const req = https.request(url, options, (res) => {
|
|
65
|
-
let data = "";
|
|
66
|
-
res.on("data", (chunk) => (data += chunk));
|
|
67
|
-
res.on("end", () => {
|
|
68
|
-
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
69
|
-
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
70
|
-
resolve({ ok: res.statusCode < 500 });
|
|
39
|
+
try {
|
|
40
|
+
let url;
|
|
41
|
+
let options;
|
|
42
|
+
|
|
43
|
+
const base = provider.baseUrl.replace(/\/+$/, "");
|
|
44
|
+
|
|
45
|
+
if (provider.format === "anthropic") {
|
|
46
|
+
// Anthropic: POST to /v1/messages with a tiny request
|
|
47
|
+
url = new URL(`${base}/v1/messages`);
|
|
48
|
+
const authHeaders = provider.authHeader(apiKey);
|
|
49
|
+
const payload = JSON.stringify({
|
|
50
|
+
model: provider.defaultModel,
|
|
51
|
+
max_tokens: 1,
|
|
52
|
+
messages: [{ role: "user", content: "hi" }],
|
|
71
53
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
54
|
+
options = {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
...authHeaders,
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
60
|
+
},
|
|
61
|
+
timeout: 8000,
|
|
62
|
+
};
|
|
63
|
+
const req = https.request(url, options, (res) => {
|
|
64
|
+
let data = "";
|
|
65
|
+
res.on("data", (chunk) => (data += chunk));
|
|
66
|
+
res.on("end", () => {
|
|
67
|
+
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
68
|
+
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
69
|
+
resolve({ ok: res.statusCode < 500 });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
req.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
73
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
|
|
74
|
+
req.write(payload);
|
|
75
|
+
req.end();
|
|
76
|
+
} else {
|
|
77
|
+
// OpenAI-compatible: GET /models
|
|
78
|
+
url = new URL(`${base}/models`);
|
|
79
|
+
const authHeaders = provider.authHeader(apiKey);
|
|
80
|
+
options = {
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: { ...authHeaders },
|
|
83
|
+
timeout: 8000,
|
|
84
|
+
};
|
|
85
|
+
const reqFn = url.protocol === "https:" ? https.request : http.request;
|
|
86
|
+
const req = reqFn(url, options, (res) => {
|
|
87
|
+
let data = "";
|
|
88
|
+
res.on("data", (chunk) => (data += chunk));
|
|
89
|
+
res.on("end", () => {
|
|
90
|
+
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
91
|
+
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
92
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300 });
|
|
93
|
+
});
|
|
94
94
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
req.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
96
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
|
|
97
|
+
req.end();
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
resolve({ ok: false, error: err.message });
|
|
99
101
|
}
|
|
100
102
|
});
|
|
101
103
|
}
|
|
@@ -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? (
|
|
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
|
|
382
|
+
message: "Choose default voice:",
|
|
366
383
|
choices: packChoices,
|
|
367
|
-
default: active || "
|
|
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
|
|
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
|
|
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
|
|
42
|
-
: "Qwen TTS (recommended, more natural voice
|
|
40
|
+
? "Qwen TTS (recommended, current, more natural voice)"
|
|
41
|
+
: "Qwen TTS (recommended, more natural voice)",
|
|
43
42
|
value: "qwen",
|
|
43
|
+
description: `Setup docs: ${QWEN_DOCS_URL}`,
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
name: currentBackend === "chatterbox"
|
|
47
|
-
? "Chatterbox (current
|
|
48
|
-
: "Chatterbox
|
|
47
|
+
? "Chatterbox (current)"
|
|
48
|
+
: "Chatterbox",
|
|
49
49
|
value: "chatterbox",
|
|
50
|
+
description: `Setup docs: ${CHATTERBOX_DOCS_URL}`,
|
|
50
51
|
},
|
|
51
52
|
];
|
|
52
53
|
}
|
|
@@ -78,11 +79,69 @@ export function probeTtsBackend(config, backend, timeoutMs = 2000) {
|
|
|
78
79
|
});
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function
|
|
82
|
+
function _registerVoiceForTest(config, pack) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const voicePath = pack.voicePath;
|
|
85
|
+
const refText = pack.ref_text;
|
|
86
|
+
if (!voicePath || !existsSync(voicePath) || !refText) return resolve(null);
|
|
87
|
+
|
|
88
|
+
const qwenUrl = config.qwen_tts_url || "http://localhost:8100";
|
|
89
|
+
const endpoint = `${qwenUrl}/voices`;
|
|
90
|
+
|
|
91
|
+
let audioData;
|
|
92
|
+
try { audioData = readFileSync(voicePath); } catch { return resolve(null); }
|
|
93
|
+
|
|
94
|
+
const boundary = `----VoxlertBoundary${Date.now()}`;
|
|
95
|
+
const parts = [];
|
|
96
|
+
parts.push(Buffer.from(
|
|
97
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="ref_text"\r\n\r\n${refText}\r\n`,
|
|
98
|
+
));
|
|
99
|
+
parts.push(Buffer.from(
|
|
100
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="voice.wav"\r\n` +
|
|
101
|
+
`Content-Type: audio/wav\r\n\r\n`,
|
|
102
|
+
));
|
|
103
|
+
parts.push(audioData);
|
|
104
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
105
|
+
const body = Buffer.concat(parts);
|
|
106
|
+
|
|
107
|
+
const url = new URL(endpoint);
|
|
108
|
+
const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
109
|
+
const req = requestFn(endpoint, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
113
|
+
"Content-Length": body.length,
|
|
114
|
+
},
|
|
115
|
+
timeout: 15000,
|
|
116
|
+
}, (res) => {
|
|
117
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); return resolve(null); }
|
|
118
|
+
const chunks = [];
|
|
119
|
+
res.on("data", (c) => chunks.push(c));
|
|
120
|
+
res.on("end", () => {
|
|
121
|
+
try {
|
|
122
|
+
const result = JSON.parse(Buffer.concat(chunks).toString());
|
|
123
|
+
resolve(result.voice_id || null);
|
|
124
|
+
} catch { resolve(null); }
|
|
125
|
+
});
|
|
126
|
+
res.on("error", () => resolve(null));
|
|
127
|
+
});
|
|
128
|
+
req.on("error", () => resolve(null));
|
|
129
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
130
|
+
req.write(body);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function requestTtsAudio(config, backend, pack) {
|
|
136
|
+
let voiceId = null;
|
|
137
|
+
if (backend === "qwen") {
|
|
138
|
+
voiceId = await _registerVoiceForTest(config, pack);
|
|
139
|
+
}
|
|
140
|
+
|
|
82
141
|
return new Promise((resolve) => {
|
|
83
142
|
const endpoint = getTtsEndpoint(config, backend);
|
|
84
143
|
const body = backend === "qwen"
|
|
85
|
-
? { text: TTS_TEST_PHRASE,
|
|
144
|
+
? { text: TTS_TEST_PHRASE, ...(voiceId ? { voice_id: voiceId } : {}) }
|
|
86
145
|
: {
|
|
87
146
|
text: TTS_TEST_PHRASE,
|
|
88
147
|
voice_mode: "predefined",
|
|
@@ -157,26 +216,19 @@ export async function runTtsSample(config, backend) {
|
|
|
157
216
|
}
|
|
158
217
|
|
|
159
218
|
export async function chooseTtsBackend(config, { qwenUp, chatterboxUp }) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
default: config.tts_backend || "qwen",
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (qwenUp) {
|
|
169
|
-
printSuccess("Using Qwen TTS.");
|
|
170
|
-
return "qwen";
|
|
171
|
-
}
|
|
219
|
+
const detected = [qwenUp && "Qwen TTS", chatterboxUp && "Chatterbox"].filter(Boolean);
|
|
220
|
+
const hint = detected.length > 0
|
|
221
|
+
? `Detected: ${detected.join(", ")}. `
|
|
222
|
+
: "";
|
|
172
223
|
|
|
173
|
-
if (chatterboxUp) {
|
|
174
|
-
|
|
175
|
-
|
|
224
|
+
if (!qwenUp && !chatterboxUp) {
|
|
225
|
+
printStatus("Note", "Local TTS needs a GPU or Apple Silicon. 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:
|
|
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
|
|
258
|
+
printWarning(`${label} is not running yet.`);
|
|
197
259
|
printStatus(`${label} docs`, docsUrl);
|
|
198
260
|
console.log("");
|
|
199
|
-
await
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
274
|
+
printWarning(`The ${label} test failed.`);
|
|
212
275
|
printStatus(`${label} docs`, docsUrl);
|
|
213
276
|
console.log("");
|
|
214
|
-
await
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
|
225
|
-
{ name: "No", value:
|
|
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
|
-
|
|
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 || "
|
|
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(
|
|
File without changes
|
|
File without changes
|