@lawaty/lacode 1.0.3 → 1.2.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.
@@ -0,0 +1,2351 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/lacode-voice
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawn as spawn2 } from "node:child_process";
6
+ import { createWriteStream, existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, rmSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { homedir as homedir3 } from "node:os";
9
+ import { dirname, join as join4 } from "node:path";
10
+ import * as net from "node:net";
11
+ import { Readable, Transform } from "node:stream";
12
+ import { pipeline } from "node:stream/promises";
13
+
14
+ // src/voice/config.ts
15
+ import { join as join2 } from "node:path";
16
+ import { homedir as homedir2 } from "node:os";
17
+ import { readFileSync as readFileSync2 } from "node:fs";
18
+
19
+ // src/voice/groq.ts
20
+ import { readFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ var DEFAULT_READ_FILE = (p) => {
24
+ try {
25
+ return readFileSync(p, "utf8");
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+ function resolveGroqKey(deps) {
31
+ const env = deps?.env ?? process.env;
32
+ const home = deps?.home ?? homedir();
33
+ const readFile = deps?.readFile ?? DEFAULT_READ_FILE;
34
+ const safeRead2 = (p) => {
35
+ try {
36
+ return readFile(p);
37
+ } catch {
38
+ return null;
39
+ }
40
+ };
41
+ const envVal = env?.GROQ_KEY;
42
+ if (typeof envVal === "string") {
43
+ const trimmed = envVal.trim();
44
+ if (trimmed !== "") return { key: trimmed, source: "env" };
45
+ }
46
+ const filePath = join(home, ".config", "lacode", "voice.json");
47
+ const fileContent = safeRead2(filePath);
48
+ if (typeof fileContent === "string") {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(fileContent);
52
+ } catch {
53
+ parsed = void 0;
54
+ }
55
+ if (parsed && typeof parsed === "object") {
56
+ const obj = parsed;
57
+ const keys = ["groqKey", "GROQ_KEY", "apiKey", "groq_key"];
58
+ for (const k of keys) {
59
+ const v = obj[k];
60
+ if (typeof v === "string") {
61
+ const t = v.trim();
62
+ if (t !== "") return { key: t, source: "file" };
63
+ }
64
+ }
65
+ }
66
+ }
67
+ const bashFiles = [join(home, ".bashrc"), join(home, ".bash_profile"), join(home, ".profile")];
68
+ const skipPatterns = ["$(", "`", "$GROQ", "$HOME"];
69
+ const regex = /^\s*(?:export\s+)?GROQ_KEY\s*=\s*(.+?)\s*$/;
70
+ for (const f of bashFiles) {
71
+ const content = safeRead2(f);
72
+ if (typeof content !== "string") continue;
73
+ const lines = content.split(/\r?\n/);
74
+ for (const line of lines) {
75
+ const m = regex.exec(line);
76
+ if (!m) continue;
77
+ let value = m[1];
78
+ const isSingle = value.startsWith("'") && value.endsWith("'") && value.length >= 2;
79
+ const isDouble = value.startsWith('"') && value.endsWith('"') && value.length >= 2;
80
+ if (isSingle || isDouble) {
81
+ value = value.slice(1, -1);
82
+ } else {
83
+ value = value.replace(/\s+#.*$/, "");
84
+ value = value.trim();
85
+ }
86
+ let skip = false;
87
+ for (const p of skipPatterns) {
88
+ if (value.includes(p)) {
89
+ skip = true;
90
+ break;
91
+ }
92
+ }
93
+ if (skip) continue;
94
+ if (value === "") continue;
95
+ return { key: value, source: "bashrc" };
96
+ }
97
+ }
98
+ return void 0;
99
+ }
100
+ async function transcribe(input) {
101
+ if (!input.audio || input.audio.length === 0) {
102
+ throw new Error("no audio captured");
103
+ }
104
+ const filename = input.filename ?? "audio.wav";
105
+ const model = input.model ?? "whisper-large-v3-turbo";
106
+ const language = input.language ?? "en";
107
+ const baseUrl = input.baseUrl ?? "https://api.groq.com/openai/v1";
108
+ const timeoutMs = input.timeoutMs ?? 3e4;
109
+ const fetchImpl = input.fetchImpl ?? fetch;
110
+ const form = new FormData();
111
+ const blob = new Blob([input.audio], { type: "audio/wav" });
112
+ form.append("file", blob, filename);
113
+ form.append("model", model);
114
+ form.append("language", language);
115
+ form.append("temperature", "0");
116
+ form.append("response_format", "json");
117
+ if (input.prompt) form.append("prompt", input.prompt);
118
+ const url = `${baseUrl}/audio/transcriptions`;
119
+ const res = await fetchImpl(url, {
120
+ method: "POST",
121
+ headers: { Authorization: `Bearer ${input.key}` },
122
+ body: form,
123
+ signal: AbortSignal.timeout(timeoutMs)
124
+ });
125
+ if (!res.ok) {
126
+ try {
127
+ await res.text();
128
+ } catch {
129
+ }
130
+ throw new Error(`groq transcription failed (HTTP ${res.status})`);
131
+ }
132
+ let json;
133
+ try {
134
+ json = await res.json();
135
+ } catch {
136
+ return "";
137
+ }
138
+ const text = json?.text;
139
+ if (typeof text !== "string" || text.trim() === "") return "";
140
+ return text.trim();
141
+ }
142
+
143
+ // src/voice/config.ts
144
+ var DEFAULT_WAKE_PHRASES = ["hey la code"];
145
+ var VOICE_DATA_DIR = (deps) => {
146
+ const env = deps?.env ?? process.env;
147
+ const home = deps?.home ?? homedir2();
148
+ const xdg = env.XDG_DATA_HOME;
149
+ if (typeof xdg === "string" && xdg.trim() !== "") {
150
+ return join2(xdg.trim(), "lacode", "voice");
151
+ }
152
+ return join2(home, ".local", "share", "lacode", "voice");
153
+ };
154
+ var DEFAULT_READ_FILE2 = (p) => {
155
+ try {
156
+ return readFileSync2(p, "utf8");
157
+ } catch {
158
+ return null;
159
+ }
160
+ };
161
+ var VOICE_CONFIG_PATH = (home) => join2(home ?? homedir2(), ".config", "lacode", "voice.json");
162
+ function safeRead(readFile, p) {
163
+ try {
164
+ return readFile(p);
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+ function envGet(env, names) {
170
+ if (!env) return void 0;
171
+ for (const n of names) {
172
+ const v = env[n];
173
+ if (typeof v === "string") return v;
174
+ }
175
+ return void 0;
176
+ }
177
+ function toInt(raw) {
178
+ if (typeof raw === "number") {
179
+ return Number.isInteger(raw) ? raw : void 0;
180
+ }
181
+ if (typeof raw === "string") {
182
+ const t = raw.trim();
183
+ if (t === "" || !/^-?\d+$/.test(t)) return void 0;
184
+ const n = Number(t);
185
+ return Number.isInteger(n) ? n : void 0;
186
+ }
187
+ return void 0;
188
+ }
189
+ function toFloat(raw) {
190
+ if (typeof raw === "number") {
191
+ return Number.isFinite(raw) ? raw : void 0;
192
+ }
193
+ if (typeof raw === "string") {
194
+ const t = raw.trim();
195
+ if (t === "") return void 0;
196
+ const n = Number(t);
197
+ return Number.isFinite(n) ? n : void 0;
198
+ }
199
+ return void 0;
200
+ }
201
+ function expandHome(value, home) {
202
+ const v = value.trim();
203
+ if (v === "~") return home;
204
+ if (v.startsWith("~/")) return join2(home, v.slice(2));
205
+ return v;
206
+ }
207
+ function isLoopbackHost(host) {
208
+ const h = host.trim().toLowerCase();
209
+ return h === "127.0.0.1" || h === "::1" || h === "localhost";
210
+ }
211
+ function normalizeEngine(raw) {
212
+ if (typeof raw !== "string") return void 0;
213
+ const v = raw.trim().toLowerCase();
214
+ if (v === "vosk" || v === "porcupine") return v;
215
+ return void 0;
216
+ }
217
+ function normalizePhrases(raw) {
218
+ if (!Array.isArray(raw)) return void 0;
219
+ const out = [];
220
+ for (const entry of raw) {
221
+ if (typeof entry !== "string") continue;
222
+ const v = entry.trim();
223
+ if (v !== "") out.push(v);
224
+ }
225
+ return out.length > 0 ? out : void 0;
226
+ }
227
+ function parsePhraseEnv(raw) {
228
+ return normalizePhrases(raw.split(","));
229
+ }
230
+ var PORT_ENV = ["LACODE_VOICE_PORT", "LACODE_PORT", "VOICE_PORT"];
231
+ var SENS_ENV = ["LACODE_WAKE_SENSITIVITY", "LACODE_VOICE_SENSITIVITY", "VOICE_SENSITIVITY", "LACODE_SENSITIVITY"];
232
+ var MODEL_ENV = ["LACODE_VOICE_MODEL", "LACODE_MODEL", "VOICE_MODEL", "LACODE_VOICE_TRANSCRIPTION_MODEL"];
233
+ var LANG_ENV = ["LACODE_VOICE_LANGUAGE", "LACODE_LANGUAGE", "LACODE_VOICE_LANG", "VOICE_LANGUAGE"];
234
+ var PK_ENV = ["PICOVOICE_ACCESS_KEY", "PICOVOICE_KEY", "LACODE_PICOVOICE_KEY"];
235
+ var WW_ENV = ["LACODE_WAKE_WORD", "LACODE_VOICE_WAKE_WORD", "LACODE_WAKE_WORD_PATH", "LACODE_VOICE_WAKEWORD"];
236
+ var INPUT_ENV = ["LACODE_VOICE_INPUT", "LACODE_INPUT", "VOICE_INPUT"];
237
+ var SR_ENV = ["LACODE_VOICE_SAMPLE_RATE", "LACODE_SAMPLE_RATE"];
238
+ var HOST_ENV = ["LACODE_VOICE_HOST", "LACODE_HOST"];
239
+ function resolveVoiceConfig(deps) {
240
+ const env = deps?.env ?? process.env;
241
+ const home = deps?.home ?? homedir2();
242
+ const defaults = {
243
+ wakeSensitivity: 0.5,
244
+ wakeEngine: "vosk",
245
+ voskModelPath: join2(VOICE_DATA_DIR({ env, home }), "vosk-model-small-en-us-0.15"),
246
+ wakePhrases: [...DEFAULT_WAKE_PHRASES],
247
+ port: 8765,
248
+ host: "127.0.0.1",
249
+ model: "whisper-large-v3-turbo",
250
+ language: "en",
251
+ armOnStart: true,
252
+ sampleRate: 16e3
253
+ };
254
+ try {
255
+ const readFile = deps?.readFile ?? DEFAULT_READ_FILE2;
256
+ const filePath = VOICE_CONFIG_PATH(home);
257
+ const content = safeRead(readFile, filePath);
258
+ let json = null;
259
+ if (typeof content === "string") {
260
+ try {
261
+ const parsed = JSON.parse(content);
262
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
263
+ json = parsed;
264
+ }
265
+ } catch {
266
+ json = null;
267
+ }
268
+ }
269
+ const j = json ?? {};
270
+ let groqKey;
271
+ let groqSource;
272
+ try {
273
+ const resolved = resolveGroqKey({ env, home, readFile });
274
+ if (resolved) {
275
+ groqKey = resolved.key;
276
+ groqSource = resolved.source;
277
+ }
278
+ } catch {
279
+ groqKey = void 0;
280
+ groqSource = void 0;
281
+ }
282
+ let picovoiceKey;
283
+ const pkEnv = envGet(env, PK_ENV);
284
+ if (typeof pkEnv === "string" && pkEnv.trim() !== "") {
285
+ picovoiceKey = pkEnv.trim();
286
+ } else {
287
+ for (const k of ["picovoiceKey", "picovoice_access_key", "picovoiceAccessKey"]) {
288
+ const v = j[k];
289
+ if (typeof v === "string" && v.trim() !== "") {
290
+ picovoiceKey = v.trim();
291
+ break;
292
+ }
293
+ }
294
+ }
295
+ let wakeWordPath;
296
+ const wwEnv = envGet(env, WW_ENV);
297
+ if (typeof wwEnv === "string" && wwEnv.trim() !== "") {
298
+ wakeWordPath = expandHome(wwEnv, home);
299
+ } else {
300
+ for (const k of ["wakeWordPath", "wake_word_path", "wakeWord"]) {
301
+ const v = j[k];
302
+ if (typeof v === "string" && v.trim() !== "") {
303
+ wakeWordPath = expandHome(v, home);
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ const sensEnv = envGet(env, SENS_ENV);
309
+ const sensRaw = sensEnv !== void 0 ? sensEnv : j.wakeSensitivity ?? j.wake_sensitivity;
310
+ const sensNum = toFloat(sensRaw);
311
+ let wakeSensitivity = 0.5;
312
+ if (sensNum !== void 0) {
313
+ wakeSensitivity = Math.min(1, Math.max(0, sensNum));
314
+ }
315
+ let wakeEngine = "vosk";
316
+ const engineEnv = env.LACODE_WAKE_ENGINE;
317
+ if (typeof engineEnv === "string" && engineEnv.trim() !== "") {
318
+ wakeEngine = normalizeEngine(engineEnv) ?? "vosk";
319
+ } else {
320
+ wakeEngine = normalizeEngine(j.wakeEngine) ?? "vosk";
321
+ }
322
+ let voskModelPath = join2(VOICE_DATA_DIR({ env, home }), "vosk-model-small-en-us-0.15");
323
+ const voskEnv = env.LACODE_VOSK_MODEL;
324
+ if (typeof voskEnv === "string" && voskEnv.trim() !== "") {
325
+ voskModelPath = expandHome(voskEnv, home);
326
+ } else {
327
+ const vv = j.voskModelPath;
328
+ if (typeof vv === "string" && vv.trim() !== "") {
329
+ voskModelPath = expandHome(vv, home);
330
+ }
331
+ }
332
+ let wakePhrases = [...DEFAULT_WAKE_PHRASES];
333
+ const phraseEnv = env.LACODE_WAKE_PHRASES;
334
+ const envPhrases = typeof phraseEnv === "string" ? parsePhraseEnv(phraseEnv) : void 0;
335
+ if (envPhrases) {
336
+ wakePhrases = envPhrases;
337
+ } else {
338
+ const jsonPhrases = normalizePhrases(j.wakePhrases);
339
+ if (jsonPhrases) wakePhrases = jsonPhrases;
340
+ }
341
+ const portEnv = envGet(env, PORT_ENV);
342
+ const portRaw = portEnv !== void 0 ? portEnv : j.port;
343
+ const portNum = toInt(portRaw);
344
+ let port = 8765;
345
+ if (portNum !== void 0 && portNum >= 1 && portNum <= 65535) {
346
+ port = portNum;
347
+ }
348
+ const allowNonLocal = typeof env.LACODE_VOICE_ALLOW_NONLOCAL === "string" && ["1", "true", "True", "TRUE"].includes(env.LACODE_VOICE_ALLOW_NONLOCAL.trim()) || j.allowNonLocal === true;
349
+ let host = "127.0.0.1";
350
+ const hostEnv = envGet(env, HOST_ENV);
351
+ if (typeof hostEnv === "string" && hostEnv.trim() !== "") {
352
+ const h = hostEnv.trim();
353
+ if (isLoopbackHost(h) || allowNonLocal) {
354
+ host = h;
355
+ } else {
356
+ host = "127.0.0.1";
357
+ }
358
+ } else {
359
+ const hv = j.host;
360
+ if (typeof hv === "string" && hv.trim() !== "") {
361
+ const h = hv.trim();
362
+ if (isLoopbackHost(h) || allowNonLocal) {
363
+ host = h;
364
+ } else {
365
+ host = "127.0.0.1";
366
+ }
367
+ }
368
+ }
369
+ let model = "whisper-large-v3-turbo";
370
+ const modelEnv = envGet(env, MODEL_ENV);
371
+ if (typeof modelEnv === "string" && modelEnv.trim() !== "") {
372
+ model = modelEnv.trim();
373
+ } else {
374
+ const mv = j.model;
375
+ if (typeof mv === "string" && mv.trim() !== "") {
376
+ model = mv.trim();
377
+ }
378
+ }
379
+ let language = "en";
380
+ const langEnv = envGet(env, LANG_ENV);
381
+ if (typeof langEnv === "string" && langEnv.trim() !== "") {
382
+ language = langEnv.trim();
383
+ } else {
384
+ const lv = j.language;
385
+ if (typeof lv === "string" && lv.trim() !== "") {
386
+ language = lv.trim();
387
+ }
388
+ }
389
+ let armOnStart = true;
390
+ const jsonArm = j.armOnStart ?? j.arm_on_start;
391
+ if (typeof jsonArm === "boolean") {
392
+ armOnStart = jsonArm;
393
+ }
394
+ const noArm = env.LACODE_VOICE_NO_ARM;
395
+ if (noArm === "1" || noArm === "true" || noArm === "True" || noArm === "TRUE") {
396
+ armOnStart = false;
397
+ }
398
+ const srEnv = envGet(env, SR_ENV);
399
+ const srRaw = srEnv !== void 0 ? srEnv : j.sampleRate ?? j.sample_rate;
400
+ const srNum = toInt(srRaw);
401
+ let sampleRate = 16e3;
402
+ if (srNum !== void 0 && srNum > 0) {
403
+ sampleRate = srNum;
404
+ }
405
+ let input;
406
+ const inputEnv = envGet(env, INPUT_ENV);
407
+ if (typeof inputEnv === "string" && inputEnv.trim() !== "") {
408
+ input = inputEnv.trim();
409
+ } else {
410
+ const iv = j.input;
411
+ if (typeof iv === "string" && iv.trim() !== "") {
412
+ input = iv.trim();
413
+ }
414
+ }
415
+ return {
416
+ groqKey,
417
+ groqSource,
418
+ picovoiceKey,
419
+ wakeWordPath,
420
+ wakeSensitivity,
421
+ wakeEngine,
422
+ voskModelPath,
423
+ wakePhrases,
424
+ port,
425
+ host,
426
+ model,
427
+ language,
428
+ armOnStart,
429
+ sampleRate,
430
+ input
431
+ };
432
+ } catch {
433
+ return { ...defaults };
434
+ }
435
+ }
436
+
437
+ // src/voice/capture.ts
438
+ import { spawn } from "node:child_process";
439
+ import { existsSync } from "node:fs";
440
+ import * as path from "node:path";
441
+ function resolveBinary(name, deps) {
442
+ const env = deps?.env ?? {};
443
+ const exists = deps?.exists ?? ((f) => {
444
+ try {
445
+ return existsSync(f);
446
+ } catch {
447
+ return false;
448
+ }
449
+ });
450
+ if (exists(name)) return name;
451
+ const pathEnv = env.PATH ?? process.env.PATH ?? "";
452
+ const dirs = pathEnv.split(path.delimiter).filter((d) => d.length > 0).concat(["/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"]);
453
+ for (const dir of dirs) {
454
+ const candidate = path.join(dir, name);
455
+ if (exists(candidate)) return candidate;
456
+ }
457
+ return void 0;
458
+ }
459
+ function sanitizeVoiceInput(input, fallback) {
460
+ if (input === void 0) return fallback;
461
+ const trimmed = typeof input === "string" ? input.trim() : String(input).trim();
462
+ if (trimmed === "") return fallback;
463
+ if (trimmed.startsWith("-")) return fallback;
464
+ return trimmed;
465
+ }
466
+ function planCapture(deps) {
467
+ const platform = deps?.platform ?? process.platform;
468
+ const env = deps?.env ?? {};
469
+ const input = env.LACODE_VOICE_INPUT;
470
+ const ffmpeg = resolveBinary("ffmpeg", deps);
471
+ const parec = resolveBinary("parec", deps);
472
+ const arecord = resolveBinary("arecord", deps);
473
+ if (platform === "linux") {
474
+ if (ffmpeg) {
475
+ const value = sanitizeVoiceInput(input, "default");
476
+ return {
477
+ bin: ffmpeg,
478
+ args: [
479
+ "-hide_banner",
480
+ "-loglevel",
481
+ "error",
482
+ "-f",
483
+ "pulse",
484
+ "-i",
485
+ value,
486
+ "-ac",
487
+ "1",
488
+ "-ar",
489
+ "16000",
490
+ "-f",
491
+ "s16le",
492
+ "pipe:1"
493
+ ]
494
+ };
495
+ }
496
+ if (parec) {
497
+ return {
498
+ bin: parec,
499
+ args: ["--format=s16le", "--rate=16000", "--channels=1", "--raw"]
500
+ };
501
+ }
502
+ if (arecord) {
503
+ return {
504
+ bin: arecord,
505
+ args: ["-q", "-t", "raw", "-f", "S16_LE", "-r", "16000", "-c", "1"]
506
+ };
507
+ }
508
+ return void 0;
509
+ }
510
+ if (platform === "darwin") {
511
+ if (ffmpeg) {
512
+ const value = sanitizeVoiceInput(input, ":0");
513
+ return {
514
+ bin: ffmpeg,
515
+ args: [
516
+ "-hide_banner",
517
+ "-loglevel",
518
+ "error",
519
+ "-f",
520
+ "avfoundation",
521
+ "-i",
522
+ value,
523
+ "-ac",
524
+ "1",
525
+ "-ar",
526
+ "16000",
527
+ "-f",
528
+ "s16le",
529
+ "pipe:1"
530
+ ]
531
+ };
532
+ }
533
+ return void 0;
534
+ }
535
+ if (platform === "win32") {
536
+ if (ffmpeg) {
537
+ const value = sanitizeVoiceInput(input, "audio=default");
538
+ return {
539
+ bin: ffmpeg,
540
+ args: [
541
+ "-hide_banner",
542
+ "-loglevel",
543
+ "error",
544
+ "-f",
545
+ "dshow",
546
+ "-i",
547
+ value,
548
+ "-ac",
549
+ "1",
550
+ "-ar",
551
+ "16000",
552
+ "-f",
553
+ "s16le",
554
+ "pipe:1"
555
+ ]
556
+ };
557
+ }
558
+ return void 0;
559
+ }
560
+ return void 0;
561
+ }
562
+ function createCapture(onChunk, deps) {
563
+ const spawnImpl = deps?.spawnImpl ?? spawn;
564
+ const plan = planCapture(deps);
565
+ let running = false;
566
+ let child = void 0;
567
+ const capture = {
568
+ get running() {
569
+ return running;
570
+ },
571
+ get plan() {
572
+ return plan;
573
+ },
574
+ start() {
575
+ if (running) return;
576
+ if (!plan) return;
577
+ if (child) {
578
+ try {
579
+ child.kill?.("SIGKILL");
580
+ } catch {
581
+ }
582
+ child = void 0;
583
+ }
584
+ let c;
585
+ try {
586
+ c = spawnImpl(plan.bin, plan.args, { stdio: ["ignore", "pipe", "ignore"] });
587
+ } catch {
588
+ running = false;
589
+ child = void 0;
590
+ return;
591
+ }
592
+ child = c;
593
+ running = true;
594
+ try {
595
+ const stdout = c?.stdout;
596
+ if (stdout && typeof stdout.on === "function") {
597
+ stdout.on("data", (chunk) => {
598
+ try {
599
+ onChunk(chunk);
600
+ } catch {
601
+ }
602
+ });
603
+ }
604
+ } catch {
605
+ }
606
+ try {
607
+ if (c && typeof c.on === "function") {
608
+ c.on("error", () => {
609
+ running = false;
610
+ });
611
+ c.on("exit", () => {
612
+ running = false;
613
+ });
614
+ c.on("close", () => {
615
+ running = false;
616
+ child = void 0;
617
+ });
618
+ }
619
+ } catch {
620
+ }
621
+ },
622
+ stop() {
623
+ const c = child;
624
+ child = void 0;
625
+ running = false;
626
+ if (!c) return;
627
+ let timer;
628
+ const cleanup = () => {
629
+ if (timer) {
630
+ clearTimeout(timer);
631
+ timer = void 0;
632
+ }
633
+ };
634
+ try {
635
+ if (typeof c.on === "function") {
636
+ c.on("close", cleanup);
637
+ c.on("exit", cleanup);
638
+ }
639
+ } catch {
640
+ }
641
+ try {
642
+ if (typeof c.kill === "function") c.kill("SIGTERM");
643
+ } catch {
644
+ cleanup();
645
+ }
646
+ timer = setTimeout(() => {
647
+ try {
648
+ if (typeof c.kill === "function") c.kill("SIGKILL");
649
+ } catch {
650
+ }
651
+ }, 1500);
652
+ try {
653
+ timer?.unref?.();
654
+ } catch {
655
+ }
656
+ try {
657
+ c.unref?.();
658
+ } catch {
659
+ }
660
+ try {
661
+ c.stdout?.destroy?.();
662
+ } catch {
663
+ }
664
+ }
665
+ };
666
+ return capture;
667
+ }
668
+ function createFrameSplitter(samples, onFrame) {
669
+ if (!Number.isInteger(samples) || samples <= 0) {
670
+ throw new Error("samples must be > 0");
671
+ }
672
+ const frameBytes = samples * 2;
673
+ let buf = Buffer.alloc(0);
674
+ return (chunk) => {
675
+ buf = buf.length > 0 ? Buffer.concat([buf, chunk]) : Buffer.from(chunk);
676
+ let off = 0;
677
+ while (off + frameBytes <= buf.length) {
678
+ const slice = buf.subarray(off, off + frameBytes);
679
+ const frame = new Int16Array(samples);
680
+ for (let i = 0; i < samples; i++) {
681
+ frame[i] = slice.readInt16LE(i * 2);
682
+ }
683
+ try {
684
+ onFrame(frame);
685
+ } catch {
686
+ }
687
+ off += frameBytes;
688
+ }
689
+ buf = Buffer.from(buf.subarray(off));
690
+ };
691
+ }
692
+
693
+ // src/voice/vad.ts
694
+ function rmsOf(frame) {
695
+ if (frame.length === 0) return 0;
696
+ let sum = 0;
697
+ for (let i = 0; i < frame.length; i++) {
698
+ const v = frame[i];
699
+ sum += v * v;
700
+ }
701
+ return Math.sqrt(sum / frame.length);
702
+ }
703
+ function concatFrames(frames) {
704
+ let total = 0;
705
+ for (const f of frames) total += f.length;
706
+ const out = new Int16Array(total);
707
+ let off = 0;
708
+ for (const f of frames) {
709
+ out.set(f, off);
710
+ off += f.length;
711
+ }
712
+ return out;
713
+ }
714
+ function createVad(config) {
715
+ const sampleRate = config?.sampleRate ?? 16e3;
716
+ const speechRms = config?.speechRms ?? 500;
717
+ const noiseMultiplier = config?.noiseMultiplier ?? 2.5;
718
+ const endSilenceMs = config?.endSilenceMs ?? 800;
719
+ const minUtteranceMs = config?.minUtteranceMs ?? 250;
720
+ const maxUtteranceMs = config?.maxUtteranceMs ?? 3e4;
721
+ const preRollMs = config?.preRollMs ?? 300;
722
+ const preRollSamples = Math.max(0, Math.round(preRollMs / 1e3 * sampleRate));
723
+ let noiseFloor = 200;
724
+ const alpha = 0.05;
725
+ let speaking = false;
726
+ let speechFrameCount = 0;
727
+ const ring = [];
728
+ let ringSamples = 0;
729
+ const accumulated = [];
730
+ let accumulatedSamples = 0;
731
+ const trailingSilence = [];
732
+ let silenceMs = 0;
733
+ function frameMs(n) {
734
+ return n / sampleRate * 1e3;
735
+ }
736
+ function resetState() {
737
+ speaking = false;
738
+ speechFrameCount = 0;
739
+ ring.length = 0;
740
+ ringSamples = 0;
741
+ accumulated.length = 0;
742
+ accumulatedSamples = 0;
743
+ trailingSilence.length = 0;
744
+ silenceMs = 0;
745
+ noiseFloor = 200;
746
+ }
747
+ function trimRing() {
748
+ while (ring.length > 1 && ringSamples > preRollSamples) {
749
+ const oldest = ring.shift();
750
+ ringSamples -= oldest.length;
751
+ }
752
+ }
753
+ function finalizeUtterance(emit, removeTrailing) {
754
+ let frames = accumulated.slice();
755
+ if (removeTrailing && trailingSilence.length > 0) {
756
+ const removeCount = trailingSilence.length;
757
+ frames = frames.slice(0, frames.length - removeCount);
758
+ }
759
+ const audio = concatFrames(frames);
760
+ const durMs = audio.length / sampleRate * 1e3;
761
+ if (durMs >= minUtteranceMs) {
762
+ emit({ t: "utterance", audio });
763
+ }
764
+ accumulated.length = 0;
765
+ accumulatedSamples = 0;
766
+ trailingSilence.length = 0;
767
+ silenceMs = 0;
768
+ }
769
+ const vad = {
770
+ get speaking() {
771
+ return speaking;
772
+ },
773
+ push(frame) {
774
+ const events = [];
775
+ const emit = (e) => events.push(e);
776
+ ring.push(frame);
777
+ ringSamples += frame.length;
778
+ trimRing();
779
+ const rms = rmsOf(frame);
780
+ const threshold = Math.max(speechRms, noiseFloor * noiseMultiplier);
781
+ const isSpeech = rms > threshold;
782
+ const durMs = frameMs(frame.length);
783
+ if (!speaking) {
784
+ if (isSpeech) {
785
+ speechFrameCount++;
786
+ if (speechFrameCount >= 3) {
787
+ speaking = true;
788
+ accumulated.length = 0;
789
+ accumulatedSamples = 0;
790
+ for (const f of ring) {
791
+ accumulated.push(f);
792
+ accumulatedSamples += f.length;
793
+ }
794
+ trailingSilence.length = 0;
795
+ silenceMs = 0;
796
+ emit({ t: "speech-start" });
797
+ }
798
+ } else {
799
+ speechFrameCount = 0;
800
+ noiseFloor = noiseFloor + alpha * (rms - noiseFloor);
801
+ }
802
+ } else {
803
+ if (isSpeech) {
804
+ accumulated.push(frame);
805
+ accumulatedSamples += frame.length;
806
+ trailingSilence.length = 0;
807
+ silenceMs = 0;
808
+ } else {
809
+ accumulated.push(frame);
810
+ accumulatedSamples += frame.length;
811
+ trailingSilence.push(frame);
812
+ silenceMs += durMs;
813
+ if (silenceMs >= endSilenceMs) {
814
+ finalizeUtterance(emit, true);
815
+ speaking = false;
816
+ speechFrameCount = 0;
817
+ }
818
+ }
819
+ if (speaking) {
820
+ const accDurMs = accumulatedSamples / sampleRate * 1e3;
821
+ if (accDurMs >= maxUtteranceMs) {
822
+ finalizeUtterance(emit, false);
823
+ silenceMs = 0;
824
+ trailingSilence.length = 0;
825
+ }
826
+ }
827
+ }
828
+ return events;
829
+ },
830
+ reset() {
831
+ resetState();
832
+ }
833
+ };
834
+ return vad;
835
+ }
836
+
837
+ // src/voice/wake.ts
838
+ import { existsSync as existsSync2 } from "node:fs";
839
+ var WakeConfigError = class extends Error {
840
+ code;
841
+ constructor(code, message) {
842
+ super(message);
843
+ this.name = "WakeConfigError";
844
+ this.code = code;
845
+ }
846
+ };
847
+ var BUILTIN_NAMES = [
848
+ "alexa",
849
+ "americano",
850
+ "blueberry",
851
+ "bumblebee",
852
+ "computer",
853
+ "grapefruit",
854
+ "grasshopper",
855
+ "hey google",
856
+ "hey siri",
857
+ "jarvis",
858
+ "ok google",
859
+ "picovoice",
860
+ "porcupine",
861
+ "terminator"
862
+ ];
863
+ function isBlank(v) {
864
+ return typeof v !== "string" || v.trim() === "";
865
+ }
866
+ function clampSensitivity(v) {
867
+ const n = typeof v === "number" ? v : Number(v);
868
+ const base = Number.isFinite(n) ? n : 0.5;
869
+ return Math.min(1, Math.max(0, base));
870
+ }
871
+ function coerceToInt16Array(input) {
872
+ if (input instanceof Int16Array) return input;
873
+ if (input == null) return null;
874
+ if (typeof input === "number" || typeof input === "string" || typeof input === "boolean") {
875
+ return null;
876
+ }
877
+ try {
878
+ if (input instanceof ArrayBuffer) {
879
+ return new Int16Array(input);
880
+ }
881
+ if (ArrayBuffer.isView(input)) {
882
+ const view = input;
883
+ return new Int16Array(view.buffer, view.byteOffset, view.byteLength / 2);
884
+ }
885
+ return new Int16Array(input);
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+ async function createWakeDetector(options, deps) {
891
+ const accessKey = options?.accessKey;
892
+ if (isBlank(accessKey)) {
893
+ throw new WakeConfigError(
894
+ "no-key",
895
+ "Missing Picovoice access key: set picovoiceKey in ~/.config/lacode/voice.json or PICOVOICE_ACCESS_KEY."
896
+ );
897
+ }
898
+ const keywordPath = options?.keywordPath;
899
+ if (isBlank(keywordPath)) {
900
+ throw new WakeConfigError(
901
+ "no-model",
902
+ "Missing wake word model: download a .ppn model and set wakeWordPath."
903
+ );
904
+ }
905
+ const trimmed = String(keywordPath).trim();
906
+ const lower = trimmed.toLowerCase();
907
+ let resolvedPath = "";
908
+ let isBuiltin = false;
909
+ if (BUILTIN_NAMES.includes(lower)) {
910
+ isBuiltin = true;
911
+ }
912
+ const exists = deps?.exists ?? ((p) => {
913
+ try {
914
+ return existsSync2(p);
915
+ } catch {
916
+ return false;
917
+ }
918
+ });
919
+ if (isBuiltin) {
920
+ } else {
921
+ resolvedPath = trimmed;
922
+ if (!exists(resolvedPath)) {
923
+ throw new WakeConfigError(
924
+ "no-model",
925
+ `Wake word model not found at resolved path: ${resolvedPath}`
926
+ );
927
+ }
928
+ }
929
+ const load = deps?.load ?? (() => import("@picovoice/porcupine-node"));
930
+ let mod;
931
+ try {
932
+ mod = await load();
933
+ } catch (err) {
934
+ throw new WakeConfigError(
935
+ "no-package",
936
+ "Missing @picovoice/porcupine-node: run `npm i @picovoice/porcupine-node`."
937
+ );
938
+ }
939
+ const ns = mod?.default && mod?.Porcupine == null ? mod.default : mod;
940
+ const PorcupineCtor = ns?.Porcupine ?? mod?.Porcupine;
941
+ const getBuiltinKeywordPath = ns?.getBuiltinKeywordPath ?? mod?.getBuiltinKeywordPath;
942
+ if (isBuiltin) {
943
+ if (typeof getBuiltinKeywordPath !== "function") {
944
+ throw new WakeConfigError("init-failed", "getBuiltinKeywordPath unavailable");
945
+ }
946
+ resolvedPath = getBuiltinKeywordPath(lower);
947
+ }
948
+ if (typeof PorcupineCtor !== "function") {
949
+ throw new WakeConfigError("init-failed", "Porcupine constructor unavailable");
950
+ }
951
+ const sensitivity = clampSensitivity(options?.sensitivity);
952
+ let instance;
953
+ try {
954
+ instance = new PorcupineCtor(accessKey, [resolvedPath], [sensitivity]);
955
+ } catch (err) {
956
+ const msg = err?.message != null ? String(err.message) : String(err);
957
+ throw new WakeConfigError("init-failed", `Failed to initialize wake detector: ${msg}`);
958
+ }
959
+ let released = false;
960
+ const detector = {
961
+ get frameLength() {
962
+ return instance.frameLength;
963
+ },
964
+ process(frame) {
965
+ const coerced = coerceToInt16Array(frame);
966
+ if (coerced == null) return false;
967
+ if (coerced.length !== instance.frameLength) return false;
968
+ try {
969
+ return instance.process(coerced) >= 0;
970
+ } catch {
971
+ return false;
972
+ }
973
+ },
974
+ release() {
975
+ if (released) return;
976
+ released = true;
977
+ try {
978
+ instance.release();
979
+ } catch {
980
+ }
981
+ }
982
+ };
983
+ return detector;
984
+ }
985
+
986
+ // src/voice/wake-vosk.ts
987
+ import { existsSync as existsSync3 } from "node:fs";
988
+ var DEFAULT_PHRASES = ["hey la code"];
989
+ var DEFAULT_SAMPLE_RATE = 16e3;
990
+ var FRAME_LENGTH = 512;
991
+ var COOLDOWN_SAMPLES = 16e3;
992
+ function normalizeWakeText(text) {
993
+ if (typeof text !== "string") return "";
994
+ return text.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
995
+ }
996
+ function matchesWakePhrase(text, phrases) {
997
+ const haystack = ` ${normalizeWakeText(text)} `;
998
+ if (haystack.trim() === "") return false;
999
+ for (const phrase of phrases ?? []) {
1000
+ const needle = normalizeWakeText(phrase);
1001
+ if (needle !== "" && haystack.includes(` ${needle} `)) return true;
1002
+ }
1003
+ return false;
1004
+ }
1005
+ var readResultField = (value, field) => {
1006
+ if (!value) return "";
1007
+ if (typeof value === "string") {
1008
+ try {
1009
+ const parsed = JSON.parse(value);
1010
+ return parsed && typeof parsed === "object" ? String(parsed[field] ?? "") : "";
1011
+ } catch {
1012
+ return "";
1013
+ }
1014
+ }
1015
+ if (typeof value === "object") return String(value[field] ?? "");
1016
+ return "";
1017
+ };
1018
+ function safeCall(fn) {
1019
+ try {
1020
+ return fn();
1021
+ } catch {
1022
+ return void 0;
1023
+ }
1024
+ }
1025
+ function errorMessage(err) {
1026
+ return err != null && typeof err === "object" && "message" in err ? String(err.message) : String(err);
1027
+ }
1028
+ async function createVoskWakeDetector(options, deps) {
1029
+ const rawPath = options?.modelPath;
1030
+ if (typeof rawPath !== "string" || rawPath.trim() === "") {
1031
+ throw new WakeConfigError(
1032
+ "no-model",
1033
+ "Missing Vosk model: set voskModelPath in ~/.config/lacode/voice.json."
1034
+ );
1035
+ }
1036
+ const modelPath = rawPath.trim();
1037
+ const exists = deps?.exists ?? ((p) => {
1038
+ try {
1039
+ return existsSync3(p);
1040
+ } catch {
1041
+ return false;
1042
+ }
1043
+ });
1044
+ if (!exists(modelPath)) {
1045
+ throw new WakeConfigError("no-model", `Vosk model not found at resolved path: ${modelPath}`);
1046
+ }
1047
+ const phrases = Array.isArray(options?.phrases) ? options.phrases : DEFAULT_PHRASES;
1048
+ const sampleRate = typeof options?.sampleRate === "number" && Number.isFinite(options.sampleRate) && options.sampleRate > 0 ? options.sampleRate : DEFAULT_SAMPLE_RATE;
1049
+ const load = deps?.load ?? (() => import("vosk"));
1050
+ let mod;
1051
+ try {
1052
+ mod = await load();
1053
+ } catch {
1054
+ throw new WakeConfigError("no-package", "Missing vosk: run `npm i vosk`.");
1055
+ }
1056
+ const ns = mod?.default && mod?.Model == null ? mod.default : mod;
1057
+ const ModelCtor = ns?.Model ?? mod?.Model;
1058
+ const RecognizerCtor = ns?.Recognizer ?? mod?.Recognizer;
1059
+ if (typeof ModelCtor !== "function" || typeof RecognizerCtor !== "function") {
1060
+ throw new WakeConfigError("init-failed", "Vosk Model/Recognizer constructor unavailable");
1061
+ }
1062
+ let model;
1063
+ let recognizer;
1064
+ try {
1065
+ model = new ModelCtor(modelPath);
1066
+ recognizer = new RecognizerCtor({ model, sampleRate, grammar: phrases });
1067
+ } catch (err) {
1068
+ try {
1069
+ recognizer?.free?.();
1070
+ } catch {
1071
+ }
1072
+ try {
1073
+ model?.free?.();
1074
+ } catch {
1075
+ }
1076
+ throw new WakeConfigError(
1077
+ "init-failed",
1078
+ `Failed to initialize Vosk wake detector: ${errorMessage(err)}`
1079
+ );
1080
+ }
1081
+ let released = false;
1082
+ let cooldown = 0;
1083
+ const resetRecognizer = () => {
1084
+ try {
1085
+ if (typeof recognizer?.reset === "function") {
1086
+ recognizer.reset();
1087
+ return;
1088
+ }
1089
+ recognizer = new RecognizerCtor({ model, sampleRate, grammar: phrases });
1090
+ } catch {
1091
+ }
1092
+ };
1093
+ const detector = {
1094
+ frameLength: FRAME_LENGTH,
1095
+ process(frame) {
1096
+ const length = frame != null && typeof frame.length === "number" ? frame.length : NaN;
1097
+ if (cooldown > 0) {
1098
+ const dec = typeof length === "number" && Number.isFinite(length) && length > 0 ? length : FRAME_LENGTH;
1099
+ cooldown = Math.max(0, cooldown - dec);
1100
+ return false;
1101
+ }
1102
+ if (!(frame instanceof Int16Array) || frame.length !== FRAME_LENGTH) return false;
1103
+ let isFinal = false;
1104
+ try {
1105
+ const buffer = Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength);
1106
+ isFinal = Boolean(recognizer.acceptWaveform(buffer));
1107
+ } catch {
1108
+ return false;
1109
+ }
1110
+ const partialText = normalizeWakeText(
1111
+ readResultField(safeCall(() => recognizer.partialResult()), "partial")
1112
+ );
1113
+ const finalText = isFinal ? normalizeWakeText(readResultField(safeCall(() => recognizer.result()), "text")) : "";
1114
+ if (matchesWakePhrase(partialText, phrases) || matchesWakePhrase(finalText, phrases)) {
1115
+ resetRecognizer();
1116
+ cooldown = COOLDOWN_SAMPLES;
1117
+ return true;
1118
+ }
1119
+ return false;
1120
+ },
1121
+ release() {
1122
+ if (released) return;
1123
+ released = true;
1124
+ try {
1125
+ recognizer?.free?.();
1126
+ } catch {
1127
+ }
1128
+ try {
1129
+ model?.free?.();
1130
+ } catch {
1131
+ }
1132
+ }
1133
+ };
1134
+ return detector;
1135
+ }
1136
+
1137
+ // src/voice/wav.ts
1138
+ var DEFAULT_SAMPLE_RATE2 = 16e3;
1139
+ var DEFAULT_CHANNELS = 1;
1140
+ var MIN_SAMPLE_RATE = 8e3;
1141
+ var MAX_SAMPLE_RATE = 48e3;
1142
+ var MIN_CHANNELS = 1;
1143
+ var MAX_CHANNELS = 2;
1144
+ function normalizeFormat(format) {
1145
+ let sampleRate = format?.sampleRate;
1146
+ if (typeof sampleRate !== "number" || !Number.isFinite(sampleRate) || sampleRate < 0) {
1147
+ sampleRate = DEFAULT_SAMPLE_RATE2;
1148
+ }
1149
+ if (sampleRate < MIN_SAMPLE_RATE || sampleRate > MAX_SAMPLE_RATE) {
1150
+ sampleRate = DEFAULT_SAMPLE_RATE2;
1151
+ }
1152
+ let channels = format?.channels;
1153
+ if (typeof channels !== "number" || !Number.isFinite(channels) || channels < 0) {
1154
+ channels = DEFAULT_CHANNELS;
1155
+ }
1156
+ if (channels < MIN_CHANNELS || channels > MAX_CHANNELS) {
1157
+ channels = DEFAULT_CHANNELS;
1158
+ }
1159
+ channels = Math.trunc(channels);
1160
+ if (channels < MIN_CHANNELS || channels > MAX_CHANNELS) channels = DEFAULT_CHANNELS;
1161
+ sampleRate = Math.trunc(sampleRate);
1162
+ return { sampleRate, channels };
1163
+ }
1164
+ function encodeWav(pcm, format) {
1165
+ const { sampleRate, channels } = normalizeFormat(format);
1166
+ const dataSize = pcm.length * 2;
1167
+ const total = 44 + dataSize;
1168
+ const out = new Uint8Array(total);
1169
+ const view = new DataView(out.buffer);
1170
+ out[0] = 82;
1171
+ out[1] = 73;
1172
+ out[2] = 70;
1173
+ out[3] = 70;
1174
+ view.setUint32(4, 36 + dataSize, true);
1175
+ out[8] = 87;
1176
+ out[9] = 65;
1177
+ out[10] = 86;
1178
+ out[11] = 69;
1179
+ out[12] = 102;
1180
+ out[13] = 109;
1181
+ out[14] = 116;
1182
+ out[15] = 32;
1183
+ view.setUint32(16, 16, true);
1184
+ view.setUint16(20, 1, true);
1185
+ view.setUint16(22, channels, true);
1186
+ view.setUint32(24, sampleRate, true);
1187
+ view.setUint32(28, sampleRate * channels * 2, true);
1188
+ view.setUint16(32, channels * 2, true);
1189
+ view.setUint16(34, 16, true);
1190
+ out[36] = 100;
1191
+ out[37] = 97;
1192
+ out[38] = 116;
1193
+ out[39] = 97;
1194
+ view.setUint32(40, dataSize, true);
1195
+ let off = 44;
1196
+ for (let i = 0; i < pcm.length; i++) {
1197
+ view.setInt16(off, pcm[i], true);
1198
+ off += 2;
1199
+ }
1200
+ return out;
1201
+ }
1202
+
1203
+ // src/voice/audio.ts
1204
+ function pcmStats(pcm, sampleRate = 16e3) {
1205
+ const samples = pcm ? pcm.length : 0;
1206
+ let sumSq = 0;
1207
+ let peak = 0;
1208
+ if (pcm) {
1209
+ for (let i = 0; i < pcm.length; i++) {
1210
+ const v = pcm[i];
1211
+ sumSq += v * v;
1212
+ const a = v < 0 ? -v : v;
1213
+ if (a > peak) peak = a;
1214
+ }
1215
+ }
1216
+ const rms = samples === 0 ? 0 : Math.sqrt(sumSq / samples);
1217
+ const durationMs = sampleRate > 0 ? samples / sampleRate * 1e3 : 0;
1218
+ return { samples, durationMs, rms, peak };
1219
+ }
1220
+ function hasSpeech(pcm, options) {
1221
+ const sampleRate = options?.sampleRate ?? 16e3;
1222
+ const minDurationMs = options?.minDurationMs ?? 250;
1223
+ const minRms = options?.minRms ?? 400;
1224
+ const minPeak = options?.minPeak ?? 1500;
1225
+ const stats = pcmStats(pcm, sampleRate);
1226
+ return stats.durationMs >= minDurationMs && stats.rms >= minRms && stats.peak >= minPeak;
1227
+ }
1228
+ var SILENCE_HALLUCINATIONS = /* @__PURE__ */ new Set([
1229
+ "thank you",
1230
+ "thanks",
1231
+ "thanks for watching",
1232
+ "thank you for watching",
1233
+ "thank you for watching!",
1234
+ "you",
1235
+ "bye",
1236
+ "bye bye",
1237
+ "please subscribe",
1238
+ "subscribe",
1239
+ "amara.org",
1240
+ "subtitles by",
1241
+ "the end",
1242
+ "okay",
1243
+ "ok"
1244
+ ]);
1245
+ function isSilenceHallucination(text, durationMs) {
1246
+ const normalised = String(text).toLowerCase().replace(/[^a-z0-9.\s]/g, "").replace(/\s+/g, " ").trim().replace(/\.$/, "");
1247
+ return durationMs < 3e3 && SILENCE_HALLUCINATIONS.has(normalised);
1248
+ }
1249
+
1250
+ // src/voice/transport.ts
1251
+ import * as nodeNet from "node:net";
1252
+
1253
+ // src/voice/protocol.ts
1254
+ var VOICE_PROTOCOL_VERSION = 1;
1255
+ function isRecord(value) {
1256
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1257
+ }
1258
+ function toText(line) {
1259
+ return typeof line === "string" ? line : line.toString("utf8");
1260
+ }
1261
+ function normalizeLine(line) {
1262
+ let s = toText(line);
1263
+ if (s.endsWith("\n")) s = s.slice(0, -1);
1264
+ if (s.endsWith("\r")) s = s.slice(0, -1);
1265
+ return s;
1266
+ }
1267
+ function encodeMessage(message) {
1268
+ return JSON.stringify(message) + "\n";
1269
+ }
1270
+ function decodeClientMessage(line) {
1271
+ const text = normalizeLine(line);
1272
+ if (text.trim() === "") return void 0;
1273
+ let parsed;
1274
+ try {
1275
+ parsed = JSON.parse(text);
1276
+ } catch {
1277
+ return void 0;
1278
+ }
1279
+ if (!isRecord(parsed)) return void 0;
1280
+ const t = parsed.t;
1281
+ switch (t) {
1282
+ case "hello": {
1283
+ if (typeof parsed.v !== "number") return void 0;
1284
+ if (parsed.client !== void 0 && typeof parsed.client !== "string") return void 0;
1285
+ return { t: "hello", v: parsed.v, client: parsed.client };
1286
+ }
1287
+ case "ping":
1288
+ return { t: "ping" };
1289
+ case "toggle":
1290
+ return { t: "toggle" };
1291
+ case "arm":
1292
+ return { t: "arm" };
1293
+ case "disarm":
1294
+ return { t: "disarm" };
1295
+ case "claim":
1296
+ return { t: "claim" };
1297
+ default:
1298
+ return void 0;
1299
+ }
1300
+ }
1301
+ function decodeDaemonMessage(line) {
1302
+ const text = normalizeLine(line);
1303
+ if (text.trim() === "") return void 0;
1304
+ let parsed;
1305
+ try {
1306
+ parsed = JSON.parse(text);
1307
+ } catch {
1308
+ return void 0;
1309
+ }
1310
+ if (!isRecord(parsed)) return void 0;
1311
+ const t = parsed.t;
1312
+ switch (t) {
1313
+ case "welcome": {
1314
+ if (typeof parsed.v !== "number") return void 0;
1315
+ if (typeof parsed.armed !== "boolean") return void 0;
1316
+ if (typeof parsed.wake !== "boolean") return void 0;
1317
+ return { t: "welcome", v: parsed.v, armed: parsed.armed, wake: parsed.wake };
1318
+ }
1319
+ case "state": {
1320
+ if (typeof parsed.armed !== "boolean") return void 0;
1321
+ if (parsed.phase !== "idle" && parsed.phase !== "recording" && parsed.phase !== "transcribing") return void 0;
1322
+ return { t: "state", armed: parsed.armed, phase: parsed.phase };
1323
+ }
1324
+ case "transcript": {
1325
+ if (typeof parsed.text !== "string") return void 0;
1326
+ if (parsed.source !== "wake" && parsed.source !== "manual") return void 0;
1327
+ return { t: "transcript", text: parsed.text, source: parsed.source };
1328
+ }
1329
+ case "error": {
1330
+ if (typeof parsed.message !== "string") return void 0;
1331
+ return { t: "error", message: parsed.message };
1332
+ }
1333
+ case "pong":
1334
+ return { t: "pong" };
1335
+ default:
1336
+ return void 0;
1337
+ }
1338
+ }
1339
+ function createLineDecoder(decode) {
1340
+ const MAX_LINE = 64 * 1024;
1341
+ let buffer = "";
1342
+ return (chunk) => {
1343
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1344
+ buffer += text;
1345
+ const out = [];
1346
+ while (true) {
1347
+ const idx = buffer.indexOf("\n");
1348
+ if (idx === -1) break;
1349
+ const line = buffer.slice(0, idx);
1350
+ buffer = buffer.slice(idx + 1);
1351
+ if (line.length > MAX_LINE) {
1352
+ buffer = "";
1353
+ break;
1354
+ }
1355
+ const msg = decode(line);
1356
+ if (msg !== void 0) out.push(msg);
1357
+ }
1358
+ if (buffer.length > MAX_LINE) {
1359
+ buffer = "";
1360
+ }
1361
+ return out;
1362
+ };
1363
+ }
1364
+
1365
+ // src/voice/transport.ts
1366
+ function createVoiceServer(options, deps) {
1367
+ const portOpt = options.port ?? 0;
1368
+ const host = options.host ?? "127.0.0.1";
1369
+ const onMessage = options.onMessage;
1370
+ const onClientCount = options.onClientCount;
1371
+ const onClientClosed = options.onClientClosed;
1372
+ const onClientOpened = options.onClientOpened;
1373
+ const netMod = deps && deps.net ? deps.net : nodeNet;
1374
+ const clients = /* @__PURE__ */ new Map();
1375
+ let nextClientId = 1;
1376
+ let server;
1377
+ let boundPort;
1378
+ let started = false;
1379
+ let stopped = false;
1380
+ const closeClient = (client) => {
1381
+ if (client.closed) return;
1382
+ client.closed = true;
1383
+ if (clients.delete(client.id)) {
1384
+ try {
1385
+ onClientCount?.(clients.size);
1386
+ } catch {
1387
+ }
1388
+ }
1389
+ try {
1390
+ onClientClosed?.(client.id);
1391
+ } catch {
1392
+ }
1393
+ };
1394
+ const broadcast = (message) => {
1395
+ try {
1396
+ const data = encodeMessage(message);
1397
+ for (const client of [...clients.values()]) {
1398
+ try {
1399
+ const sock = client.socket;
1400
+ if (sock && !sock.destroyed && sock.writable) {
1401
+ sock.write(data);
1402
+ }
1403
+ } catch {
1404
+ }
1405
+ }
1406
+ } catch {
1407
+ }
1408
+ };
1409
+ const sendTo = (id, message) => {
1410
+ try {
1411
+ const client = clients.get(id);
1412
+ if (!client || client.closed) return false;
1413
+ const sock = client.socket;
1414
+ if (!sock || sock.destroyed || !sock.writable) return false;
1415
+ sock.write(encodeMessage(message));
1416
+ return true;
1417
+ } catch {
1418
+ return false;
1419
+ }
1420
+ };
1421
+ const voiceServer = {
1422
+ get clientCount() {
1423
+ return clients.size;
1424
+ },
1425
+ get port() {
1426
+ return boundPort;
1427
+ },
1428
+ start() {
1429
+ if (started) {
1430
+ return Promise.resolve(boundPort !== void 0 ? boundPort : portOpt);
1431
+ }
1432
+ started = true;
1433
+ return new Promise((resolve, reject) => {
1434
+ let srv;
1435
+ try {
1436
+ srv = netMod.createServer((sock) => {
1437
+ const client = {
1438
+ id: nextClientId++,
1439
+ remote: sock.remoteAddress ?? "",
1440
+ socket: sock,
1441
+ closed: false
1442
+ };
1443
+ clients.set(client.id, client);
1444
+ try {
1445
+ onClientCount?.(clients.size);
1446
+ } catch {
1447
+ }
1448
+ try {
1449
+ onClientOpened?.(client.id);
1450
+ } catch {
1451
+ }
1452
+ const decoder = createLineDecoder(decodeClientMessage);
1453
+ sock.on("data", (chunk) => {
1454
+ try {
1455
+ const msgs = decoder(chunk);
1456
+ for (const m of msgs) {
1457
+ try {
1458
+ onMessage?.(m, { id: client.id, remote: client.remote });
1459
+ } catch {
1460
+ }
1461
+ }
1462
+ } catch {
1463
+ }
1464
+ });
1465
+ sock.on("close", () => {
1466
+ closeClient(client);
1467
+ });
1468
+ sock.on("error", () => {
1469
+ try {
1470
+ sock.destroy();
1471
+ } catch {
1472
+ }
1473
+ closeClient(client);
1474
+ });
1475
+ });
1476
+ } catch (err) {
1477
+ reject(err);
1478
+ return;
1479
+ }
1480
+ server = srv;
1481
+ srv.once("error", (err) => {
1482
+ reject(err);
1483
+ });
1484
+ try {
1485
+ srv.listen(portOpt, host, () => {
1486
+ const addr = srv.address();
1487
+ if (addr && typeof addr === "object") {
1488
+ boundPort = addr.port;
1489
+ } else {
1490
+ boundPort = portOpt;
1491
+ }
1492
+ resolve(boundPort);
1493
+ });
1494
+ } catch (err) {
1495
+ reject(err);
1496
+ }
1497
+ });
1498
+ },
1499
+ async stop() {
1500
+ if (stopped) return;
1501
+ stopped = true;
1502
+ for (const client of [...clients.values()]) {
1503
+ try {
1504
+ client.socket.destroy();
1505
+ } catch {
1506
+ }
1507
+ }
1508
+ clients.clear();
1509
+ const srv = server;
1510
+ server = void 0;
1511
+ if (srv) {
1512
+ await new Promise((resolve) => {
1513
+ let done = false;
1514
+ const finish = () => {
1515
+ if (done) return;
1516
+ done = true;
1517
+ resolve();
1518
+ };
1519
+ try {
1520
+ srv.close(() => finish());
1521
+ } catch {
1522
+ finish();
1523
+ return;
1524
+ }
1525
+ setTimeout(finish, 1e3).unref?.();
1526
+ });
1527
+ }
1528
+ boundPort = void 0;
1529
+ try {
1530
+ onClientCount?.(0);
1531
+ } catch {
1532
+ }
1533
+ },
1534
+ broadcast,
1535
+ sendTo
1536
+ };
1537
+ return voiceServer;
1538
+ }
1539
+ function createVoiceClient(options, deps) {
1540
+ const port = options.port ?? 8765;
1541
+ const host = options.host ?? "127.0.0.1";
1542
+ const onMessage = options.onMessage;
1543
+ const onConnectionChange = options.onConnectionChange;
1544
+ const reconnectMs = options.reconnectMs ?? 2e3;
1545
+ const netMod = deps && deps.net ? deps.net : nodeNet;
1546
+ let socket;
1547
+ let connected = false;
1548
+ let stopped = false;
1549
+ let timer;
1550
+ let decoder = createLineDecoder(decodeDaemonMessage);
1551
+ const setConnected = (value) => {
1552
+ if (connected === value) return;
1553
+ connected = value;
1554
+ try {
1555
+ onConnectionChange?.(value);
1556
+ } catch {
1557
+ }
1558
+ };
1559
+ const scheduleReconnect = () => {
1560
+ if (stopped) return;
1561
+ if (timer) return;
1562
+ timer = setTimeout(() => {
1563
+ timer = void 0;
1564
+ connect();
1565
+ }, reconnectMs);
1566
+ try {
1567
+ timer.unref?.();
1568
+ } catch {
1569
+ }
1570
+ };
1571
+ const connect = () => {
1572
+ if (stopped) return;
1573
+ if (socket && !socket.destroyed) return;
1574
+ decoder = createLineDecoder(decodeDaemonMessage);
1575
+ let sock;
1576
+ try {
1577
+ sock = netMod.createConnection({ port, host });
1578
+ } catch {
1579
+ socket = void 0;
1580
+ scheduleReconnect();
1581
+ return;
1582
+ }
1583
+ socket = sock;
1584
+ sock.on("connect", () => {
1585
+ setConnected(true);
1586
+ });
1587
+ sock.on("data", (chunk) => {
1588
+ try {
1589
+ const msgs = decoder(chunk);
1590
+ for (const m of msgs) {
1591
+ try {
1592
+ onMessage?.(m);
1593
+ } catch {
1594
+ }
1595
+ }
1596
+ } catch {
1597
+ }
1598
+ });
1599
+ sock.on("close", () => {
1600
+ socket = void 0;
1601
+ setConnected(false);
1602
+ scheduleReconnect();
1603
+ });
1604
+ sock.on("error", () => {
1605
+ setConnected(false);
1606
+ try {
1607
+ sock.destroy();
1608
+ } catch {
1609
+ }
1610
+ socket = void 0;
1611
+ scheduleReconnect();
1612
+ });
1613
+ };
1614
+ const client = {
1615
+ get connected() {
1616
+ return connected;
1617
+ },
1618
+ start() {
1619
+ stopped = false;
1620
+ connect();
1621
+ },
1622
+ stop() {
1623
+ if (stopped && !socket && !timer) {
1624
+ }
1625
+ stopped = true;
1626
+ if (timer) {
1627
+ clearTimeout(timer);
1628
+ timer = void 0;
1629
+ }
1630
+ if (socket) {
1631
+ try {
1632
+ socket.destroy();
1633
+ } catch {
1634
+ }
1635
+ socket = void 0;
1636
+ }
1637
+ setConnected(false);
1638
+ },
1639
+ send(message) {
1640
+ try {
1641
+ if (stopped) return false;
1642
+ if (!socket || socket.destroyed || !socket.writable) return false;
1643
+ if (!connected) return false;
1644
+ socket.write(encodeMessage(message));
1645
+ return true;
1646
+ } catch {
1647
+ return false;
1648
+ }
1649
+ }
1650
+ };
1651
+ return client;
1652
+ }
1653
+
1654
+ // src/voice/parse.ts
1655
+ var cleanTranscript = (text) => /[a-z0-9]/i.test(text) ? text.trim() : "";
1656
+
1657
+ // src/voice/daemon.ts
1658
+ function createVoiceDaemon(deps) {
1659
+ const log = deps?.log ?? ((_level, _message) => {
1660
+ });
1661
+ const state = {
1662
+ armed: false,
1663
+ phase: "idle",
1664
+ wake: false,
1665
+ port: void 0,
1666
+ clients: 0
1667
+ };
1668
+ let config;
1669
+ let server;
1670
+ let capture;
1671
+ let vad;
1672
+ let wakeDetector;
1673
+ let splitter;
1674
+ let frameLength = 512;
1675
+ let source;
1676
+ let utteranceFrames = [];
1677
+ let manualSamples = 0;
1678
+ let stopped = false;
1679
+ let inFlight;
1680
+ let captureCheckTimer;
1681
+ const concatFrames2 = (frames) => {
1682
+ let total = 0;
1683
+ for (const f of frames) total += f.length;
1684
+ const out = new Int16Array(total);
1685
+ let off = 0;
1686
+ for (const f of frames) {
1687
+ out.set(f, off);
1688
+ off += f.length;
1689
+ }
1690
+ return out;
1691
+ };
1692
+ const broadcast = (msg) => {
1693
+ if (stopped) return;
1694
+ try {
1695
+ server?.broadcast(msg);
1696
+ } catch {
1697
+ }
1698
+ };
1699
+ const broadcastState = () => {
1700
+ broadcast({ t: "state", armed: state.armed, phase: state.phase });
1701
+ };
1702
+ const beginRecording = (src) => {
1703
+ state.phase = "recording";
1704
+ source = src;
1705
+ utteranceFrames = [];
1706
+ manualSamples = 0;
1707
+ try {
1708
+ vad?.reset();
1709
+ } catch {
1710
+ }
1711
+ broadcastState();
1712
+ };
1713
+ const finish = async (audio, src) => {
1714
+ if (state.phase === "transcribing" || stopped) return;
1715
+ if (audio.length === 0) {
1716
+ state.phase = "idle";
1717
+ source = void 0;
1718
+ try {
1719
+ vad?.reset();
1720
+ } catch {
1721
+ }
1722
+ broadcastState();
1723
+ broadcast({ t: "error", message: "No audio captured" });
1724
+ inFlight = void 0;
1725
+ return;
1726
+ }
1727
+ if (!hasSpeech(audio, { sampleRate: config.sampleRate })) {
1728
+ state.phase = "idle";
1729
+ source = void 0;
1730
+ try {
1731
+ vad?.reset();
1732
+ } catch {
1733
+ }
1734
+ broadcastState();
1735
+ broadcast({ t: "error", message: "No speech detected" });
1736
+ inFlight = void 0;
1737
+ return;
1738
+ }
1739
+ state.phase = "transcribing";
1740
+ source = void 0;
1741
+ broadcastState();
1742
+ const run = async () => {
1743
+ try {
1744
+ if (!config.groqKey) {
1745
+ broadcast({ t: "error", message: "Groq key is not configured" });
1746
+ } else {
1747
+ const transcribeFn = deps?.transcribe ?? transcribe;
1748
+ const wav = encodeWav(audio, { sampleRate: config.sampleRate });
1749
+ const text = cleanTranscript(
1750
+ await transcribeFn({
1751
+ audio: wav,
1752
+ filename: "audio.wav",
1753
+ key: config.groqKey ?? "",
1754
+ model: config.model,
1755
+ language: config.language
1756
+ })
1757
+ );
1758
+ if (isSilenceHallucination(text, audio.length / config.sampleRate * 1e3)) {
1759
+ broadcast({ t: "error", message: "No speech detected" });
1760
+ } else if (text) {
1761
+ broadcast({ t: "transcript", text, source: src });
1762
+ }
1763
+ }
1764
+ } catch (err) {
1765
+ const msg = err?.message != null ? String(err.message) : String(err);
1766
+ broadcast({ t: "error", message: msg });
1767
+ } finally {
1768
+ state.phase = "idle";
1769
+ try {
1770
+ vad?.reset();
1771
+ } catch {
1772
+ }
1773
+ broadcastState();
1774
+ inFlight = void 0;
1775
+ }
1776
+ };
1777
+ inFlight = run();
1778
+ try {
1779
+ await inFlight;
1780
+ } catch {
1781
+ }
1782
+ };
1783
+ const handleClientMessage = (msg) => {
1784
+ if (stopped) return;
1785
+ if (!msg || typeof msg !== "object") return;
1786
+ switch (msg.t) {
1787
+ case "hello":
1788
+ broadcast({ t: "welcome", v: VOICE_PROTOCOL_VERSION, armed: state.armed, wake: state.wake });
1789
+ break;
1790
+ case "ping":
1791
+ broadcast({ t: "pong" });
1792
+ break;
1793
+ case "arm":
1794
+ state.armed = true;
1795
+ broadcastState();
1796
+ break;
1797
+ case "disarm":
1798
+ state.armed = false;
1799
+ if (state.phase === "recording") {
1800
+ try {
1801
+ vad?.reset();
1802
+ } catch {
1803
+ }
1804
+ state.phase = "idle";
1805
+ source = void 0;
1806
+ utteranceFrames = [];
1807
+ manualSamples = 0;
1808
+ }
1809
+ broadcastState();
1810
+ break;
1811
+ case "toggle":
1812
+ if (state.phase === "idle") {
1813
+ beginRecording("manual");
1814
+ } else if (state.phase === "recording" && source === "manual") {
1815
+ const audio = concatFrames2(utteranceFrames);
1816
+ if (audio.length === 0) {
1817
+ state.phase = "idle";
1818
+ source = void 0;
1819
+ try {
1820
+ vad?.reset();
1821
+ } catch {
1822
+ }
1823
+ broadcastState();
1824
+ broadcast({ t: "error", message: "No audio captured" });
1825
+ } else {
1826
+ void finish(audio, "manual");
1827
+ }
1828
+ }
1829
+ break;
1830
+ default:
1831
+ break;
1832
+ }
1833
+ };
1834
+ const onFrame = (frame) => {
1835
+ if (stopped) return;
1836
+ if (state.phase === "recording") {
1837
+ if (source === "manual") {
1838
+ const sr = config?.sampleRate ?? 16e3;
1839
+ const limit = 120 * sr;
1840
+ if (manualSamples < limit) {
1841
+ utteranceFrames.push(new Int16Array(frame));
1842
+ manualSamples += frame.length;
1843
+ }
1844
+ } else if (source === "wake") {
1845
+ const events = vad ? vad.push(frame) : [];
1846
+ for (const ev of events) {
1847
+ if (ev.t === "utterance") {
1848
+ void finish(ev.audio, "wake");
1849
+ }
1850
+ if (state.phase !== "recording") break;
1851
+ }
1852
+ }
1853
+ } else if (state.phase === "idle" && state.armed && state.wake && wakeDetector) {
1854
+ try {
1855
+ if (wakeDetector.process(frame)) {
1856
+ beginRecording("wake");
1857
+ }
1858
+ } catch {
1859
+ }
1860
+ }
1861
+ };
1862
+ const daemon = {
1863
+ state,
1864
+ async start() {
1865
+ config = deps?.config ?? resolveVoiceConfig();
1866
+ const createServerFn = deps?.createServer ?? createVoiceServer;
1867
+ server = createServerFn({
1868
+ port: config.port,
1869
+ host: config.host,
1870
+ onMessage: (m) => handleClientMessage(m),
1871
+ onClientCount: (c) => {
1872
+ state.clients = c;
1873
+ broadcastState();
1874
+ }
1875
+ });
1876
+ const resolvedPort = await server.start();
1877
+ state.port = resolvedPort;
1878
+ let wakeError;
1879
+ try {
1880
+ if (deps?.createWakeDetector) {
1881
+ wakeDetector = await deps.createWakeDetector({
1882
+ accessKey: config.picovoiceKey ?? "",
1883
+ keywordPath: config.wakeWordPath ?? "",
1884
+ sensitivity: config.wakeSensitivity
1885
+ });
1886
+ } else if (config.wakeEngine === "porcupine") {
1887
+ wakeDetector = await createWakeDetector({
1888
+ accessKey: config.picovoiceKey ?? "",
1889
+ keywordPath: config.wakeWordPath ?? "",
1890
+ sensitivity: config.wakeSensitivity
1891
+ });
1892
+ } else {
1893
+ wakeDetector = await createVoskWakeDetector({
1894
+ modelPath: config.voskModelPath,
1895
+ phrases: config.wakePhrases,
1896
+ sampleRate: config.sampleRate
1897
+ });
1898
+ }
1899
+ state.wake = true;
1900
+ } catch (err) {
1901
+ state.wake = false;
1902
+ wakeError = err?.message != null ? String(err.message) : String(err);
1903
+ log("warn", wakeError);
1904
+ }
1905
+ const createVadFn = deps?.createVad ?? createVad;
1906
+ vad = createVadFn({ sampleRate: config.sampleRate });
1907
+ state.armed = Boolean(config.armOnStart && state.wake);
1908
+ frameLength = wakeDetector?.frameLength ?? 512;
1909
+ splitter = createFrameSplitter(frameLength, (frame) => onFrame(frame));
1910
+ const createCaptureFn = deps?.createCapture ?? createCapture;
1911
+ capture = createCaptureFn((chunk) => {
1912
+ try {
1913
+ splitter?.(chunk);
1914
+ } catch {
1915
+ }
1916
+ });
1917
+ try {
1918
+ capture.start();
1919
+ } catch {
1920
+ }
1921
+ try {
1922
+ const cap = capture;
1923
+ const notRunning = !cap || !cap.running || cap.plan === void 0;
1924
+ if (notRunning) {
1925
+ const detail = !cap || !cap.running ? "not running" : cap.plan === void 0 ? "plan undefined" : cap.plan;
1926
+ const detailStr = typeof detail === "string" ? detail : JSON.stringify(detail);
1927
+ const message = `microphone capture is not running: ${detailStr}`;
1928
+ log("error", message);
1929
+ broadcast({ t: "error", message: "microphone capture is not running" });
1930
+ }
1931
+ } catch {
1932
+ }
1933
+ try {
1934
+ captureCheckTimer = setTimeout(() => {
1935
+ if (stopped) return;
1936
+ try {
1937
+ if (!capture?.running) {
1938
+ const message = "Microphone capture stopped unexpectedly";
1939
+ log("error", message);
1940
+ broadcast({ t: "error", message });
1941
+ }
1942
+ } catch {
1943
+ }
1944
+ }, 750);
1945
+ captureCheckTimer?.unref?.();
1946
+ } catch {
1947
+ }
1948
+ return { port: state.port, wake: state.wake, wakeError };
1949
+ },
1950
+ async stop() {
1951
+ if (stopped) return;
1952
+ stopped = true;
1953
+ if (captureCheckTimer) {
1954
+ try {
1955
+ clearTimeout(captureCheckTimer);
1956
+ } catch {
1957
+ }
1958
+ captureCheckTimer = void 0;
1959
+ }
1960
+ try {
1961
+ capture?.stop();
1962
+ } catch {
1963
+ }
1964
+ try {
1965
+ wakeDetector?.release();
1966
+ } catch {
1967
+ }
1968
+ try {
1969
+ vad?.reset();
1970
+ } catch {
1971
+ }
1972
+ if (inFlight) {
1973
+ try {
1974
+ await Promise.race([inFlight, new Promise((r) => setTimeout(r, 5e3))]);
1975
+ } catch {
1976
+ }
1977
+ inFlight = void 0;
1978
+ }
1979
+ try {
1980
+ await server?.stop();
1981
+ } catch {
1982
+ }
1983
+ }
1984
+ };
1985
+ return daemon;
1986
+ }
1987
+
1988
+ // bin/lacode-voice
1989
+ var require2 = createRequire(import.meta.url);
1990
+ if (!process.features?.typescript) {
1991
+ if (process.env.LACODE_STRIP_TYPECHECKED === "1") {
1992
+ console.error("lacode-voice: this node cannot load TypeScript sources; node >= 22.18 is required.");
1993
+ process.exit(1);
1994
+ }
1995
+ const { spawnSync } = await import("node:child_process");
1996
+ const result = spawnSync(
1997
+ process.execPath,
1998
+ ["--experimental-strip-types", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)],
1999
+ { stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
2000
+ );
2001
+ process.exit(result.status ?? 1);
2002
+ }
2003
+ var USAGE = `lacode-voice \u2014 CLI for the LaCode voice daemon
2004
+
2005
+ lacode-voice [start]
2006
+ Resolve config, start the voice daemon, and keep running.
2007
+
2008
+ lacode-voice doctor
2009
+ Print a diagnostic checklist. Does not start audio capture.
2010
+
2011
+ lacode-voice model [--force] | fetch-model [--force]
2012
+ Download the Vosk wake-word model into the voice data directory.
2013
+ --force re-downloads and re-extracts an existing model.
2014
+
2015
+ lacode-voice status
2016
+ Query a running daemon (ping + hello) and print its state.
2017
+
2018
+ lacode-voice --help | -h
2019
+ Print this help.`;
2020
+ function expandHome2(value, home) {
2021
+ const v = value.trim();
2022
+ if (v === "~") return home;
2023
+ if (v.startsWith("~/")) return join4(home, v.slice(2));
2024
+ return v;
2025
+ }
2026
+ function isBuiltinWakeWord(value) {
2027
+ const builtins = [
2028
+ "alexa",
2029
+ "americano",
2030
+ "blueberry",
2031
+ "bumblebee",
2032
+ "computer",
2033
+ "grapefruit",
2034
+ "grasshopper",
2035
+ "hey google",
2036
+ "hey siri",
2037
+ "jarvis",
2038
+ "ok google",
2039
+ "picovoice",
2040
+ "porcupine",
2041
+ "terminator"
2042
+ ];
2043
+ return builtins.includes(value.trim().toLowerCase());
2044
+ }
2045
+ function porcupineInfo() {
2046
+ try {
2047
+ const resolved = require2.resolve("@picovoice/porcupine-node");
2048
+ let dir = dirname(resolved);
2049
+ for (let i = 0; i < 8; i++) {
2050
+ try {
2051
+ const pkg = JSON.parse(readFileSync3(join4(dir, "package.json"), "utf8"));
2052
+ if (pkg && pkg.name === "@picovoice/porcupine-node" && typeof pkg.version === "string") {
2053
+ return { installed: true, version: pkg.version };
2054
+ }
2055
+ } catch {
2056
+ }
2057
+ const parent = dirname(dir);
2058
+ if (parent === dir) break;
2059
+ dir = parent;
2060
+ }
2061
+ try {
2062
+ const pkg = require2("@picovoice/porcupine-node/package.json");
2063
+ if (pkg && typeof pkg.version === "string") return { installed: true, version: pkg.version };
2064
+ } catch {
2065
+ }
2066
+ return { installed: true, version: "unknown" };
2067
+ } catch {
2068
+ return { installed: false, version: "not installed" };
2069
+ }
2070
+ }
2071
+ function voskInfo() {
2072
+ try {
2073
+ const resolved = require2.resolve("vosk");
2074
+ let dir = dirname(resolved);
2075
+ for (let i = 0; i < 8; i++) {
2076
+ try {
2077
+ const pkg = JSON.parse(readFileSync3(join4(dir, "package.json"), "utf8"));
2078
+ if (pkg && pkg.name === "vosk" && typeof pkg.version === "string") {
2079
+ return { installed: true, version: pkg.version };
2080
+ }
2081
+ } catch {
2082
+ }
2083
+ const parent = dirname(dir);
2084
+ if (parent === dir) break;
2085
+ dir = parent;
2086
+ }
2087
+ return { installed: true, version: "unknown" };
2088
+ } catch {
2089
+ return { installed: false, version: "not installed" };
2090
+ }
2091
+ }
2092
+ function runUnzip(zip, dest) {
2093
+ return new Promise((resolve, reject) => {
2094
+ const child = spawn2("unzip", ["-q", "-o", zip, "-d", dest], { stdio: "inherit" });
2095
+ child.once("error", reject);
2096
+ child.once("close", (code) => resolve(code));
2097
+ });
2098
+ }
2099
+ function canBind(port, host) {
2100
+ return new Promise((resolve) => {
2101
+ let server;
2102
+ try {
2103
+ server = net.createServer();
2104
+ } catch {
2105
+ resolve(false);
2106
+ return;
2107
+ }
2108
+ let done = false;
2109
+ const finish = (value) => {
2110
+ if (done) return;
2111
+ done = true;
2112
+ try {
2113
+ server.close();
2114
+ } catch {
2115
+ }
2116
+ resolve(value);
2117
+ };
2118
+ server.once("error", () => finish(false));
2119
+ try {
2120
+ server.listen(port, host, () => finish(true));
2121
+ } catch {
2122
+ finish(false);
2123
+ }
2124
+ setTimeout(() => finish(false), 1e3).unref?.();
2125
+ });
2126
+ }
2127
+ function delay(ms) {
2128
+ return new Promise((resolve) => setTimeout(resolve, ms));
2129
+ }
2130
+ var rawArgv = process.argv.slice(2);
2131
+ var command;
2132
+ if (rawArgv.length > 0 && rawArgv[0].startsWith("-")) {
2133
+ if (rawArgv[0] === "--help" || rawArgv[0] === "-h") {
2134
+ command = rawArgv.shift();
2135
+ } else {
2136
+ const idx = rawArgv.findIndex((a) => !a.startsWith("-"));
2137
+ if (idx >= 0) {
2138
+ command = rawArgv.splice(idx, 1)[0];
2139
+ } else {
2140
+ command = rawArgv.shift();
2141
+ }
2142
+ }
2143
+ } else {
2144
+ command = rawArgv.shift();
2145
+ }
2146
+ if (command === void 0) command = "start";
2147
+ switch (command) {
2148
+ case "start": {
2149
+ const config = resolveVoiceConfig();
2150
+ const daemon = createVoiceDaemon({ config });
2151
+ let shuttingDown = false;
2152
+ const shutdown = async () => {
2153
+ if (shuttingDown) return;
2154
+ shuttingDown = true;
2155
+ try {
2156
+ await daemon.stop();
2157
+ } catch {
2158
+ }
2159
+ process.exit(0);
2160
+ };
2161
+ process.on("SIGINT", () => {
2162
+ void shutdown();
2163
+ });
2164
+ process.on("SIGTERM", () => {
2165
+ void shutdown();
2166
+ });
2167
+ const result = await daemon.start();
2168
+ const wake = result.wake ? "on" : "off";
2169
+ console.log(`voice daemon listening on ${config.host}:${result.port} (wake: ${wake})`);
2170
+ if (result.wakeError) {
2171
+ console.log(`warning: wake word unavailable: ${result.wakeError}`);
2172
+ }
2173
+ break;
2174
+ }
2175
+ case "doctor": {
2176
+ const config = resolveVoiceConfig();
2177
+ const home = homedir3();
2178
+ if (config.groqKey) {
2179
+ console.log(`ok groq key: ${config.groqSource ?? "unknown"}`);
2180
+ } else {
2181
+ console.log("fail groq key: missing");
2182
+ }
2183
+ console.log(`wake engine: ${config.wakeEngine}`);
2184
+ let engineUsable = false;
2185
+ if (config.wakeEngine === "vosk") {
2186
+ const modelPath = config.voskModelPath;
2187
+ const modelDisplay = modelPath && modelPath.trim() !== "" ? modelPath : "(none)";
2188
+ let modelExists = false;
2189
+ if (modelPath && modelPath.trim() !== "") {
2190
+ try {
2191
+ modelExists = existsSync4(modelPath);
2192
+ } catch {
2193
+ modelExists = false;
2194
+ }
2195
+ }
2196
+ if (modelExists) {
2197
+ console.log(`ok vosk model: ${modelDisplay} (exists)`);
2198
+ } else {
2199
+ console.log(`fail vosk model: ${modelDisplay} (not found)`);
2200
+ console.log('hint: run "lacode-voice model" to download it');
2201
+ }
2202
+ const vk = voskInfo();
2203
+ if (vk.installed) {
2204
+ console.log(`ok vosk module: ${vk.version}`);
2205
+ } else {
2206
+ console.log("fail vosk module: not installed");
2207
+ }
2208
+ engineUsable = modelExists && vk.installed;
2209
+ } else {
2210
+ if (config.picovoiceKey) {
2211
+ console.log("ok picovoice key: present");
2212
+ } else {
2213
+ console.log("warn picovoice key: missing");
2214
+ }
2215
+ const ww = config.wakeWordPath;
2216
+ const wwDisplay = ww ? ww : "(none)";
2217
+ let wwExists = false;
2218
+ let wwAvailable = false;
2219
+ if (ww) {
2220
+ if (isBuiltinWakeWord(ww)) {
2221
+ wwExists = true;
2222
+ wwAvailable = true;
2223
+ } else {
2224
+ const expanded = expandHome2(ww, home);
2225
+ try {
2226
+ wwExists = existsSync4(expanded);
2227
+ } catch {
2228
+ wwExists = false;
2229
+ }
2230
+ wwAvailable = wwExists;
2231
+ }
2232
+ }
2233
+ const wwStatus = wwExists ? "exists" : "not found";
2234
+ const pp = porcupineInfo();
2235
+ const wakeAvailable = Boolean(config.picovoiceKey) && wwAvailable && pp.installed;
2236
+ const wwPrefix = wakeAvailable ? "ok" : "fail";
2237
+ console.log(`${wwPrefix} wake word: ${wwDisplay} (${wwStatus})`);
2238
+ if (pp.installed) {
2239
+ console.log(`ok porcupine module: ${pp.version}`);
2240
+ } else {
2241
+ console.log("warn porcupine module: not installed");
2242
+ }
2243
+ engineUsable = wakeAvailable;
2244
+ }
2245
+ const plan = planCapture();
2246
+ if (plan) {
2247
+ console.log(`ok capture: ${plan.bin} ${plan.args.join(" ")}`);
2248
+ } else {
2249
+ console.log("warn capture: no recorder found (install ffmpeg)");
2250
+ }
2251
+ const bindable = await canBind(config.port, config.host);
2252
+ const portPrefix = bindable ? "ok" : "fail";
2253
+ const portStatus = bindable ? "available" : "in use";
2254
+ console.log(`${portPrefix} port: ${config.port} ${portStatus}`);
2255
+ const groqMissing = !config.groqKey;
2256
+ const exitCode = groqMissing || !engineUsable ? 1 : 0;
2257
+ process.exit(exitCode);
2258
+ break;
2259
+ }
2260
+ case "model":
2261
+ case "fetch-model": {
2262
+ const config = resolveVoiceConfig();
2263
+ const modelPath = config.voskModelPath;
2264
+ const force = rawArgv.includes("--force");
2265
+ try {
2266
+ if (!force && modelPath && modelPath.trim() !== "" && existsSync4(modelPath)) {
2267
+ console.log(`vosk model already present: ${modelPath}`);
2268
+ process.exit(0);
2269
+ }
2270
+ const dataDir = VOICE_DATA_DIR();
2271
+ const zip = join4(dataDir, "vosk-model.zip");
2272
+ mkdirSync(dataDir, { recursive: true });
2273
+ const url = "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip";
2274
+ console.log("downloading vosk model (41 MB)...");
2275
+ const response = await fetch(url);
2276
+ if (!response.ok || !response.body) {
2277
+ throw new Error(`download failed: HTTP ${response.status} ${response.statusText}`);
2278
+ }
2279
+ let bytes = 0;
2280
+ const counter = new Transform({
2281
+ transform(chunk, _encoding, callback) {
2282
+ bytes += chunk.length;
2283
+ callback(null, chunk);
2284
+ }
2285
+ });
2286
+ await pipeline(Readable.fromWeb(response.body), counter, createWriteStream(zip));
2287
+ console.log(`downloaded ${(bytes / (1024 * 1024)).toFixed(1)} MB`);
2288
+ const code = await runUnzip(zip, dataDir);
2289
+ if (code !== 0) throw new Error(`unzip failed with exit code ${code}`);
2290
+ if (!existsSync4(modelPath)) {
2291
+ throw new Error(`vosk model not found after extraction: ${modelPath}`);
2292
+ }
2293
+ console.log(`vosk model ready: ${modelPath}`);
2294
+ rmSync(zip, { force: true });
2295
+ process.exit(0);
2296
+ } catch (err) {
2297
+ const message = err instanceof Error ? err.message : String(err);
2298
+ console.error(`lacode-voice: ${message}`);
2299
+ process.exit(1);
2300
+ }
2301
+ break;
2302
+ }
2303
+ case "status": {
2304
+ const config = resolveVoiceConfig();
2305
+ let got = null;
2306
+ const client = createVoiceClient({
2307
+ host: config.host,
2308
+ port: config.port,
2309
+ reconnectMs: 5e3,
2310
+ onMessage: (m) => {
2311
+ if (!got && (m.t === "pong" || m.t === "welcome" || m.t === "state")) got = m;
2312
+ }
2313
+ });
2314
+ client.start();
2315
+ const deadline = Date.now() + 1500;
2316
+ while (!client.connected && Date.now() < deadline) await delay(10);
2317
+ if (client.connected) {
2318
+ client.send({ t: "ping" });
2319
+ client.send({ t: "hello", v: VOICE_PROTOCOL_VERSION, client: "lacode-voice" });
2320
+ }
2321
+ while (!got && Date.now() < deadline) await delay(10);
2322
+ try {
2323
+ client.stop();
2324
+ } catch {
2325
+ }
2326
+ if (!got) {
2327
+ console.log("daemon not running");
2328
+ process.exit(1);
2329
+ }
2330
+ if (got.t === "state") {
2331
+ console.log(`armed: ${got.armed} phase: ${got.phase}`);
2332
+ } else if (got.t === "welcome") {
2333
+ console.log(`armed: ${got.armed} wake: ${got.wake ? "on" : "off"}`);
2334
+ } else {
2335
+ console.log("daemon running");
2336
+ }
2337
+ process.exit(0);
2338
+ break;
2339
+ }
2340
+ case "--help":
2341
+ case "-h":
2342
+ case "help":
2343
+ console.log(USAGE);
2344
+ process.exit(0);
2345
+ break;
2346
+ default:
2347
+ console.error(`lacode-voice: unknown command ${JSON.stringify(command)}`);
2348
+ console.error(USAGE);
2349
+ process.exit(1);
2350
+ break;
2351
+ }