pi-voicekit 0.1.0

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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/extensions/voice/config.ts +395 -0
  4. package/extensions/voice/deepgram.ts +33 -0
  5. package/extensions/voice/device.ts +382 -0
  6. package/extensions/voice/hold-to-talk.ts +69 -0
  7. package/extensions/voice/local.ts +1143 -0
  8. package/extensions/voice/model-download.ts +636 -0
  9. package/extensions/voice/onboarding.ts +739 -0
  10. package/extensions/voice/release-controller.ts +55 -0
  11. package/extensions/voice/settings-panel.ts +1602 -0
  12. package/extensions/voice/sherpa-engine.ts +464 -0
  13. package/extensions/voice/sherpa-loader.ts +143 -0
  14. package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
  15. package/extensions/voice/speak.ts +430 -0
  16. package/extensions/voice/tts-deepgram.ts +454 -0
  17. package/extensions/voice/tts-engine.ts +653 -0
  18. package/extensions/voice/tts-install-progress.ts +257 -0
  19. package/extensions/voice/tts-local-models.ts +1255 -0
  20. package/extensions/voice/tts-onboarding-overlay.ts +186 -0
  21. package/extensions/voice/tts-onboarding.ts +87 -0
  22. package/extensions/voice/tts-playback-indicator.ts +127 -0
  23. package/extensions/voice/tts-playback.ts +675 -0
  24. package/extensions/voice/tts-text-filter.ts +404 -0
  25. package/extensions/voice/ui-aura.ts +272 -0
  26. package/extensions/voice/ui-help-overlay.ts +161 -0
  27. package/extensions/voice/ui-icons.ts +124 -0
  28. package/extensions/voice/ui-locale-labels.ts +110 -0
  29. package/extensions/voice/ui-picker.ts +209 -0
  30. package/extensions/voice/ui-render-ticker.ts +171 -0
  31. package/extensions/voice/ui-widget-base.ts +219 -0
  32. package/extensions/voice/ui-width.ts +112 -0
  33. package/extensions/voice.ts +3644 -0
  34. package/package.json +75 -0
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Device detection — auto-detect hardware profile for smart model recommendations.
3
+ *
4
+ * Detects:
5
+ * - RAM (container-aware via cgroup fallback)
6
+ * - Raspberry Pi model (via /proc/device-tree/model)
7
+ * - GPU (NVIDIA via nvidia-smi, Apple Metal via platform+arch)
8
+ * - Container environment (Docker, cgroups)
9
+ * - System locale for language auto-detection
10
+ */
11
+
12
+ import * as os from "node:os";
13
+ import * as fs from "node:fs";
14
+ import { spawnSync } from "node:child_process";
15
+ import type { LocalModelInfo } from "./local";
16
+ import { languagesForLangSupport } from "./local";
17
+
18
+ // ─── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ export interface DeviceProfile {
21
+ platform: NodeJS.Platform;
22
+ arch: string;
23
+ totalRamMB: number;
24
+ freeRamMB: number;
25
+ cpuCores: number;
26
+ cpuModel: string;
27
+ isRaspberryPi: boolean;
28
+ piModel?: string;
29
+ gpu: {
30
+ hasNvidia: boolean;
31
+ hasMetal: boolean;
32
+ vramMB?: number;
33
+ gpuName?: string;
34
+ };
35
+ isContainer: boolean;
36
+ systemLocale: string;
37
+ }
38
+
39
+ export type ModelFitness = "recommended" | "compatible" | "warning" | "incompatible";
40
+
41
+ // ─── Detection ───────────────────────────────────────────────────────────────
42
+
43
+ /** Detect the current device profile. Synchronous — all checks are fast. */
44
+ export function detectDevice(): DeviceProfile {
45
+ const platform = process.platform;
46
+ const arch = process.arch;
47
+ // os.cpus() can return undefined on Alpine/restricted containers
48
+ const cpuList = os.cpus() || [];
49
+ const cpuCores = cpuList.length || 1;
50
+ const cpuModel = cpuList[0]?.model || "unknown";
51
+
52
+ // RAM — container-aware
53
+ const isContainer = detectContainer();
54
+ const hostRamMB = Math.round(os.totalmem() / (1024 * 1024));
55
+ const totalRamMB = isContainer ? getContainerRamMB(hostRamMB) : hostRamMB;
56
+ // Use MemAvailable from /proc/meminfo on Linux (more accurate than os.freemem()
57
+ // which returns MemFree, ignoring reclaimable buffer/cache memory)
58
+ const freeRamMB = getAvailableRamMB();
59
+
60
+ // Raspberry Pi
61
+ const piInfo = detectRaspberryPi();
62
+
63
+ // GPU
64
+ const gpu = detectGPU(platform, arch);
65
+
66
+ // Locale
67
+ const systemLocale = detectLocale();
68
+
69
+ return {
70
+ platform,
71
+ arch,
72
+ totalRamMB,
73
+ freeRamMB,
74
+ cpuCores,
75
+ cpuModel,
76
+ isRaspberryPi: piInfo.isRPi,
77
+ piModel: piInfo.model,
78
+ gpu,
79
+ isContainer,
80
+ systemLocale,
81
+ };
82
+ }
83
+
84
+ // ─── Model fitness scoring ───────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Score how well a model fits this device.
88
+ *
89
+ * "recommended" is reserved for preferred models (best-in-class for their
90
+ * language/use case) that fit comfortably. All other runnable models are
91
+ * "compatible". This prevents every model from showing [recommended] on
92
+ * machines with plenty of RAM.
93
+ */
94
+ export function getModelFitness(model: LocalModelInfo, device: DeviceProfile): ModelFitness {
95
+ const runtimeRamMB = model.runtimeRamMB ?? estimateRuntimeRam(model.sizeBytes);
96
+ const ratio = runtimeRamMB / device.totalRamMB;
97
+
98
+ // >80% of total RAM — won't run
99
+ if (ratio > 0.8) return "incompatible";
100
+ // >60% of total RAM — will run but may cause swapping
101
+ if (ratio > 0.6) return "warning";
102
+ // Preferred models that fit comfortably get "recommended"
103
+ if (model.preferred && ratio < 0.4) return "recommended";
104
+ return "compatible";
105
+ }
106
+
107
+ /** Estimate runtime RAM from download size (bytes) — ~2.5x model file size. */
108
+ function estimateRuntimeRam(sizeBytes?: number): number {
109
+ if (!sizeBytes) return 500; // Conservative default
110
+ return Math.round((sizeBytes / (1024 * 1024)) * 2.5);
111
+ }
112
+
113
+ /**
114
+ * Auto-recommend the best model for a device + language combination.
115
+ * Prioritizes: language fit → device fitness → accuracy (larger is better within recommended).
116
+ */
117
+ export function autoRecommendModel(
118
+ models: LocalModelInfo[],
119
+ device: DeviceProfile,
120
+ language: string
121
+ ): LocalModelInfo | undefined {
122
+ // Filter by language support
123
+ const langModels = models.filter((m) => modelSupportsLanguage(m, language));
124
+ if (langModels.length === 0) return undefined;
125
+
126
+ // Score each model
127
+ const scored = langModels.map((m) => ({
128
+ model: m,
129
+ fitness: getModelFitness(m, device),
130
+ size: m.sizeBytes || 0,
131
+ }));
132
+
133
+ // Prefer recommended > compatible > warning, then largest within tier (more accurate)
134
+ const fitnessOrder: Record<ModelFitness, number> = {
135
+ recommended: 0,
136
+ compatible: 1,
137
+ warning: 2,
138
+ incompatible: 3,
139
+ };
140
+
141
+ scored.sort((a, b) => {
142
+ const fitDiff = fitnessOrder[a.fitness] - fitnessOrder[b.fitness];
143
+ if (fitDiff !== 0) return fitDiff;
144
+ // Within same fitness tier, prefer larger (more accurate)
145
+ return b.size - a.size;
146
+ });
147
+
148
+ // Don't recommend incompatible models
149
+ const best = scored[0];
150
+ if (best && best.fitness !== "incompatible") return best.model;
151
+
152
+ // Fallback: smallest model regardless
153
+ return scored[scored.length - 1]?.model;
154
+ }
155
+
156
+ /** Check if a model supports a given language code. */
157
+ function modelSupportsLanguage(model: LocalModelInfo, langCode: string): boolean {
158
+ // Single source of truth: resolve the shared capability table by langSupport
159
+ // (local.ts languagesForLangSupport), not by model.id against the global
160
+ // catalog — unknown/custom models whose langSupport is unregistered
161
+ // conservatively resolve to no languages, avoiding fail-open. Both the
162
+ // earlier parallel switch and the by-id lookup let new language families
163
+ // drift into "supports every language".
164
+ const base = langCode.split("-")[0];
165
+ const languages = languagesForLangSupport(model.langSupport);
166
+ return languages.some((l) => l.code === base || l.code === langCode);
167
+ }
168
+
169
+ /** Format device profile as a short summary string. */
170
+ export function formatDeviceSummary(device: DeviceProfile): string {
171
+ const parts: string[] = [];
172
+
173
+ // RAM
174
+ const ramGB = (device.totalRamMB / 1024).toFixed(1);
175
+ parts.push(`${ramGB} GB RAM`);
176
+
177
+ // Platform/arch
178
+ parts.push(device.arch);
179
+
180
+ // RPi
181
+ if (device.isRaspberryPi && device.piModel) {
182
+ parts.push(device.piModel);
183
+ } else {
184
+ const platformNames: Record<string, string> = {
185
+ darwin: "macOS",
186
+ linux: "Linux",
187
+ win32: "Windows",
188
+ };
189
+ parts.push(platformNames[device.platform] || device.platform);
190
+ }
191
+
192
+ // GPU
193
+ if (device.gpu.hasNvidia && device.gpu.gpuName) {
194
+ parts.push(device.gpu.gpuName);
195
+ } else if (device.gpu.hasMetal) {
196
+ parts.push("Apple Silicon");
197
+ }
198
+
199
+ // Container
200
+ if (device.isContainer) {
201
+ parts.push("container");
202
+ }
203
+
204
+ return parts.join(", ");
205
+ }
206
+
207
+ // ─── Internal detection helpers ──────────────────────────────────────────────
208
+
209
+ /**
210
+ * Get available RAM in MB — uses /proc/meminfo MemAvailable on Linux
211
+ * (includes reclaimable buffer/cache), falls back to os.freemem() elsewhere.
212
+ *
213
+ * os.freemem() returns MemFree on Linux, which excludes buffer/cache and is
214
+ * often ~500MB even on a 32GB machine. MemAvailable is what the kernel considers
215
+ * actually available for new processes.
216
+ */
217
+ function getAvailableRamMB(): number {
218
+ if (process.platform === "linux") {
219
+ try {
220
+ const meminfo = fs.readFileSync("/proc/meminfo", "utf-8");
221
+ const match = meminfo.match(/^MemAvailable:\s+(\d+)\s+kB$/m);
222
+ if (match) {
223
+ return Math.round(parseInt(match[1]!, 10) / 1024);
224
+ }
225
+ } catch {
226
+ // Fallback to os.freemem()
227
+ }
228
+ }
229
+ return Math.round(os.freemem() / (1024 * 1024));
230
+ }
231
+
232
+ function detectContainer(): boolean {
233
+ try {
234
+ if (fs.existsSync("/.dockerenv")) return true;
235
+ if (fs.existsSync("/run/.containerenv")) return true; // Podman
236
+ const cgroup = fs.readFileSync("/proc/1/cgroup", "utf-8");
237
+ if (cgroup.includes("docker") || cgroup.includes("kubepods") || cgroup.includes("containerd")) return true;
238
+ // cgroup v2: check /proc/self/mountinfo for container indicators
239
+ if (cgroup.trim() === "0::/") {
240
+ try {
241
+ const mountinfo = fs.readFileSync("/proc/self/mountinfo", "utf-8");
242
+ if (mountinfo.includes("/docker/") || mountinfo.includes("/containers/")) return true;
243
+ } catch {
244
+ // Not accessible
245
+ }
246
+ }
247
+ } catch {
248
+ // Not Linux or no permissions
249
+ }
250
+ return false;
251
+ }
252
+
253
+ function getContainerRamMB(hostRamMB: number): number {
254
+ // Try cgroup v2 first, then v1
255
+ const paths = [
256
+ "/sys/fs/cgroup/memory.max", // cgroup v2
257
+ "/sys/fs/cgroup/memory/memory.limit_in_bytes", // cgroup v1
258
+ ];
259
+ for (const p of paths) {
260
+ try {
261
+ const raw = fs.readFileSync(p, "utf-8").trim();
262
+ // "max" = cgroup v2 unlimited
263
+ if (raw === "max") continue;
264
+ // cgroup v1 unlimited: LLONG_MAX or page-aligned variants (64-bit and 32-bit)
265
+ // Use string comparison to avoid parseInt precision loss on values > MAX_SAFE_INTEGER
266
+ if (raw === "9223372036854775807" || raw === "9223372036854771712") continue;
267
+ const bytes = parseInt(raw, 10);
268
+ if (!Number.isFinite(bytes) || bytes <= 0) continue;
269
+ // Heuristic: if cgroup value exceeds host RAM, treat as unlimited
270
+ // Catches 32-bit sentinels (~2 GB) and any non-standard page-aligned variants
271
+ const mb = Math.round(bytes / (1024 * 1024));
272
+ if (mb >= hostRamMB) continue;
273
+ return mb;
274
+ } catch {
275
+ // File not accessible
276
+ }
277
+ }
278
+ return hostRamMB;
279
+ }
280
+
281
+ function detectRaspberryPi(): { isRPi: boolean; model?: string } {
282
+ // Method 1: /proc/device-tree/model (most reliable)
283
+ try {
284
+ const model = fs.readFileSync("/proc/device-tree/model", "utf-8").replace(/\0/g, "").trim();
285
+ if (model.toLowerCase().includes("raspberry pi")) {
286
+ return { isRPi: true, model };
287
+ }
288
+ } catch {
289
+ // Not available
290
+ }
291
+
292
+ // Method 2: /proc/cpuinfo BCM chip
293
+ try {
294
+ const cpuinfo = fs.readFileSync("/proc/cpuinfo", "utf-8");
295
+ if (cpuinfo.includes("BCM2")) {
296
+ // Prefer "Model" field (human-readable) over "Hardware" (just shows BCM2835 for all models)
297
+ const modelMatch = cpuinfo.match(/^Model\s*:\s*(.+)$/m);
298
+ const hwMatch = cpuinfo.match(/^Hardware\s*:\s*(.+)$/m);
299
+ return { isRPi: true, model: modelMatch?.[1]?.trim() || hwMatch?.[1]?.trim() };
300
+ }
301
+ } catch {
302
+ // Not available
303
+ }
304
+
305
+ // Method 3: ARM64 + Debian/Raspbian heuristic
306
+ if (process.arch === "arm64" || process.arch === "arm") {
307
+ try {
308
+ const release = fs.readFileSync("/etc/os-release", "utf-8");
309
+ if (release.includes("Raspbian") || release.includes("raspberry")) {
310
+ return { isRPi: true };
311
+ }
312
+ } catch {
313
+ // Not available
314
+ }
315
+ }
316
+
317
+ return { isRPi: false };
318
+ }
319
+
320
+ function detectGPU(platform: NodeJS.Platform, arch: string): DeviceProfile["gpu"] {
321
+ const result: DeviceProfile["gpu"] = {
322
+ hasNvidia: false,
323
+ hasMetal: false,
324
+ };
325
+
326
+ // Apple Metal — macOS + ARM64
327
+ if (platform === "darwin" && arch === "arm64") {
328
+ result.hasMetal = true;
329
+ }
330
+
331
+ // NVIDIA — skip expensive nvidia-smi if no GPU device file exists (Linux)
332
+ if (platform === "linux" && !fs.existsSync("/dev/nvidiactl")) {
333
+ return result;
334
+ }
335
+
336
+ // NVIDIA — try nvidia-smi (2s timeout)
337
+ try {
338
+ const nv = spawnSync("nvidia-smi", ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], {
339
+ timeout: 2000,
340
+ encoding: "utf-8",
341
+ stdio: ["pipe", "pipe", "pipe"],
342
+ });
343
+
344
+ if (nv.status === 0 && nv.stdout) {
345
+ const line = nv.stdout.trim().split("\n")[0];
346
+ if (line) {
347
+ const [name, vram] = line.split(",").map((s) => s?.trim());
348
+ result.hasNvidia = true;
349
+ result.gpuName = name;
350
+ result.vramMB = vram ? parseInt(vram, 10) : undefined;
351
+ }
352
+ }
353
+ } catch {
354
+ // nvidia-smi not available
355
+ }
356
+
357
+ return result;
358
+ }
359
+
360
+ function detectLocale(): string {
361
+ try {
362
+ const resolved = Intl.DateTimeFormat().resolvedOptions().locale;
363
+ if (resolved) return resolved;
364
+ } catch {
365
+ // Fallback
366
+ }
367
+
368
+ // Try environment
369
+ const envLocale = process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES;
370
+ if (envLocale) {
371
+ // "en_US.UTF-8" → "en-US"
372
+ const base = envLocale.split(".")[0];
373
+ return base?.replace("_", "-") || "en";
374
+ }
375
+
376
+ return "en";
377
+ }
378
+
379
+ /** Extract the base language code from a system locale (e.g. "en-US" → "en"). */
380
+ export function localeToLanguageCode(locale: string): string {
381
+ return locale.split("-")[0] || "en";
382
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Hold-to-talk release-detection policy — extracted from voice.ts so the
3
+ * state-machine decisions are unit-testable.
4
+ *
5
+ * Background (upstream #13): terminals without key-release events
6
+ * (e.g. Ghostty on macOS in non-kitty modes) signal "key released" purely by
7
+ * a GAP in key-repeat events. Every repeat event must therefore re-arm the
8
+ * release-detect timer; otherwise recording state locks up forever — the
9
+ * timer is cleared at recording start and nothing ever re-arms it.
10
+ *
11
+ * Kitty-protocol terminals DO emit real key-release events, so gap-based
12
+ * detection is unnecessary there — arming it would just make an occasional
13
+ * repeat pause look like a release.
14
+ */
15
+
16
+ export type HoldState = "idle" | "warmup" | "recording" | "finalizing";
17
+
18
+ export interface SpaceHoldContext {
19
+ voiceState: HoldState;
20
+ kittyReleaseDetected: boolean;
21
+ }
22
+
23
+ /**
24
+ * Whether a SPACE key-repeat arriving during recording/finalizing must re-arm
25
+ * the gap-based release-detect timer before being consumed.
26
+ *
27
+ * - Kitty terminals: true key-release events exist → never arm (a repeat
28
+ * pause must not be mistaken for a release).
29
+ * - Non-kitty terminals: the only "release" signal is a gap in repeats, so
30
+ * every repeat re-arms the 250ms timer — the #13 fix.
31
+ *
32
+ * The hold-counter path (spaceDownTime && !holdConfirmed) re-arms in its own
33
+ * branch at the call site and is intentionally NOT covered here.
34
+ */
35
+ export function shouldArmReleaseDetectOnRepeat(ctx: SpaceHoldContext): boolean {
36
+ if (ctx.kittyReleaseDetected) return false;
37
+ // Only re-arm once recording is fully ready (recording/finalizing). Warmup
38
+ // or a startup gap with only spaceConsumed set never arms — a timer armed
39
+ // here would expire into onSpaceReleaseDetected's warmup-cancel branch and
40
+ // produce a false stop (upstream PATH B comment is explicit: never re-arm
41
+ // during startup; recovery is delegated to the unified arm once recording
42
+ // is ready plus the repeat stream kept alive here).
43
+ return ctx.voiceState === "recording" || ctx.voiceState === "finalizing";
44
+ }
45
+
46
+ export type RecordingStartTimerAction = "arm" | "clear";
47
+
48
+ /**
49
+ * What to do with the release-detect timer once recording is actually ready.
50
+ *
51
+ * Called ONLY after the recording state-machine has fully transitioned to
52
+ * "recording" (successful start). Arm it before that and a slow async startup
53
+ * would hit onSpaceReleaseDetected's warmup branch — a false "early release".
54
+ *
55
+ * - isHold (spaceDownTime set — hold-to-talk session): non-kitty terminals arm
56
+ * a fresh (250ms) timer — key-up is detected purely by a gap in repeats,
57
+ * and repeats keep re-arming it from here on. This also covers releases that
58
+ * happen during the startup window. Kitty terminals stay clear (real
59
+ * key-release event governs).
60
+ * - toggle/dictation sessions (no spaceDownTime): NEVER arm — there is no
61
+ * repeat stream keeping the timer alive, so arming would auto-stop them
62
+ * 250ms after start.
63
+ */
64
+ export function decideRecordingStartTimer(ctx: {
65
+ kittyReleaseDetected: boolean;
66
+ isHold: boolean;
67
+ }): RecordingStartTimerAction {
68
+ return !ctx.kittyReleaseDetected && ctx.isHold ? "arm" : "clear";
69
+ }