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.
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/extensions/voice/config.ts +395 -0
- package/extensions/voice/deepgram.ts +33 -0
- package/extensions/voice/device.ts +382 -0
- package/extensions/voice/hold-to-talk.ts +69 -0
- package/extensions/voice/local.ts +1143 -0
- package/extensions/voice/model-download.ts +636 -0
- package/extensions/voice/onboarding.ts +739 -0
- package/extensions/voice/release-controller.ts +55 -0
- package/extensions/voice/settings-panel.ts +1602 -0
- package/extensions/voice/sherpa-engine.ts +464 -0
- package/extensions/voice/sherpa-loader.ts +143 -0
- package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
- package/extensions/voice/speak.ts +430 -0
- package/extensions/voice/tts-deepgram.ts +454 -0
- package/extensions/voice/tts-engine.ts +653 -0
- package/extensions/voice/tts-install-progress.ts +257 -0
- package/extensions/voice/tts-local-models.ts +1255 -0
- package/extensions/voice/tts-onboarding-overlay.ts +186 -0
- package/extensions/voice/tts-onboarding.ts +87 -0
- package/extensions/voice/tts-playback-indicator.ts +127 -0
- package/extensions/voice/tts-playback.ts +675 -0
- package/extensions/voice/tts-text-filter.ts +404 -0
- package/extensions/voice/ui-aura.ts +272 -0
- package/extensions/voice/ui-help-overlay.ts +161 -0
- package/extensions/voice/ui-icons.ts +124 -0
- package/extensions/voice/ui-locale-labels.ts +110 -0
- package/extensions/voice/ui-picker.ts +209 -0
- package/extensions/voice/ui-render-ticker.ts +171 -0
- package/extensions/voice/ui-widget-base.ts +219 -0
- package/extensions/voice/ui-width.ts +112 -0
- package/extensions/voice.ts +3644 -0
- 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
|
+
}
|