@settinghead/voxlert 0.3.5
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/LICENSE +21 -0
- package/README.md +353 -0
- package/assets/cortana.png +0 -0
- package/assets/deckard-cain.png +0 -0
- package/assets/demo-thumbnail.png +0 -0
- package/assets/glados.png +0 -0
- package/assets/hl-hev-suit.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/red-alert-eva.png +0 -0
- package/assets/sc1-adjutant.gif +0 -0
- package/assets/sc1-kerrigan.gif +0 -0
- package/assets/sc1-protoss-advisor.jpg +0 -0
- package/assets/sc2-adjutant.jpg +0 -0
- package/assets/sc2-kerrigan.jpg +0 -0
- package/assets/ss1-shodan.png +0 -0
- package/config.default.json +35 -0
- package/openclaw-plugin/index.ts +100 -0
- package/openclaw-plugin/openclaw.plugin.json +21 -0
- package/package.json +51 -0
- package/packs/hl-hev-suit/pack.json +72 -0
- package/packs/hl-hev-suit/voice.wav +0 -0
- package/packs/red-alert-eva/pack.json +73 -0
- package/packs/red-alert-eva/voice.wav +0 -0
- package/packs/sc1-adjutant/pack.json +31 -0
- package/packs/sc1-adjutant/voice.wav +0 -0
- package/packs/sc1-kerrigan/pack.json +69 -0
- package/packs/sc1-kerrigan/voice.wav +0 -0
- package/packs/sc1-protoss-advisor/pack.json +70 -0
- package/packs/sc1-protoss-advisor/voice.wav +0 -0
- package/packs/sc2-adjutant/pack.json +14 -0
- package/packs/sc2-adjutant/voice.wav +0 -0
- package/packs/sc2-kerrigan/pack.json +69 -0
- package/packs/sc2-kerrigan/voice.wav +0 -0
- package/packs/sc2-protoss-advisor/pack.json +70 -0
- package/packs/sc2-protoss-advisor/voice.wav +0 -0
- package/packs/ss1-shodan/pack.json +69 -0
- package/packs/ss1-shodan/voice.wav +0 -0
- package/skills/voxlert-config/SKILL.md +44 -0
- package/src/activity-log.js +58 -0
- package/src/audio.js +381 -0
- package/src/cli.js +86 -0
- package/src/codex-config.js +149 -0
- package/src/commands/codex-notify.js +70 -0
- package/src/commands/config.js +141 -0
- package/src/commands/cost.js +20 -0
- package/src/commands/cursor-hook.js +52 -0
- package/src/commands/help.js +25 -0
- package/src/commands/hook-utils.js +73 -0
- package/src/commands/hook.js +27 -0
- package/src/commands/index.js +45 -0
- package/src/commands/log.js +92 -0
- package/src/commands/notification.js +50 -0
- package/src/commands/pack-helpers.js +157 -0
- package/src/commands/pack.js +25 -0
- package/src/commands/setup.js +13 -0
- package/src/commands/test.js +14 -0
- package/src/commands/uninstall.js +60 -0
- package/src/commands/version.js +12 -0
- package/src/commands/voice.js +14 -0
- package/src/commands/volume.js +38 -0
- package/src/config.js +230 -0
- package/src/cost.js +124 -0
- package/src/cursor-hooks.js +93 -0
- package/src/formats.js +55 -0
- package/src/hooks.js +129 -0
- package/src/llm.js +237 -0
- package/src/overlay.js +212 -0
- package/src/overlay.jxa +186 -0
- package/src/pack-registry.js +28 -0
- package/src/packs.js +182 -0
- package/src/paths.js +39 -0
- package/src/postinstall.js +13 -0
- package/src/providers.js +129 -0
- package/src/setup-ui.js +177 -0
- package/src/setup.js +504 -0
- package/src/tts-test.js +243 -0
- package/src/upgrade-check.js +137 -0
- package/src/voxlert.js +200 -0
- package/voxlert.sh +4 -0
package/src/overlay.jxa
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voxlert — Native macOS overlay notification.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* osascript -l JavaScript overlay.jxa <message> <gradient_json> <icon_path> <slot> <dismiss_secs> [subtitle]
|
|
6
|
+
*
|
|
7
|
+
* Arguments:
|
|
8
|
+
* message — Main phrase text
|
|
9
|
+
* gradient_json — JSON array of [r,g,b] triples (0-1), e.g. '[[0.1,0.4,0.7],[0.05,0.2,0.5]]'
|
|
10
|
+
* icon_path — Absolute path to character icon (or "" for no icon)
|
|
11
|
+
* slot — Vertical slot index (0-4)
|
|
12
|
+
* dismiss_secs — Seconds before auto-dismiss
|
|
13
|
+
* subtitle — Optional subtitle text (project name · character)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
ObjC.import("Cocoa");
|
|
17
|
+
ObjC.import("QuartzCore");
|
|
18
|
+
|
|
19
|
+
function run(argv) {
|
|
20
|
+
var message = argv[0] || "Notification";
|
|
21
|
+
var gradientJson = argv[1] || "[[0.15,0.15,0.2],[0.1,0.1,0.15]]";
|
|
22
|
+
var iconPath = argv[2] || "";
|
|
23
|
+
var slot = parseInt(argv[3], 10) || 0;
|
|
24
|
+
var dismissSecs = parseFloat(argv[4]) || 4;
|
|
25
|
+
var subtitle = argv[5] || "";
|
|
26
|
+
|
|
27
|
+
// Parse gradient colors
|
|
28
|
+
var colors;
|
|
29
|
+
try {
|
|
30
|
+
colors = JSON.parse(gradientJson);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
colors = [[0.15, 0.15, 0.2], [0.1, 0.1, 0.15]];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ensure we have at least 2 color stops
|
|
36
|
+
if (!Array.isArray(colors) || colors.length < 2) {
|
|
37
|
+
colors = [[0.15, 0.15, 0.2], [0.1, 0.1, 0.15]];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Window dimensions
|
|
41
|
+
var winWidth = 500;
|
|
42
|
+
var winHeight = 80;
|
|
43
|
+
var cornerRadius = 12;
|
|
44
|
+
var iconSize = 48;
|
|
45
|
+
var iconPadding = 16;
|
|
46
|
+
|
|
47
|
+
// Get screen geometry
|
|
48
|
+
var screen = $.NSScreen.mainScreen;
|
|
49
|
+
var frame = screen.visibleFrame;
|
|
50
|
+
var screenWidth = frame.size.width;
|
|
51
|
+
var screenTop = frame.origin.y + frame.size.height;
|
|
52
|
+
|
|
53
|
+
// Position: top-center, stacked by slot
|
|
54
|
+
var x = (screenWidth - winWidth) / 2 + frame.origin.x;
|
|
55
|
+
var slotGap = winHeight + 8;
|
|
56
|
+
var y = screenTop - winHeight - 12 - (slot * slotGap);
|
|
57
|
+
|
|
58
|
+
// Create borderless, always-on-top window
|
|
59
|
+
var winRect = $.NSMakeRect(x, y, winWidth, winHeight);
|
|
60
|
+
var styleMask = $.NSWindowStyleMaskBorderless;
|
|
61
|
+
var win = $.NSWindow.alloc.initWithContentRectStyleMaskBackingDefer(
|
|
62
|
+
winRect, styleMask, $.NSBackingStoreBuffered, false
|
|
63
|
+
);
|
|
64
|
+
win.setLevel($.NSStatusWindowLevel + 1);
|
|
65
|
+
win.setOpaque(false);
|
|
66
|
+
win.setAlphaValue(0.95);
|
|
67
|
+
win.setBackgroundColor($.NSColor.clearColor);
|
|
68
|
+
win.setHasShadow(true);
|
|
69
|
+
win.setIgnoresMouseEvents(true);
|
|
70
|
+
|
|
71
|
+
// Content view with rounded corners
|
|
72
|
+
var contentView = win.contentView;
|
|
73
|
+
contentView.wantsLayer = true;
|
|
74
|
+
var layer = contentView.layer;
|
|
75
|
+
layer.cornerRadius = cornerRadius;
|
|
76
|
+
layer.masksToBounds = true;
|
|
77
|
+
|
|
78
|
+
// Animated gradient background
|
|
79
|
+
var gradientLayer = $.CAGradientLayer.layer;
|
|
80
|
+
gradientLayer.frame = layer.bounds;
|
|
81
|
+
|
|
82
|
+
// Build NSColor array for gradient
|
|
83
|
+
var nsColors = $.NSMutableArray.alloc.init;
|
|
84
|
+
for (var i = 0; i < colors.length; i++) {
|
|
85
|
+
var c = colors[i];
|
|
86
|
+
var r = c[0] || 0, g = c[1] || 0, b = c[2] || 0;
|
|
87
|
+
var nsColor = $.NSColor.colorWithCalibratedRedGreenBlueAlpha(r, g, b, 1.0);
|
|
88
|
+
nsColors.addObject(nsColor.CGColor);
|
|
89
|
+
}
|
|
90
|
+
gradientLayer.colors = nsColors;
|
|
91
|
+
gradientLayer.startPoint = $.CGPointMake(0, 0);
|
|
92
|
+
gradientLayer.endPoint = $.CGPointMake(1, 1);
|
|
93
|
+
layer.insertSublayerAtIndex(gradientLayer, 0);
|
|
94
|
+
|
|
95
|
+
// Animate gradient flow
|
|
96
|
+
var anim = $.CABasicAnimation.animationWithKeyPath("startPoint");
|
|
97
|
+
anim.fromValue = $.NSValue.valueWithPoint($.NSMakePoint(0, 0));
|
|
98
|
+
anim.toValue = $.NSValue.valueWithPoint($.NSMakePoint(1, 0));
|
|
99
|
+
anim.duration = 3.0;
|
|
100
|
+
anim.repeatCount = Infinity;
|
|
101
|
+
anim.autoreverses = true;
|
|
102
|
+
gradientLayer.addAnimationForKey(anim, "flowStart");
|
|
103
|
+
|
|
104
|
+
var anim2 = $.CABasicAnimation.animationWithKeyPath("endPoint");
|
|
105
|
+
anim2.fromValue = $.NSValue.valueWithPoint($.NSMakePoint(1, 1));
|
|
106
|
+
anim2.toValue = $.NSValue.valueWithPoint($.NSMakePoint(0, 1));
|
|
107
|
+
anim2.duration = 3.0;
|
|
108
|
+
anim2.repeatCount = Infinity;
|
|
109
|
+
anim2.autoreverses = true;
|
|
110
|
+
gradientLayer.addAnimationForKey(anim2, "flowEnd");
|
|
111
|
+
|
|
112
|
+
// Text area offset (shift right if icon present)
|
|
113
|
+
var textLeft = iconPadding;
|
|
114
|
+
var hasIcon = false;
|
|
115
|
+
|
|
116
|
+
// Character icon
|
|
117
|
+
if (iconPath && iconPath !== "") {
|
|
118
|
+
var img = $.NSImage.alloc.initWithContentsOfFile($(iconPath));
|
|
119
|
+
if (img && img.isValid) {
|
|
120
|
+
hasIcon = true;
|
|
121
|
+
var imgView = $.NSImageView.alloc.initWithFrame(
|
|
122
|
+
$.NSMakeRect(iconPadding, (winHeight - iconSize) / 2, iconSize, iconSize)
|
|
123
|
+
);
|
|
124
|
+
imgView.setImage(img);
|
|
125
|
+
imgView.setImageScaling($.NSImageScaleProportionallyUpOrDown);
|
|
126
|
+
contentView.addSubview(imgView);
|
|
127
|
+
textLeft = iconPadding + iconSize + 12;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
var textWidth = winWidth - textLeft - iconPadding;
|
|
132
|
+
|
|
133
|
+
// Main phrase text (bold 16pt, white)
|
|
134
|
+
var phraseLabel = $.NSTextField.alloc.initWithFrame(
|
|
135
|
+
$.NSMakeRect(textLeft, subtitle ? 28 : 22, textWidth, 30)
|
|
136
|
+
);
|
|
137
|
+
phraseLabel.stringValue = $(message);
|
|
138
|
+
phraseLabel.bezeled = false;
|
|
139
|
+
phraseLabel.drawsBackground = false;
|
|
140
|
+
phraseLabel.editable = false;
|
|
141
|
+
phraseLabel.selectable = false;
|
|
142
|
+
phraseLabel.textColor = $.NSColor.whiteColor;
|
|
143
|
+
phraseLabel.font = $.NSFont.boldSystemFontOfSize(16);
|
|
144
|
+
phraseLabel.lineBreakMode = $.NSLineBreakByTruncatingTail;
|
|
145
|
+
contentView.addSubview(phraseLabel);
|
|
146
|
+
|
|
147
|
+
// Subtitle (12pt, white 70% opacity)
|
|
148
|
+
if (subtitle) {
|
|
149
|
+
var subLabel = $.NSTextField.alloc.initWithFrame(
|
|
150
|
+
$.NSMakeRect(textLeft, 10, textWidth, 20)
|
|
151
|
+
);
|
|
152
|
+
subLabel.stringValue = $(subtitle);
|
|
153
|
+
subLabel.bezeled = false;
|
|
154
|
+
subLabel.drawsBackground = false;
|
|
155
|
+
subLabel.editable = false;
|
|
156
|
+
subLabel.selectable = false;
|
|
157
|
+
subLabel.textColor = $.NSColor.colorWithCalibratedRedGreenBlueAlpha(1, 1, 1, 0.7);
|
|
158
|
+
subLabel.font = $.NSFont.systemFontOfSize(12);
|
|
159
|
+
subLabel.lineBreakMode = $.NSLineBreakByTruncatingTail;
|
|
160
|
+
contentView.addSubview(subLabel);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Show window (skip NSViewAnimation fade-in — in JXA the animation target can be lost/bridge as null)
|
|
164
|
+
win.setAlphaValue(0.95);
|
|
165
|
+
win.orderFrontRegardless;
|
|
166
|
+
|
|
167
|
+
var app = $.NSApplication.sharedApplication;
|
|
168
|
+
app.activateIgnoringOtherApps(false);
|
|
169
|
+
|
|
170
|
+
// Run loop with timeout
|
|
171
|
+
var runLoop = $.NSRunLoop.currentRunLoop;
|
|
172
|
+
var endDate = $.NSDate.dateWithTimeIntervalSinceNow(dismissSecs);
|
|
173
|
+
while (runLoop.runModeBeforeDate($.NSDefaultRunLoopMode, endDate)) {
|
|
174
|
+
if ($.NSDate.date.compare(endDate) !== $.NSOrderedAscending) break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fade out by animating alpha (avoids NSViewAnimation null-target issue in JXA)
|
|
178
|
+
var fadeOutEnd = $.NSDate.dateWithTimeIntervalSinceNow(0.35);
|
|
179
|
+
for (var a = 0.95; a > 0; a -= 0.15) {
|
|
180
|
+
win.setAlphaValue(Math.max(0, a));
|
|
181
|
+
runLoop.runModeBeforeDate($.NSDefaultRunLoopMode, $.NSDate.dateWithTimeIntervalSinceNow(0.05));
|
|
182
|
+
}
|
|
183
|
+
win.setAlphaValue(0);
|
|
184
|
+
|
|
185
|
+
win.close;
|
|
186
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of voice packs that can be downloaded from the GitHub repo.
|
|
3
|
+
* Used by the setup wizard so users can choose which packs to install
|
|
4
|
+
* without shipping them all in the npm package.
|
|
5
|
+
*
|
|
6
|
+
* baseUrl should point at raw GitHub content (branch or tag).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/settinghead/voxlert/main/packs";
|
|
10
|
+
|
|
11
|
+
export const PACK_REGISTRY = [
|
|
12
|
+
{ id: "sc2-adjutant", name: "StarCraft 2 Adjutant" },
|
|
13
|
+
{ id: "sc1-adjutant", name: "StarCraft 1 Adjutant" },
|
|
14
|
+
{ id: "red-alert-eva", name: "Red Alert EVA" },
|
|
15
|
+
{ id: "sc1-kerrigan", name: "SC1 Kerrigan" },
|
|
16
|
+
{ id: "sc2-kerrigan", name: "SC2 Kerrigan" },
|
|
17
|
+
{ id: "sc1-protoss-advisor", name: "Protoss Advisor (SC1)" },
|
|
18
|
+
{ id: "sc2-protoss-advisor", name: "Protoss Advisor (SC2)" },
|
|
19
|
+
{ id: "ss1-shodan", name: "SHODAN" },
|
|
20
|
+
{ id: "hl-hev-suit", name: "HEV Suit" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Default pack ids to pre-select in setup when offering download. */
|
|
24
|
+
export const DEFAULT_DOWNLOAD_PACK_IDS = ["sc2-adjutant", "sc1-adjutant", "red-alert-eva"];
|
|
25
|
+
|
|
26
|
+
export function getPackRegistryBaseUrl() {
|
|
27
|
+
return process.env.VOXLERT_PACKS_BASE_URL || GITHUB_RAW_BASE;
|
|
28
|
+
}
|
package/src/packs.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { PACKS_DIR, PACK_VOLUME_CACHE_DIR } from "./paths.js";
|
|
6
|
+
|
|
7
|
+
// Target mean volume in dBFS — voices are normalized to this level
|
|
8
|
+
const TARGET_MEAN_DB = -16;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Analyze a WAV file's mean volume using ffmpeg volumedetect.
|
|
12
|
+
* Returns mean_volume in dBFS, or null on failure.
|
|
13
|
+
*/
|
|
14
|
+
function analyzeVolume(wavPath) {
|
|
15
|
+
try {
|
|
16
|
+
const output = execSync(
|
|
17
|
+
`ffmpeg -i "${wavPath}" -af volumedetect -f null /dev/null 2>&1`,
|
|
18
|
+
{ encoding: "utf-8", timeout: 10000 },
|
|
19
|
+
);
|
|
20
|
+
const match = output.match(/mean_volume:\s*([-\d.]+)\s*dB/);
|
|
21
|
+
if (match) return parseFloat(match[1]);
|
|
22
|
+
} catch {
|
|
23
|
+
// ffmpeg not available or analysis failed
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get volume offset in dB for a pack's voice file.
|
|
30
|
+
* Caches result in ~/.voxlert/pack-volume/<pack-id>.json.
|
|
31
|
+
*/
|
|
32
|
+
function getVolumeOffsetDb(voicePath, packId) {
|
|
33
|
+
if (!voicePath || !existsSync(voicePath)) return 0;
|
|
34
|
+
|
|
35
|
+
let stats;
|
|
36
|
+
try {
|
|
37
|
+
stats = statSync(voicePath);
|
|
38
|
+
} catch {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const voiceKey = createHash("sha1").update(voicePath).digest("hex");
|
|
43
|
+
|
|
44
|
+
// Check cache
|
|
45
|
+
const cachePath = join(PACK_VOLUME_CACHE_DIR, `${packId || "_legacy"}.json`);
|
|
46
|
+
try {
|
|
47
|
+
const cached = JSON.parse(readFileSync(cachePath, "utf-8"));
|
|
48
|
+
if (
|
|
49
|
+
cached.voiceKey === voiceKey &&
|
|
50
|
+
cached.mtimeMs === Math.round(stats.mtimeMs) &&
|
|
51
|
+
cached.size === stats.size &&
|
|
52
|
+
typeof cached.offsetDb === "number"
|
|
53
|
+
) {
|
|
54
|
+
return cached.offsetDb;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// no cache or invalid
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Analyze and cache
|
|
61
|
+
const meanDb = analyzeVolume(voicePath);
|
|
62
|
+
if (meanDb == null) return 0;
|
|
63
|
+
|
|
64
|
+
const offsetDb = Math.round((TARGET_MEAN_DB - meanDb) * 10) / 10;
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(PACK_VOLUME_CACHE_DIR, { recursive: true });
|
|
67
|
+
writeFileSync(
|
|
68
|
+
cachePath,
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
voiceKey,
|
|
71
|
+
mtimeMs: Math.round(stats.mtimeMs),
|
|
72
|
+
size: stats.size,
|
|
73
|
+
meanDb,
|
|
74
|
+
offsetDb,
|
|
75
|
+
}) + "\n",
|
|
76
|
+
);
|
|
77
|
+
} catch {
|
|
78
|
+
// best-effort caching
|
|
79
|
+
}
|
|
80
|
+
return offsetDb;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load a voice pack by id (from config.active_pack).
|
|
85
|
+
* Returns { id, name, echo, voicePath, style, fallback_phrases }.
|
|
86
|
+
* Falls back to legacy config.voice if no active_pack is set.
|
|
87
|
+
*/
|
|
88
|
+
export function loadPack(config) {
|
|
89
|
+
let packId = config.active_pack;
|
|
90
|
+
|
|
91
|
+
// Random mode: pick a random pack each time
|
|
92
|
+
if (packId === "random") {
|
|
93
|
+
const packs = listPacks();
|
|
94
|
+
if (packs.length > 0) {
|
|
95
|
+
packId = packs[Math.floor(Math.random() * packs.length)].id;
|
|
96
|
+
} else {
|
|
97
|
+
packId = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Legacy fallback: no active_pack configured
|
|
102
|
+
if (!packId) {
|
|
103
|
+
return {
|
|
104
|
+
id: "_legacy",
|
|
105
|
+
name: "Legacy",
|
|
106
|
+
echo: true,
|
|
107
|
+
voicePath: config.voice || "default.wav",
|
|
108
|
+
volumeOffsetDb: 0,
|
|
109
|
+
style: null,
|
|
110
|
+
fallback_phrases: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const packDir = join(PACKS_DIR, packId);
|
|
115
|
+
const packJsonPath = join(packDir, "pack.json");
|
|
116
|
+
|
|
117
|
+
let packData;
|
|
118
|
+
try {
|
|
119
|
+
packData = JSON.parse(readFileSync(packJsonPath, "utf-8"));
|
|
120
|
+
} catch {
|
|
121
|
+
// Pack not found or invalid — fall back to defaults
|
|
122
|
+
return {
|
|
123
|
+
id: packId,
|
|
124
|
+
name: packId,
|
|
125
|
+
echo: true,
|
|
126
|
+
voicePath: config.voice || "default.wav",
|
|
127
|
+
volumeOffsetDb: 0,
|
|
128
|
+
style: null,
|
|
129
|
+
fallback_phrases: null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Resolve voice path: relative to pack dir, or fall back to config.voice
|
|
134
|
+
let voicePath = config.voice || "default.wav";
|
|
135
|
+
if (packData.voice) {
|
|
136
|
+
const resolved = resolve(packDir, packData.voice);
|
|
137
|
+
if (existsSync(resolved)) {
|
|
138
|
+
voicePath = resolved;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
id: packId,
|
|
144
|
+
name: packData.name || packId,
|
|
145
|
+
echo: packData.echo !== false,
|
|
146
|
+
voicePath,
|
|
147
|
+
volumeOffsetDb: getVolumeOffsetDb(voicePath, packId),
|
|
148
|
+
tts_params: packData.tts_params || null,
|
|
149
|
+
audio_filter: packData.audio_filter || null,
|
|
150
|
+
post_process: packData.post_process || null,
|
|
151
|
+
style: packData.style || packData.system_prompt || null,
|
|
152
|
+
examples: packData.examples || null,
|
|
153
|
+
fallback_phrases: packData.fallback_phrases || null,
|
|
154
|
+
overlay_colors: packData.overlay_colors || null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List all available voice packs.
|
|
160
|
+
* Returns [{ id, name }].
|
|
161
|
+
*/
|
|
162
|
+
export function listPacks() {
|
|
163
|
+
const packs = [];
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(PACKS_DIR, { withFileTypes: true });
|
|
167
|
+
} catch {
|
|
168
|
+
return packs;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (!entry.isDirectory()) continue;
|
|
173
|
+
const packJsonPath = join(PACKS_DIR, entry.name, "pack.json");
|
|
174
|
+
try {
|
|
175
|
+
const data = JSON.parse(readFileSync(packJsonPath, "utf-8"));
|
|
176
|
+
packs.push({ id: entry.name, name: data.name || entry.name });
|
|
177
|
+
} catch {
|
|
178
|
+
// skip invalid packs
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return packs;
|
|
182
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { dirname, join } from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
export const SCRIPT_DIR = dirname(__dirname);
|
|
7
|
+
|
|
8
|
+
// Detect npm global install (src/ lives inside node_modules)
|
|
9
|
+
export const IS_NPM_GLOBAL = SCRIPT_DIR.includes("node_modules");
|
|
10
|
+
|
|
11
|
+
// User-level config and state in ~/.voxlert
|
|
12
|
+
export const STATE_DIR = join(homedir(), ".voxlert");
|
|
13
|
+
|
|
14
|
+
// When npm global: mutable data goes to ~/.voxlert/
|
|
15
|
+
// When git clone: unchanged behavior (install dir)
|
|
16
|
+
export const CONFIG_PATH = IS_NPM_GLOBAL
|
|
17
|
+
? join(STATE_DIR, "config.json")
|
|
18
|
+
: join(SCRIPT_DIR, "config.json");
|
|
19
|
+
export const PACKS_DIR = IS_NPM_GLOBAL
|
|
20
|
+
? join(STATE_DIR, "packs")
|
|
21
|
+
: join(SCRIPT_DIR, "packs");
|
|
22
|
+
export const CACHE_DIR = IS_NPM_GLOBAL
|
|
23
|
+
? join(STATE_DIR, "cache")
|
|
24
|
+
: join(SCRIPT_DIR, "cache");
|
|
25
|
+
export const COLLECT_DIR = IS_NPM_GLOBAL
|
|
26
|
+
? join(STATE_DIR, "llm_collect")
|
|
27
|
+
: join(SCRIPT_DIR, "llm_collect");
|
|
28
|
+
|
|
29
|
+
// Packs shipped with the npm package (read-only)
|
|
30
|
+
export const BUNDLED_PACKS_DIR = join(SCRIPT_DIR, "packs");
|
|
31
|
+
|
|
32
|
+
export const GLOBAL_USER_CONFIG_PATH = join(STATE_DIR, "config.json");
|
|
33
|
+
export const QUEUE_DIR = join(STATE_DIR, "queue");
|
|
34
|
+
export const LOCK_FILE = join(STATE_DIR, "playback.lock");
|
|
35
|
+
export const LOG_FILE = join(STATE_DIR, "fallback.log");
|
|
36
|
+
export const MAIN_LOG_FILE = join(STATE_DIR, "voxlert.log");
|
|
37
|
+
export const HOOK_DEBUG_LOG = join(STATE_DIR, "hook-debug.log");
|
|
38
|
+
export const USAGE_FILE = join(STATE_DIR, "usage.jsonl");
|
|
39
|
+
export const PACK_VOLUME_CACHE_DIR = join(STATE_DIR, "pack-volume");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// npm postinstall — prints setup instructions only, no file mutations.
|
|
4
|
+
console.log(`
|
|
5
|
+
╔══════════════════════════════════════════════╗
|
|
6
|
+
║ Voxlert installed! ║
|
|
7
|
+
║ ║
|
|
8
|
+
║ Run the setup wizard: ║
|
|
9
|
+
║ ║
|
|
10
|
+
║ voxlert setup ║
|
|
11
|
+
║ ║
|
|
12
|
+
╚══════════════════════════════════════════════╝
|
|
13
|
+
`);
|
package/src/providers.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// LLM provider registry — base URLs, auth patterns, default models, signup URLs.
|
|
2
|
+
// Anthropic uses its own Messages API format; all others are OpenAI-compatible.
|
|
3
|
+
|
|
4
|
+
export const LLM_PROVIDERS = {
|
|
5
|
+
openrouter: {
|
|
6
|
+
name: "OpenRouter",
|
|
7
|
+
description: "200+ models, cheap",
|
|
8
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
9
|
+
defaultModel: "google/gemma-3-27b-it",
|
|
10
|
+
signupUrl: "https://openrouter.ai/keys",
|
|
11
|
+
authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
12
|
+
format: "openai",
|
|
13
|
+
},
|
|
14
|
+
openai: {
|
|
15
|
+
name: "OpenAI",
|
|
16
|
+
description: "GPT-4o-mini",
|
|
17
|
+
baseUrl: "https://api.openai.com/v1",
|
|
18
|
+
defaultModel: "gpt-4o-mini",
|
|
19
|
+
signupUrl: "https://platform.openai.com/api-keys",
|
|
20
|
+
authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
21
|
+
format: "openai",
|
|
22
|
+
},
|
|
23
|
+
gemini: {
|
|
24
|
+
name: "Google Gemini",
|
|
25
|
+
description: "free tier available",
|
|
26
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
27
|
+
defaultModel: "gemini-2.0-flash",
|
|
28
|
+
signupUrl: "https://aistudio.google.com/apikey",
|
|
29
|
+
authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
30
|
+
format: "openai",
|
|
31
|
+
},
|
|
32
|
+
anthropic: {
|
|
33
|
+
name: "Anthropic",
|
|
34
|
+
description: "Claude 3.5 Haiku",
|
|
35
|
+
baseUrl: "https://api.anthropic.com",
|
|
36
|
+
defaultModel: "claude-3-5-haiku-latest",
|
|
37
|
+
signupUrl: "https://console.anthropic.com/settings/keys",
|
|
38
|
+
authHeader: (key) => ({
|
|
39
|
+
"x-api-key": key,
|
|
40
|
+
"anthropic-version": "2023-06-01",
|
|
41
|
+
}),
|
|
42
|
+
format: "anthropic",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function getProvider(id) {
|
|
47
|
+
return LLM_PROVIDERS[id] || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the API key for a given provider from config.
|
|
52
|
+
* Checks unified llm_api_key first, then legacy openrouter_api_key for backward compat.
|
|
53
|
+
*/
|
|
54
|
+
export function getApiKey(config) {
|
|
55
|
+
if (config.llm_api_key) return config.llm_api_key;
|
|
56
|
+
// Backward compat: openrouter_api_key
|
|
57
|
+
if (config.openrouter_api_key) return config.openrouter_api_key;
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the model for a given provider from config.
|
|
63
|
+
*/
|
|
64
|
+
export function getModel(config) {
|
|
65
|
+
const provider = getProvider(config.llm_backend);
|
|
66
|
+
if (config.llm_model) return config.llm_model;
|
|
67
|
+
// Backward compat: openrouter_model
|
|
68
|
+
if (config.llm_backend === "openrouter" && config.openrouter_model) {
|
|
69
|
+
return config.openrouter_model;
|
|
70
|
+
}
|
|
71
|
+
return provider ? provider.defaultModel : "gpt-4o-mini";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the request body for the given provider format.
|
|
76
|
+
*/
|
|
77
|
+
export function formatRequestBody(provider, model, messages, maxTokens, temperature) {
|
|
78
|
+
if (provider.format === "anthropic") {
|
|
79
|
+
// Convert OpenAI-style messages to Anthropic Messages API format
|
|
80
|
+
const system = messages.find((m) => m.role === "system")?.content || "";
|
|
81
|
+
const userMessages = messages
|
|
82
|
+
.filter((m) => m.role !== "system")
|
|
83
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
model,
|
|
86
|
+
max_tokens: maxTokens,
|
|
87
|
+
temperature,
|
|
88
|
+
system,
|
|
89
|
+
messages: userMessages,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// OpenAI-compatible format (openrouter, openai, gemini)
|
|
93
|
+
return JSON.stringify({
|
|
94
|
+
model,
|
|
95
|
+
messages,
|
|
96
|
+
max_tokens: maxTokens,
|
|
97
|
+
temperature,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse the response body from a provider into { phrase, usage }.
|
|
103
|
+
*/
|
|
104
|
+
export function parseResponse(provider, data) {
|
|
105
|
+
if (provider.format === "anthropic") {
|
|
106
|
+
const text = data.content?.[0]?.text || "";
|
|
107
|
+
const usage = data.usage
|
|
108
|
+
? {
|
|
109
|
+
prompt_tokens: data.usage.input_tokens || 0,
|
|
110
|
+
completion_tokens: data.usage.output_tokens || 0,
|
|
111
|
+
total_tokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
|
|
112
|
+
}
|
|
113
|
+
: null;
|
|
114
|
+
return { text, usage };
|
|
115
|
+
}
|
|
116
|
+
// OpenAI-compatible
|
|
117
|
+
const text = data.choices?.[0]?.message?.content || "";
|
|
118
|
+
const usage = data.usage || null;
|
|
119
|
+
return { text, usage };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get the full chat completions endpoint URL for a provider.
|
|
124
|
+
*/
|
|
125
|
+
export function getEndpointUrl(provider) {
|
|
126
|
+
const base = provider.baseUrl.replace(/\/+$/, "");
|
|
127
|
+
if (provider.format === "anthropic") return `${base}/v1/messages`;
|
|
128
|
+
return `${base}/chat/completions`;
|
|
129
|
+
}
|