pi-voicekit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/extensions/voice/config.ts +395 -0
  4. package/extensions/voice/deepgram.ts +33 -0
  5. package/extensions/voice/device.ts +382 -0
  6. package/extensions/voice/hold-to-talk.ts +69 -0
  7. package/extensions/voice/local.ts +1143 -0
  8. package/extensions/voice/model-download.ts +636 -0
  9. package/extensions/voice/onboarding.ts +739 -0
  10. package/extensions/voice/release-controller.ts +55 -0
  11. package/extensions/voice/settings-panel.ts +1602 -0
  12. package/extensions/voice/sherpa-engine.ts +464 -0
  13. package/extensions/voice/sherpa-loader.ts +143 -0
  14. package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
  15. package/extensions/voice/speak.ts +430 -0
  16. package/extensions/voice/tts-deepgram.ts +454 -0
  17. package/extensions/voice/tts-engine.ts +653 -0
  18. package/extensions/voice/tts-install-progress.ts +257 -0
  19. package/extensions/voice/tts-local-models.ts +1255 -0
  20. package/extensions/voice/tts-onboarding-overlay.ts +186 -0
  21. package/extensions/voice/tts-onboarding.ts +87 -0
  22. package/extensions/voice/tts-playback-indicator.ts +127 -0
  23. package/extensions/voice/tts-playback.ts +675 -0
  24. package/extensions/voice/tts-text-filter.ts +404 -0
  25. package/extensions/voice/ui-aura.ts +272 -0
  26. package/extensions/voice/ui-help-overlay.ts +161 -0
  27. package/extensions/voice/ui-icons.ts +124 -0
  28. package/extensions/voice/ui-locale-labels.ts +110 -0
  29. package/extensions/voice/ui-picker.ts +209 -0
  30. package/extensions/voice/ui-render-ticker.ts +171 -0
  31. package/extensions/voice/ui-widget-base.ts +219 -0
  32. package/extensions/voice/ui-width.ts +112 -0
  33. package/extensions/voice.ts +3644 -0
  34. package/package.json +75 -0
@@ -0,0 +1,1143 @@
1
+ /**
2
+ * Local transcription backend — in-process STT via sherpa-onnx + external server fallback.
3
+ *
4
+ * Default: sherpa-onnx in-process inference (zero-config, auto-download models).
5
+ * Fallback: External server via POST /v1/audio/transcriptions (advanced users).
6
+ *
7
+ * Model catalog verified against HuggingFace repos:
8
+ * - csukuangfj/ and csukuangfj2/ repos on huggingface.co
9
+ * - k2-fsa/sherpa-onnx GitHub releases (asr-models tag)
10
+ *
11
+ * Architecture:
12
+ * Deepgram → real-time streaming (WebSocket, interim results while speaking)
13
+ * Local → batch mode (record complete audio, transcribe after stop)
14
+ */
15
+
16
+ import type { ChildProcess } from "node:child_process";
17
+ import type { VoiceConfig } from "./config";
18
+ import { isLoopbackEndpoint } from "./config";
19
+ import { SAMPLE_RATE, CHANNELS } from "./deepgram";
20
+
21
+ // ─── Model catalog ───────────────────────────────────────────────────────────
22
+
23
+ export interface SherpaModelConfig {
24
+ /** Recognizer type for sherpa-onnx */
25
+ type: "whisper" | "moonshine" | "sense_voice" | "nemo_ctc" | "transducer" | "paraformer" | "qwen3_asr";
26
+ /** Map of role → filename within model directory */
27
+ files: Record<string, string>;
28
+ /** Map of role → download URL (HuggingFace or GitHub releases) */
29
+ downloadUrls: Record<string, string>;
30
+ }
31
+
32
+ export interface LocalModelInfo {
33
+ id: string;
34
+ name: string;
35
+ /** Human-readable download size */
36
+ size: string;
37
+ /** Download size in bytes (for progress tracking + fitness scoring) */
38
+ sizeBytes: number;
39
+ /** Peak runtime RAM in MB (~2.5x model file size) */
40
+ runtimeRamMB: number;
41
+ notes: string;
42
+ /** Language family — determines which language list to show */
43
+ langSupport:
44
+ | "whisper"
45
+ | "english-only"
46
+ | "parakeet-multi"
47
+ | "sensevoice"
48
+ | "russian-only"
49
+ | "single-ar"
50
+ | "single-zh"
51
+ | "single-ja"
52
+ | "single-ko"
53
+ | "single-uk"
54
+ | "single-vi"
55
+ | "single-es"
56
+ | "bilingual-zh-en"
57
+ | "qwen3";
58
+ /** Device tier: edge (<256 MB), standard (256 MB–1 GB), heavy (>1 GB) */
59
+ tier: "edge" | "standard" | "heavy";
60
+ /** Preferred model — best-in-class for its language/use case. Only these get [recommended]. */
61
+ preferred?: boolean;
62
+ /** Accuracy rating 1-5 (5 = best). Based on published WER benchmarks. */
63
+ accuracy: 1 | 2 | 3 | 4 | 5;
64
+ /** Speed rating 1-5 (5 = fastest). Based on real-time factor and latency benchmarks. */
65
+ speed: 1 | 2 | 3 | 4 | 5;
66
+ /** sherpa-onnx model configuration — file paths and download URLs */
67
+ sherpaModel: SherpaModelConfig;
68
+ }
69
+
70
+ // HuggingFace base URLs for sherpa-onnx models (verified repos)
71
+ const HF1 = "https://huggingface.co/csukuangfj";
72
+ const HF2 = "https://huggingface.co/csukuangfj2";
73
+ const GH = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models";
74
+
75
+ // Helper to build HuggingFace resolve URLs
76
+ function hf1(repo: string, file: string): string {
77
+ return `${HF1}/${repo}/resolve/main/${file}`;
78
+ }
79
+ function hf2(repo: string, file: string): string {
80
+ return `${HF2}/${repo}/resolve/main/${file}`;
81
+ }
82
+
83
+ /**
84
+ * Model catalog — verified against actual HuggingFace repos and file listings.
85
+ *
86
+ * Evidence:
87
+ * - Moonshine v1 (csukuangfj): {preprocess.onnx, encode.int8.onnx, uncached_decode.int8.onnx, cached_decode.int8.onnx, tokens.txt}
88
+ * - Moonshine v2 (csukuangfj2): {encoder_model.ort, decoder_model_merged.ort, tokens.txt}
89
+ * - Whisper (csukuangfj): {SIZE-encoder.int8.onnx, SIZE-decoder.int8.onnx, SIZE-tokens.txt}
90
+ * - SenseVoice (csukuangfj): {model.int8.onnx, tokens.txt}
91
+ * - GigaAM CTC (csukuangfj): {model.int8.onnx, tokens.txt}
92
+ * - Parakeet TDT (csukuangfj): {encoder.int8.onnx, decoder.int8.onnx, joiner.int8.onnx, tokens.txt} — transducer!
93
+ *
94
+ * Note on Moonshine v2 Small/Medium:
95
+ * These exist ONLY as streaming models (moonshine-ai/moonshine) with a different 5-file
96
+ * architecture (encoder.ort, frontend.ort, decoder_kv.ort, cross_kv.ort, adapter.ort)
97
+ * that is incompatible with sherpa-onnx's moonshine recognizer (which expects 2-file
98
+ * encoder+mergedDecoder or 4-file v1 structure). Only Tiny and Base have non-streaming
99
+ * variants compatible with sherpa-onnx. See: https://github.com/moonshine-ai/moonshine
100
+ */
101
+ export const LOCAL_MODELS: LocalModelInfo[] = [
102
+ // ═══════════════════════════════════════════════════════════════════════
103
+ // TOP PICKS — best overall models, shown first
104
+ // ═══════════════════════════════════════════════════════════════════════
105
+ {
106
+ id: "parakeet-v3",
107
+ name: "Parakeet TDT v3",
108
+ size: "~671 MB",
109
+ sizeBytes: 703_594_496,
110
+ runtimeRamMB: 1675,
111
+ notes: "Best multilingual — 25 languages, auto language detection, WER 6.3%",
112
+ langSupport: "parakeet-multi",
113
+ tier: "standard",
114
+ preferred: true,
115
+ accuracy: 4,
116
+ speed: 4,
117
+ sherpaModel: {
118
+ type: "transducer",
119
+ files: {
120
+ encoder: "encoder.int8.onnx",
121
+ decoder: "decoder.int8.onnx",
122
+ joiner: "joiner.int8.onnx",
123
+ tokens: "tokens.txt",
124
+ },
125
+ downloadUrls: {
126
+ encoder: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "encoder.int8.onnx"),
127
+ decoder: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "decoder.int8.onnx"),
128
+ joiner: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "joiner.int8.onnx"),
129
+ tokens: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "tokens.txt"),
130
+ },
131
+ },
132
+ },
133
+ {
134
+ id: "parakeet-v2",
135
+ name: "Parakeet TDT v2",
136
+ size: "~661 MB",
137
+ sizeBytes: 693_109_760,
138
+ runtimeRamMB: 1650,
139
+ notes: "Best English — lowest WER (6.0%), fast, NVIDIA NeMo",
140
+ langSupport: "english-only",
141
+ tier: "standard",
142
+ preferred: true,
143
+ accuracy: 5,
144
+ speed: 4,
145
+ sherpaModel: {
146
+ type: "transducer",
147
+ files: {
148
+ encoder: "encoder.int8.onnx",
149
+ decoder: "decoder.int8.onnx",
150
+ joiner: "joiner.int8.onnx",
151
+ tokens: "tokens.txt",
152
+ },
153
+ downloadUrls: {
154
+ encoder: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "encoder.int8.onnx"),
155
+ decoder: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "decoder.int8.onnx"),
156
+ joiner: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "joiner.int8.onnx"),
157
+ tokens: hf1("sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", "tokens.txt"),
158
+ },
159
+ },
160
+ },
161
+ // ═══════════════════════════════════════════════════════════════════════
162
+ // WHISPER — OpenAI, broadest language support (57 languages)
163
+ // ═══════════════════════════════════════════════════════════════════════
164
+ {
165
+ id: "whisper-turbo",
166
+ name: "Whisper Turbo",
167
+ size: "~1.0 GB",
168
+ sizeBytes: 1_087_373_312,
169
+ runtimeRamMB: 2590,
170
+ notes: "57 languages, good accuracy, faster than Medium and Large",
171
+ langSupport: "whisper",
172
+ tier: "heavy",
173
+ accuracy: 4,
174
+ speed: 2,
175
+ sherpaModel: {
176
+ type: "whisper",
177
+ files: { encoder: "turbo-encoder.int8.onnx", decoder: "turbo-decoder.int8.onnx", tokens: "turbo-tokens.txt" },
178
+ downloadUrls: {
179
+ encoder: hf1("sherpa-onnx-whisper-turbo", "turbo-encoder.int8.onnx"),
180
+ decoder: hf1("sherpa-onnx-whisper-turbo", "turbo-decoder.int8.onnx"),
181
+ tokens: hf1("sherpa-onnx-whisper-turbo", "turbo-tokens.txt"),
182
+ },
183
+ },
184
+ },
185
+ {
186
+ id: "whisper-medium",
187
+ name: "Whisper Medium",
188
+ size: "~946 MB",
189
+ sizeBytes: 991_952_896,
190
+ runtimeRamMB: 2365,
191
+ notes: "57 languages, good accuracy, medium speed",
192
+ langSupport: "whisper",
193
+ tier: "standard",
194
+ accuracy: 4,
195
+ speed: 3,
196
+ sherpaModel: {
197
+ type: "whisper",
198
+ files: { encoder: "medium-encoder.int8.onnx", decoder: "medium-decoder.int8.onnx", tokens: "medium-tokens.txt" },
199
+ downloadUrls: {
200
+ encoder: hf1("sherpa-onnx-whisper-medium", "medium-encoder.int8.onnx"),
201
+ decoder: hf1("sherpa-onnx-whisper-medium", "medium-decoder.int8.onnx"),
202
+ tokens: hf1("sherpa-onnx-whisper-medium", "medium-tokens.txt"),
203
+ },
204
+ },
205
+ },
206
+ {
207
+ id: "whisper-small",
208
+ name: "Whisper Small",
209
+ size: "~375 MB",
210
+ sizeBytes: 393_216_000,
211
+ runtimeRamMB: 940,
212
+ notes: "57 languages, fast, good for low-power devices",
213
+ langSupport: "whisper",
214
+ tier: "standard",
215
+ accuracy: 3,
216
+ speed: 4,
217
+ sherpaModel: {
218
+ type: "whisper",
219
+ files: { encoder: "small-encoder.int8.onnx", decoder: "small-decoder.int8.onnx", tokens: "small-tokens.txt" },
220
+ downloadUrls: {
221
+ encoder: hf1("sherpa-onnx-whisper-small", "small-encoder.int8.onnx"),
222
+ decoder: hf1("sherpa-onnx-whisper-small", "small-decoder.int8.onnx"),
223
+ tokens: hf1("sherpa-onnx-whisper-small", "small-tokens.txt"),
224
+ },
225
+ },
226
+ },
227
+ {
228
+ id: "whisper-large",
229
+ name: "Whisper Large v3",
230
+ size: "~1.8 GB",
231
+ sizeBytes: 1_863_319_552,
232
+ runtimeRamMB: 4440,
233
+ notes: "57 languages, highest Whisper accuracy, slow on CPU",
234
+ langSupport: "whisper",
235
+ tier: "heavy",
236
+ accuracy: 4,
237
+ speed: 1,
238
+ sherpaModel: {
239
+ type: "whisper",
240
+ files: {
241
+ encoder: "large-v3-encoder.int8.onnx",
242
+ decoder: "large-v3-decoder.int8.onnx",
243
+ tokens: "large-v3-tokens.txt",
244
+ },
245
+ downloadUrls: {
246
+ encoder: hf1("sherpa-onnx-whisper-large-v3", "large-v3-encoder.int8.onnx"),
247
+ decoder: hf1("sherpa-onnx-whisper-large-v3", "large-v3-decoder.int8.onnx"),
248
+ tokens: hf1("sherpa-onnx-whisper-large-v3", "large-v3-tokens.txt"),
249
+ },
250
+ },
251
+ },
252
+ // ═══════════════════════════════════════════════════════════════════════
253
+ // MOONSHINE — ultra-fast edge models
254
+ // ═══════════════════════════════════════════════════════════════════════
255
+ {
256
+ id: "moonshine-base",
257
+ name: "Moonshine Base",
258
+ size: "~287 MB",
259
+ sizeBytes: 300_940_288,
260
+ runtimeRamMB: 720,
261
+ notes: "English only, very fast, handles accents well",
262
+ langSupport: "english-only",
263
+ tier: "standard",
264
+ accuracy: 3,
265
+ speed: 5,
266
+ sherpaModel: {
267
+ type: "moonshine",
268
+ files: {
269
+ preprocessor: "preprocess.onnx",
270
+ encoder: "encode.int8.onnx",
271
+ uncachedDecoder: "uncached_decode.int8.onnx",
272
+ cachedDecoder: "cached_decode.int8.onnx",
273
+ tokens: "tokens.txt",
274
+ },
275
+ downloadUrls: {
276
+ preprocessor: hf1("sherpa-onnx-moonshine-base-en-int8", "preprocess.onnx"),
277
+ encoder: hf1("sherpa-onnx-moonshine-base-en-int8", "encode.int8.onnx"),
278
+ uncachedDecoder: hf1("sherpa-onnx-moonshine-base-en-int8", "uncached_decode.int8.onnx"),
279
+ cachedDecoder: hf1("sherpa-onnx-moonshine-base-en-int8", "cached_decode.int8.onnx"),
280
+ tokens: hf1("sherpa-onnx-moonshine-base-en-int8", "tokens.txt"),
281
+ },
282
+ },
283
+ },
284
+ {
285
+ id: "moonshine-tiny",
286
+ name: "Moonshine Tiny",
287
+ size: "~124 MB",
288
+ sizeBytes: 130_023_424,
289
+ runtimeRamMB: 310,
290
+ notes: "English only, 5x faster than Whisper Tiny, low accuracy",
291
+ langSupport: "english-only",
292
+ tier: "edge",
293
+ accuracy: 2,
294
+ speed: 5,
295
+ sherpaModel: {
296
+ type: "moonshine",
297
+ files: {
298
+ preprocessor: "preprocess.onnx",
299
+ encoder: "encode.int8.onnx",
300
+ uncachedDecoder: "uncached_decode.int8.onnx",
301
+ cachedDecoder: "cached_decode.int8.onnx",
302
+ tokens: "tokens.txt",
303
+ },
304
+ downloadUrls: {
305
+ preprocessor: hf1("sherpa-onnx-moonshine-tiny-en-int8", "preprocess.onnx"),
306
+ encoder: hf1("sherpa-onnx-moonshine-tiny-en-int8", "encode.int8.onnx"),
307
+ uncachedDecoder: hf1("sherpa-onnx-moonshine-tiny-en-int8", "uncached_decode.int8.onnx"),
308
+ cachedDecoder: hf1("sherpa-onnx-moonshine-tiny-en-int8", "cached_decode.int8.onnx"),
309
+ tokens: hf1("sherpa-onnx-moonshine-tiny-en-int8", "tokens.txt"),
310
+ },
311
+ },
312
+ },
313
+ {
314
+ id: "moonshine-v2-tiny",
315
+ name: "Moonshine v2 Tiny",
316
+ size: "~43 MB",
317
+ sizeBytes: 45_088_768,
318
+ runtimeRamMB: 110,
319
+ notes: "English only, smallest model, 34ms latency, Raspberry Pi friendly",
320
+ langSupport: "english-only",
321
+ tier: "edge",
322
+ preferred: true,
323
+ accuracy: 2,
324
+ speed: 5,
325
+ sherpaModel: {
326
+ type: "moonshine",
327
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
328
+ downloadUrls: {
329
+ encoder: hf2("sherpa-onnx-moonshine-tiny-en-quantized-2026-02-27", "encoder_model.ort"),
330
+ mergedDecoder: hf2("sherpa-onnx-moonshine-tiny-en-quantized-2026-02-27", "decoder_model_merged.ort"),
331
+ tokens: hf2("sherpa-onnx-moonshine-tiny-en-quantized-2026-02-27", "tokens.txt"),
332
+ },
333
+ },
334
+ },
335
+ // ═══════════════════════════════════════════════════════════════════════
336
+ // SPECIALIST — best-in-class for specific languages
337
+ // ═══════════════════════════════════════════════════════════════════════
338
+ {
339
+ id: "sensevoice-small",
340
+ name: "SenseVoice Small",
341
+ size: "~228 MB",
342
+ sizeBytes: 239_075_328,
343
+ runtimeRamMB: 570,
344
+ notes: "Chinese, English, Japanese, Korean, Cantonese — very fast",
345
+ langSupport: "sensevoice",
346
+ tier: "edge",
347
+ preferred: true,
348
+ accuracy: 3,
349
+ speed: 5,
350
+ sherpaModel: {
351
+ type: "sense_voice",
352
+ files: { model: "model.int8.onnx", tokens: "tokens.txt" },
353
+ downloadUrls: {
354
+ model: hf1("sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17", "model.int8.onnx"),
355
+ tokens: hf1("sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17", "tokens.txt"),
356
+ },
357
+ },
358
+ },
359
+ {
360
+ id: "gigaam-v3",
361
+ name: "GigaAM v3",
362
+ size: "~225 MB",
363
+ sizeBytes: 235_929_600,
364
+ runtimeRamMB: 560,
365
+ notes: "Russian — fast and accurate, 50% lower WER than Whisper",
366
+ langSupport: "russian-only",
367
+ tier: "edge",
368
+ preferred: true,
369
+ accuracy: 4,
370
+ speed: 4,
371
+ sherpaModel: {
372
+ type: "nemo_ctc",
373
+ files: { model: "model.int8.onnx", tokens: "tokens.txt" },
374
+ downloadUrls: {
375
+ model: hf1("sherpa-onnx-nemo-ctc-giga-am-v3-russian-2025-12-16", "model.int8.onnx"),
376
+ tokens: hf1("sherpa-onnx-nemo-ctc-giga-am-v3-russian-2025-12-16", "tokens.txt"),
377
+ },
378
+ },
379
+ },
380
+ // ═══════════════════════════════════════════════════════════════════════
381
+ // MOONSHINE v2 LANGUAGE VARIANTS — fast, single-language specialized
382
+ // ═══════════════════════════════════════════════════════════════════════
383
+ {
384
+ id: "moonshine-v2-tiny-ja",
385
+ name: "Moonshine v2 Tiny Japanese",
386
+ size: "~69 MB",
387
+ sizeBytes: 72_351_744,
388
+ runtimeRamMB: 175,
389
+ notes: "Japanese-specialized, ultra-fast",
390
+ langSupport: "single-ja",
391
+ tier: "edge",
392
+ accuracy: 3,
393
+ speed: 5,
394
+ sherpaModel: {
395
+ type: "moonshine",
396
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
397
+ downloadUrls: {
398
+ encoder: hf2("sherpa-onnx-moonshine-tiny-ja-quantized-2026-02-27", "encoder_model.ort"),
399
+ mergedDecoder: hf2("sherpa-onnx-moonshine-tiny-ja-quantized-2026-02-27", "decoder_model_merged.ort"),
400
+ tokens: hf2("sherpa-onnx-moonshine-tiny-ja-quantized-2026-02-27", "tokens.txt"),
401
+ },
402
+ },
403
+ },
404
+ {
405
+ id: "moonshine-v2-tiny-ko",
406
+ name: "Moonshine v2 Tiny Korean",
407
+ size: "~69 MB",
408
+ sizeBytes: 72_351_744,
409
+ runtimeRamMB: 175,
410
+ notes: "Korean-specialized, ultra-fast",
411
+ langSupport: "single-ko",
412
+ tier: "edge",
413
+ accuracy: 3,
414
+ speed: 5,
415
+ sherpaModel: {
416
+ type: "moonshine",
417
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
418
+ downloadUrls: {
419
+ encoder: hf2("sherpa-onnx-moonshine-tiny-ko-quantized-2026-02-27", "encoder_model.ort"),
420
+ mergedDecoder: hf2("sherpa-onnx-moonshine-tiny-ko-quantized-2026-02-27", "decoder_model_merged.ort"),
421
+ tokens: hf2("sherpa-onnx-moonshine-tiny-ko-quantized-2026-02-27", "tokens.txt"),
422
+ },
423
+ },
424
+ },
425
+ {
426
+ id: "moonshine-v2-base-ar",
427
+ name: "Moonshine v2 Base Arabic",
428
+ size: "~135 MB",
429
+ sizeBytes: 141_557_760,
430
+ runtimeRamMB: 340,
431
+ notes: "Arabic-specialized",
432
+ langSupport: "single-ar",
433
+ tier: "edge",
434
+ accuracy: 3,
435
+ speed: 5,
436
+ sherpaModel: {
437
+ type: "moonshine",
438
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
439
+ downloadUrls: {
440
+ encoder: hf2("sherpa-onnx-moonshine-base-ar-quantized-2026-02-27", "encoder_model.ort"),
441
+ mergedDecoder: hf2("sherpa-onnx-moonshine-base-ar-quantized-2026-02-27", "decoder_model_merged.ort"),
442
+ tokens: hf2("sherpa-onnx-moonshine-base-ar-quantized-2026-02-27", "tokens.txt"),
443
+ },
444
+ },
445
+ },
446
+ {
447
+ id: "moonshine-v2-base-zh",
448
+ name: "Moonshine v2 Base Chinese",
449
+ size: "~135 MB",
450
+ sizeBytes: 141_557_760,
451
+ runtimeRamMB: 340,
452
+ notes: "Chinese-specialized",
453
+ langSupport: "single-zh",
454
+ tier: "edge",
455
+ accuracy: 3,
456
+ speed: 5,
457
+ sherpaModel: {
458
+ type: "moonshine",
459
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
460
+ downloadUrls: {
461
+ encoder: hf2("sherpa-onnx-moonshine-base-zh-quantized-2026-02-27", "encoder_model.ort"),
462
+ mergedDecoder: hf2("sherpa-onnx-moonshine-base-zh-quantized-2026-02-27", "decoder_model_merged.ort"),
463
+ tokens: hf2("sherpa-onnx-moonshine-base-zh-quantized-2026-02-27", "tokens.txt"),
464
+ },
465
+ },
466
+ },
467
+ {
468
+ id: "moonshine-v2-base-ja",
469
+ name: "Moonshine v2 Base Japanese",
470
+ size: "~135 MB",
471
+ sizeBytes: 141_557_760,
472
+ runtimeRamMB: 340,
473
+ notes: "Japanese-specialized",
474
+ langSupport: "single-ja",
475
+ tier: "edge",
476
+ accuracy: 3,
477
+ speed: 4,
478
+ sherpaModel: {
479
+ type: "moonshine",
480
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
481
+ downloadUrls: {
482
+ encoder: hf2("sherpa-onnx-moonshine-base-ja-quantized-2026-02-27", "encoder_model.ort"),
483
+ mergedDecoder: hf2("sherpa-onnx-moonshine-base-ja-quantized-2026-02-27", "decoder_model_merged.ort"),
484
+ tokens: hf2("sherpa-onnx-moonshine-base-ja-quantized-2026-02-27", "tokens.txt"),
485
+ },
486
+ },
487
+ },
488
+ {
489
+ id: "moonshine-v2-base-uk",
490
+ name: "Moonshine v2 Base Ukrainian",
491
+ size: "~135 MB",
492
+ sizeBytes: 141_557_760,
493
+ runtimeRamMB: 340,
494
+ notes: "Ukrainian-specialized",
495
+ langSupport: "single-uk",
496
+ tier: "edge",
497
+ accuracy: 3,
498
+ speed: 4,
499
+ sherpaModel: {
500
+ type: "moonshine",
501
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
502
+ downloadUrls: {
503
+ encoder: hf2("sherpa-onnx-moonshine-base-uk-quantized-2026-02-27", "encoder_model.ort"),
504
+ mergedDecoder: hf2("sherpa-onnx-moonshine-base-uk-quantized-2026-02-27", "decoder_model_merged.ort"),
505
+ tokens: hf2("sherpa-onnx-moonshine-base-uk-quantized-2026-02-27", "tokens.txt"),
506
+ },
507
+ },
508
+ },
509
+ {
510
+ id: "moonshine-v2-base-vi",
511
+ name: "Moonshine v2 Base Vietnamese",
512
+ size: "~135 MB",
513
+ sizeBytes: 141_557_760,
514
+ runtimeRamMB: 340,
515
+ notes: "Vietnamese-specialized",
516
+ langSupport: "single-vi",
517
+ tier: "edge",
518
+ accuracy: 3,
519
+ speed: 4,
520
+ sherpaModel: {
521
+ type: "moonshine",
522
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
523
+ downloadUrls: {
524
+ encoder: hf2("sherpa-onnx-moonshine-base-vi-quantized-2026-02-27", "encoder_model.ort"),
525
+ mergedDecoder: hf2("sherpa-onnx-moonshine-base-vi-quantized-2026-02-27", "decoder_model_merged.ort"),
526
+ tokens: hf2("sherpa-onnx-moonshine-base-vi-quantized-2026-02-27", "tokens.txt"),
527
+ },
528
+ },
529
+ },
530
+ {
531
+ id: "moonshine-v2-base-es",
532
+ name: "Moonshine v2 Base Spanish",
533
+ size: "~63 MB",
534
+ sizeBytes: 66_060_288,
535
+ runtimeRamMB: 160,
536
+ notes: "Spanish-specialized",
537
+ langSupport: "single-es",
538
+ tier: "edge",
539
+ accuracy: 3,
540
+ speed: 5,
541
+ sherpaModel: {
542
+ type: "moonshine",
543
+ files: { encoder: "encoder_model.ort", mergedDecoder: "decoder_model_merged.ort", tokens: "tokens.txt" },
544
+ downloadUrls: {
545
+ encoder: hf2("sherpa-onnx-moonshine-base-es-quantized-2026-02-27", "encoder_model.ort"),
546
+ mergedDecoder: hf2("sherpa-onnx-moonshine-base-es-quantized-2026-02-27", "decoder_model_merged.ort"),
547
+ tokens: hf2("sherpa-onnx-moonshine-base-es-quantized-2026-02-27", "tokens.txt"),
548
+ },
549
+ },
550
+ },
551
+ // ═══════════════════════════════════════════════════════════════════════
552
+ // LLM-BASED — Qwen3-ASR and Paraformer (added via local sherpa-onnx patch)
553
+ // ═══════════════════════════════════════════════════════════════════════
554
+ {
555
+ id: "qwen3-asr-0.6b",
556
+ name: "Qwen3 ASR 0.6B",
557
+ size: "~983 MB",
558
+ sizeBytes: 1_031_000_000,
559
+ runtimeRamMB: 2100,
560
+ notes:
561
+ "Qwen (Alibaba) 2026-01 — 30 languages + 22 Chinese dialects, language auto-detection, strong zh/en code-switching. Punctuation per model card (not independently verified on this stack).",
562
+ langSupport: "qwen3",
563
+ tier: "heavy",
564
+ preferred: true,
565
+ accuracy: 4,
566
+ speed: 2,
567
+ sherpaModel: {
568
+ type: "qwen3_asr",
569
+ files: {
570
+ convFrontend: "conv_frontend.onnx",
571
+ encoder: "encoder.int8.onnx",
572
+ decoder: "decoder.int8.onnx",
573
+ tokenizerMerges: "merges.txt",
574
+ tokenizerVocab: "vocab.json",
575
+ tokenizerConfig: "tokenizer_config.json",
576
+ },
577
+ downloadUrls: {
578
+ convFrontend: "https://huggingface.co/pantinor/sherpa-onnx-qwen3-asr-0.6b-int8/resolve/main/conv_frontend.onnx",
579
+ encoder: "https://huggingface.co/pantinor/sherpa-onnx-qwen3-asr-0.6b-int8/resolve/main/encoder.int8.onnx",
580
+ decoder: "https://huggingface.co/pantinor/sherpa-onnx-qwen3-asr-0.6b-int8/resolve/main/decoder.int8.onnx",
581
+ tokenizerMerges:
582
+ "https://huggingface.co/pantinor/sherpa-onnx-qwen3-asr-0.6b-int8/resolve/main/tokenizer/merges.txt",
583
+ tokenizerVocab:
584
+ "https://huggingface.co/pantinor/sherpa-onnx-qwen3-asr-0.6b-int8/resolve/main/tokenizer/vocab.json",
585
+ tokenizerConfig:
586
+ "https://huggingface.co/pantinor/sherpa-onnx-qwen3-asr-0.6b-int8/resolve/main/tokenizer/tokenizer_config.json",
587
+ },
588
+ },
589
+ },
590
+ {
591
+ id: "paraformer-zh",
592
+ name: "Paraformer zh (bilingual)",
593
+ size: "~238 MB",
594
+ sizeBytes: 249_000_000,
595
+ runtimeRamMB: 620,
596
+ notes:
597
+ "Alibaba speech_paraformer-large-vad-punc 2023-09 — zh/en bilingual auto-detect. -punc variant nominally adds punctuation (per model card, not independently verified on this stack).",
598
+ langSupport: "bilingual-zh-en",
599
+ tier: "standard",
600
+ preferred: true,
601
+ accuracy: 4,
602
+ speed: 5,
603
+ sherpaModel: {
604
+ type: "paraformer",
605
+ files: { model: "model.int8.onnx", tokens: "tokens.txt" },
606
+ downloadUrls: {
607
+ model: hf1("sherpa-onnx-paraformer-zh-2023-09-14", "model.int8.onnx"),
608
+ tokens: hf1("sherpa-onnx-paraformer-zh-2023-09-14", "tokens.txt"),
609
+ },
610
+ },
611
+ },
612
+ ];
613
+
614
+ export const DEFAULT_LOCAL_ENDPOINT = "http://localhost:8080";
615
+ export const DEFAULT_LOCAL_MODEL = "parakeet-v3";
616
+
617
+ // ─── Language support per model family ───────────────────────────────────────
618
+ // Whisper uses simple ISO 639-1 codes (no regional variants like "en-AU").
619
+ // Parakeet V2 is English-only. Parakeet V3 shares Whisper's language set.
620
+
621
+ export interface LocalLangEntry {
622
+ name: string;
623
+ code: string;
624
+ popular?: boolean;
625
+ }
626
+
627
+ const WHISPER_LANGUAGES: LocalLangEntry[] = [
628
+ // Popular — shown first
629
+ { name: "English", code: "en", popular: true },
630
+ { name: "Hindi", code: "hi", popular: true },
631
+ { name: "Spanish", code: "es", popular: true },
632
+ { name: "French", code: "fr", popular: true },
633
+ { name: "German", code: "de", popular: true },
634
+ { name: "Portuguese", code: "pt", popular: true },
635
+ { name: "Japanese", code: "ja", popular: true },
636
+ { name: "Korean", code: "ko", popular: true },
637
+ { name: "Chinese", code: "zh", popular: true },
638
+ { name: "Arabic", code: "ar", popular: true },
639
+ { name: "Russian", code: "ru", popular: true },
640
+ { name: "Italian", code: "it", popular: true },
641
+ // All others alphabetically
642
+ { name: "Afrikaans", code: "af" },
643
+ { name: "Armenian", code: "hy" },
644
+ { name: "Azerbaijani", code: "az" },
645
+ { name: "Belarusian", code: "be" },
646
+ { name: "Bengali", code: "bn" },
647
+ { name: "Bosnian", code: "bs" },
648
+ { name: "Bulgarian", code: "bg" },
649
+ { name: "Catalan", code: "ca" },
650
+ { name: "Croatian", code: "hr" },
651
+ { name: "Czech", code: "cs" },
652
+ { name: "Danish", code: "da" },
653
+ { name: "Dutch", code: "nl" },
654
+ { name: "Estonian", code: "et" },
655
+ { name: "Finnish", code: "fi" },
656
+ { name: "Galician", code: "gl" },
657
+ { name: "Greek", code: "el" },
658
+ { name: "Hebrew", code: "he" },
659
+ { name: "Hungarian", code: "hu" },
660
+ { name: "Icelandic", code: "is" },
661
+ { name: "Indonesian", code: "id" },
662
+ { name: "Kannada", code: "kn" },
663
+ { name: "Kazakh", code: "kk" },
664
+ { name: "Latvian", code: "lv" },
665
+ { name: "Lithuanian", code: "lt" },
666
+ { name: "Macedonian", code: "mk" },
667
+ { name: "Malay", code: "ms" },
668
+ { name: "Maori", code: "mi" },
669
+ { name: "Marathi", code: "mr" },
670
+ { name: "Nepali", code: "ne" },
671
+ { name: "Norwegian", code: "no" },
672
+ { name: "Persian", code: "fa" },
673
+ { name: "Polish", code: "pl" },
674
+ { name: "Romanian", code: "ro" },
675
+ { name: "Serbian", code: "sr" },
676
+ { name: "Slovak", code: "sk" },
677
+ { name: "Slovenian", code: "sl" },
678
+ { name: "Swahili", code: "sw" },
679
+ { name: "Swedish", code: "sv" },
680
+ { name: "Tagalog", code: "tl" },
681
+ { name: "Tamil", code: "ta" },
682
+ { name: "Telugu", code: "te" },
683
+ { name: "Thai", code: "th" },
684
+ { name: "Turkish", code: "tr" },
685
+ { name: "Ukrainian", code: "uk" },
686
+ { name: "Urdu", code: "ur" },
687
+ { name: "Vietnamese", code: "vi" },
688
+ { name: "Welsh", code: "cy" },
689
+ ];
690
+
691
+ const ENGLISH_ONLY_LANGUAGES: LocalLangEntry[] = [{ name: "English", code: "en", popular: true }];
692
+
693
+ const SENSEVOICE_LANGUAGES: LocalLangEntry[] = [
694
+ { name: "Chinese (Mandarin)", code: "zh", popular: true },
695
+ { name: "English", code: "en", popular: true },
696
+ { name: "Japanese", code: "ja", popular: true },
697
+ { name: "Korean", code: "ko", popular: true },
698
+ { name: "Cantonese", code: "yue", popular: true },
699
+ ];
700
+
701
+ const RUSSIAN_ONLY_LANGUAGES: LocalLangEntry[] = [{ name: "Russian", code: "ru", popular: true }];
702
+
703
+ /** Paraformer zh — actual capability is zh/en bilingual (auto-detect). */
704
+ const BILINGUAL_ZH_EN_LANGUAGES: LocalLangEntry[] = [
705
+ { name: "Chinese", code: "zh", popular: true },
706
+ { name: "English", code: "en", popular: true },
707
+ ];
708
+
709
+ /**
710
+ * Qwen3-ASR 0.6B — language auto-detection across 30 languages + 22 Chinese
711
+ * dialects (verified against Qwen/sherpa-onnx published specs; the full 30-language
712
+ * list is not enumerated here, zh/en shown as the primary use cases).
713
+ */
714
+ const QWEN3_LANGUAGES: LocalLangEntry[] = [
715
+ { name: "Chinese", code: "zh", popular: true },
716
+ { name: "English", code: "en", popular: true },
717
+ ];
718
+
719
+ // Single-language lists for Moonshine Flavors
720
+ const SINGLE_LANG: Record<string, LocalLangEntry[]> = {
721
+ ar: [{ name: "Arabic", code: "ar", popular: true }],
722
+ zh: [{ name: "Chinese", code: "zh", popular: true }],
723
+ ja: [{ name: "Japanese", code: "ja", popular: true }],
724
+ ko: [{ name: "Korean", code: "ko", popular: true }],
725
+ uk: [{ name: "Ukrainian", code: "uk", popular: true }],
726
+ vi: [{ name: "Vietnamese", code: "vi", popular: true }],
727
+ es: [{ name: "Spanish", code: "es", popular: true }],
728
+ };
729
+
730
+ /**
731
+ * Get the supported language list for a local model.
732
+ * Returns englishOnly=true when only one language is supported (no picker needed).
733
+ */
734
+ /**
735
+ * Shared langSupport → language-list map. BOTH the UI (getLanguagesForLocalModel)
736
+ * and the device recommender (device.ts modelSupportsLanguage) read from here,
737
+ * so a new langSupport value can never drift one side fail-open again.
738
+ *
739
+ * Unknown langSupport values (forward/custom models) conservatively resolve to
740
+ * no languages: an unregistered capability must NOT silently mean "all".
741
+ */
742
+ export function languagesForLangSupport(langSupport: LocalModelInfo["langSupport"]): LocalLangEntry[] {
743
+ switch (langSupport) {
744
+ case "english-only":
745
+ return ENGLISH_ONLY_LANGUAGES;
746
+ case "russian-only":
747
+ return RUSSIAN_ONLY_LANGUAGES;
748
+ case "single-ar":
749
+ return SINGLE_LANG.ar!;
750
+ case "single-zh":
751
+ return SINGLE_LANG.zh!;
752
+ case "single-ja":
753
+ return SINGLE_LANG.ja!;
754
+ case "single-ko":
755
+ return SINGLE_LANG.ko!;
756
+ case "single-uk":
757
+ return SINGLE_LANG.uk!;
758
+ case "single-vi":
759
+ return SINGLE_LANG.vi!;
760
+ case "single-es":
761
+ return SINGLE_LANG.es!;
762
+ case "sensevoice":
763
+ return SENSEVOICE_LANGUAGES;
764
+ case "bilingual-zh-en":
765
+ return BILINGUAL_ZH_EN_LANGUAGES;
766
+ case "qwen3":
767
+ return QWEN3_LANGUAGES;
768
+ case "whisper":
769
+ case "parakeet-multi":
770
+ return WHISPER_LANGUAGES;
771
+ default:
772
+ // Unregistered language families conservatively resolve to empty (assume no languages)
773
+ return [];
774
+ }
775
+ }
776
+
777
+ export function getLanguagesForLocalModel(modelId: string): { languages: LocalLangEntry[]; englishOnly: boolean } {
778
+ const model = LOCAL_MODELS.find((m) => m.id === modelId);
779
+ // UI is lenient with unknown model ids — show the full Whisper list for manual selection.
780
+ // (Capability checks go through device.modelSupportsLanguage, which uses the shared table by langSupport.)
781
+ if (!model) return { languages: WHISPER_LANGUAGES, englishOnly: false };
782
+ const languages = languagesForLangSupport(model.langSupport);
783
+ return { languages, englishOnly: languages.length <= 1 };
784
+ }
785
+
786
+ /**
787
+ * Check if a language code is supported by a local model.
788
+ * Used to validate /voice-language changes against current model.
789
+ */
790
+ export function isLanguageSupportedByModel(modelId: string, langCode: string): boolean {
791
+ const { languages } = getLanguagesForLocalModel(modelId);
792
+ // Match base code (e.g. "en" matches "en", regional variants stripped for local)
793
+ const baseCode = langCode.split("-")[0];
794
+ return languages.some((l) => l.code === baseCode || l.code === langCode);
795
+ }
796
+
797
+ /**
798
+ * Find display name for a language code in local model context.
799
+ */
800
+ export function localLanguageDisplayName(code: string): string {
801
+ // Check all language lists
802
+ const allLists = [
803
+ WHISPER_LANGUAGES,
804
+ SENSEVOICE_LANGUAGES,
805
+ BILINGUAL_ZH_EN_LANGUAGES,
806
+ QWEN3_LANGUAGES,
807
+ RUSSIAN_ONLY_LANGUAGES,
808
+ ...Object.values(SINGLE_LANG),
809
+ ];
810
+ for (const list of allLists) {
811
+ const entry = list.find((l) => l.code === code);
812
+ if (entry) return `${entry.name} (${entry.code})`;
813
+ }
814
+ return code;
815
+ }
816
+
817
+ // ─── Local session type ──────────────────────────────────────────────────────
818
+
819
+ export interface LocalSession {
820
+ backend: "local";
821
+ recProcess: ChildProcess;
822
+ audioChunks: Buffer[];
823
+ closed: boolean;
824
+ hadAudioData: boolean;
825
+ onTranscript: (interim: string, finals: string[]) => void;
826
+ onDone: (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => void;
827
+ onError: (err: string) => void;
828
+ }
829
+
830
+ // ─── WAV encoding ────────────────────────────────────────────────────────────
831
+
832
+ /** Create a WAV file buffer from raw PCM data (16-bit signed LE, 16kHz, mono). */
833
+ function createWavBuffer(pcmData: Buffer): Buffer {
834
+ const header = Buffer.alloc(44);
835
+ const dataSize = pcmData.length;
836
+ const fileSize = 36 + dataSize;
837
+
838
+ // RIFF header
839
+ header.write("RIFF", 0);
840
+ header.writeUInt32LE(fileSize, 4);
841
+ header.write("WAVE", 8);
842
+
843
+ // fmt chunk
844
+ header.write("fmt ", 12);
845
+ header.writeUInt32LE(16, 16); // chunk size
846
+ header.writeUInt16LE(1, 20); // PCM format
847
+ header.writeUInt16LE(CHANNELS, 22);
848
+ header.writeUInt32LE(SAMPLE_RATE, 24);
849
+ header.writeUInt32LE(SAMPLE_RATE * CHANNELS * 2, 28); // byte rate
850
+ header.writeUInt16LE(CHANNELS * 2, 32); // block align
851
+ header.writeUInt16LE(16, 34); // bits per sample
852
+
853
+ // data chunk
854
+ header.write("data", 36);
855
+ header.writeUInt32LE(dataSize, 40);
856
+
857
+ return Buffer.concat([header, pcmData]);
858
+ }
859
+
860
+ // ─── Transcription via local server ──────────────────────────────────────────
861
+
862
+ /**
863
+ * POST audio to a local OpenAI-compatible transcription endpoint.
864
+ * Tries /v1/audio/transcriptions first, falls back to /inference (whisper.cpp native).
865
+ */
866
+ export async function transcribeWithServer(wavBuffer: Buffer, config: VoiceConfig): Promise<string> {
867
+ const endpoint = config.localEndpoint || DEFAULT_LOCAL_ENDPOINT;
868
+
869
+ // Security: refuse to send audio to non-loopback endpoints
870
+ if (!isLoopbackEndpoint(endpoint)) {
871
+ throw new Error(`Refusing to send audio to non-local endpoint: ${endpoint}. Only localhost/127.0.0.1/::1 allowed.`);
872
+ }
873
+
874
+ const model = config.localModel || DEFAULT_LOCAL_MODEL;
875
+ const language = config.language || "en";
876
+
877
+ // Build multipart/form-data manually (no external deps)
878
+ const boundary = `----PiVoice${Date.now()}`;
879
+ const parts: Buffer[] = [];
880
+
881
+ // file field
882
+ parts.push(
883
+ Buffer.from(
884
+ `--${boundary}\r\n` +
885
+ `Content-Disposition: form-data; name="file"; filename="audio.wav"\r\n` +
886
+ `Content-Type: audio/wav\r\n\r\n`
887
+ )
888
+ );
889
+ parts.push(wavBuffer);
890
+ parts.push(Buffer.from("\r\n"));
891
+
892
+ // model field
893
+ parts.push(
894
+ Buffer.from(`--${boundary}\r\n` + `Content-Disposition: form-data; name="model"\r\n\r\n` + `${model}\r\n`)
895
+ );
896
+
897
+ // language field
898
+ parts.push(
899
+ Buffer.from(`--${boundary}\r\n` + `Content-Disposition: form-data; name="language"\r\n\r\n` + `${language}\r\n`)
900
+ );
901
+
902
+ // response_format field
903
+ parts.push(
904
+ Buffer.from(`--${boundary}\r\n` + `Content-Disposition: form-data; name="response_format"\r\n\r\n` + `json\r\n`)
905
+ );
906
+
907
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
908
+
909
+ const body = Buffer.concat(parts);
910
+
911
+ // Try OpenAI-compatible endpoint first
912
+ const urls = [`${endpoint}/v1/audio/transcriptions`, `${endpoint}/inference`];
913
+
914
+ let lastError = "";
915
+ for (const url of urls) {
916
+ try {
917
+ const resp = await fetch(url, {
918
+ method: "POST",
919
+ headers: {
920
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
921
+ "Content-Length": String(body.length),
922
+ },
923
+ body,
924
+ signal: AbortSignal.timeout(120_000), // 2 min timeout for large models
925
+ });
926
+
927
+ if (!resp.ok) {
928
+ lastError = `HTTP ${resp.status}: ${await resp.text().catch(() => "unknown")}`;
929
+ continue;
930
+ }
931
+
932
+ const contentType = resp.headers.get("content-type") || "";
933
+ if (contentType.includes("application/json")) {
934
+ const json = (await resp.json()) as { text?: string };
935
+ return (json.text || "").trim();
936
+ }
937
+ // Plain text response
938
+ return (await resp.text()).trim();
939
+ } catch (err: any) {
940
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") {
941
+ lastError = "Transcription timed out (120s)";
942
+ break; // Don't retry on timeout
943
+ }
944
+ lastError = err?.message || String(err);
945
+ // Connection refused = server not running, try next URL
946
+ continue;
947
+ }
948
+ }
949
+
950
+ throw new Error(lastError || "Could not connect to local transcription server");
951
+ }
952
+
953
+ // ─── Session lifecycle ───────────────────────────────────────────────────────
954
+
955
+ /**
956
+ * Start a local recording session. Audio is buffered in memory.
957
+ * Transcription happens when stopLocalSession() is called.
958
+ */
959
+ export function startLocalSession(
960
+ recProcess: ChildProcess,
961
+ callbacks: {
962
+ onTranscript: (interim: string, finals: string[]) => void;
963
+ onDone: (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => void;
964
+ onError: (err: string) => void;
965
+ }
966
+ ): LocalSession {
967
+ const session: LocalSession = {
968
+ backend: "local",
969
+ recProcess,
970
+ audioChunks: [],
971
+ closed: false,
972
+ hadAudioData: false,
973
+ onTranscript: callbacks.onTranscript,
974
+ onDone: callbacks.onDone,
975
+ onError: callbacks.onError,
976
+ };
977
+
978
+ recProcess.stdout?.on("data", (chunk: Buffer) => {
979
+ if (!session.closed) {
980
+ session.hadAudioData = true;
981
+ session.audioChunks.push(chunk);
982
+ }
983
+ });
984
+
985
+ recProcess.stderr?.on("data", (d: Buffer) => {
986
+ const msg = d.toString().trim();
987
+ if (msg.includes("buffer overrun") || msg.includes("Discarding") || msg.includes("Last message repeated")) return;
988
+ });
989
+
990
+ recProcess.on("error", (err) => {
991
+ if (!session.closed) {
992
+ session.onError(`Audio capture error: ${err.message}`);
993
+ }
994
+ });
995
+
996
+ return session;
997
+ }
998
+
999
+ /**
1000
+ * Stop recording and transcribe the buffered audio.
1001
+ *
1002
+ * Routes transcription based on config:
1003
+ * - If localEndpoint is set → external server (advanced users)
1004
+ * - Otherwise → sherpa-onnx in-process (default, zero-config)
1005
+ */
1006
+ export async function stopLocalSession(session: LocalSession, config: VoiceConfig): Promise<void> {
1007
+ if (session.closed) return;
1008
+
1009
+ // Stop recording
1010
+ try {
1011
+ session.recProcess.kill("SIGTERM");
1012
+ } catch {}
1013
+
1014
+ // Wait briefly for any remaining audio data
1015
+ await new Promise((r) => setTimeout(r, 200));
1016
+
1017
+ // Recheck after await — abort may have fired during the 200ms wait.
1018
+ // Still call onDone so the voice state machine transitions back to idle.
1019
+ if (session.closed) {
1020
+ session.onDone("", { hadAudio: false, hadSpeech: false });
1021
+ return;
1022
+ }
1023
+
1024
+ const pcmData = Buffer.concat(session.audioChunks);
1025
+ // Free individual chunk references during transcription
1026
+ session.audioChunks.length = 0;
1027
+
1028
+ if (pcmData.length === 0) {
1029
+ session.closed = true;
1030
+ session.onDone("", { hadAudio: false, hadSpeech: false });
1031
+ return;
1032
+ }
1033
+
1034
+ try {
1035
+ let text: string;
1036
+
1037
+ if (config.localEndpoint) {
1038
+ // External server mode (advanced override)
1039
+ const wavBuffer = createWavBuffer(pcmData);
1040
+ text = await transcribeWithServer(wavBuffer, config);
1041
+ } else {
1042
+ // In-process via sherpa-onnx (default, 120s timeout)
1043
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
1044
+ text = await Promise.race([
1045
+ transcribeInProcess(pcmData, config),
1046
+ new Promise<never>((_, reject) => {
1047
+ timeoutHandle = setTimeout(() => reject(new Error("Transcription timed out (120s)")), 120_000);
1048
+ }),
1049
+ ]).finally(() => {
1050
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1051
+ });
1052
+ }
1053
+
1054
+ // Recheck after await — abort may have fired during transcription.
1055
+ // Still call onDone so state machine transitions back to idle.
1056
+ if (session.closed) {
1057
+ session.onDone("", { hadAudio: false, hadSpeech: false });
1058
+ return;
1059
+ }
1060
+
1061
+ session.closed = true;
1062
+ session.onDone(text, { hadAudio: true, hadSpeech: text.trim().length > 0 });
1063
+ } catch (err: any) {
1064
+ if (session.closed) {
1065
+ // Session was aborted during transcription — still surface the error
1066
+ // so the user knows transcription failed, not just "no speech"
1067
+ session.onError(`Local transcription aborted: ${err.message || err}`);
1068
+ return;
1069
+ }
1070
+ session.closed = true;
1071
+ session.onError(`Local transcription failed: ${err.message || err}`);
1072
+ }
1073
+ }
1074
+
1075
+ /** Abort a local session — kill recording, discard audio. */
1076
+ export function abortLocalSession(session: LocalSession | null): void {
1077
+ if (!session || session.closed) return;
1078
+ session.closed = true;
1079
+ try {
1080
+ session.recProcess.kill("SIGKILL");
1081
+ } catch {}
1082
+ }
1083
+
1084
+ // ─── In-process transcription via sherpa-onnx ────────────────────────────────
1085
+
1086
+ /**
1087
+ * Transcribe PCM audio using sherpa-onnx in-process.
1088
+ * Auto-downloads model on first use.
1089
+ */
1090
+ async function transcribeInProcess(pcmData: Buffer, config: VoiceConfig): Promise<string> {
1091
+ const {
1092
+ initSherpa,
1093
+ isSherpaAvailable,
1094
+ getSherpaError,
1095
+ getOrCreateRecognizer,
1096
+ transcribeBuffer,
1097
+ transcribeBufferSegmented,
1098
+ } = await import("./sherpa-engine");
1099
+ const { ensureModelDownloaded } = await import("./model-download");
1100
+
1101
+ // Initialize sherpa if needed
1102
+ if (!isSherpaAvailable()) {
1103
+ const ok = await initSherpa();
1104
+ if (!ok) {
1105
+ throw new Error(
1106
+ `sherpa-onnx not available: ${getSherpaError() || "unknown error"}. Set localEndpoint in config to use an external server instead.`
1107
+ );
1108
+ }
1109
+ }
1110
+
1111
+ const model = LOCAL_MODELS.find((m) => m.id === (config.localModel || DEFAULT_LOCAL_MODEL));
1112
+ if (!model) throw new Error(`Unknown model: ${config.localModel}`);
1113
+
1114
+ // Ensure model files are downloaded
1115
+ const modelDir = await ensureModelDownloaded(model.id, model.sherpaModel.downloadUrls, model.sizeBytes);
1116
+
1117
+ // Create/reuse recognizer and transcribe
1118
+ const recognizer = getOrCreateRecognizer(model, modelDir, config.language || "en");
1119
+ // Qwen3-ASR caps context at 512 tokens (~18s); segment long audio via VAD before decode.
1120
+ if (model.sherpaModel.type === "qwen3_asr") {
1121
+ return transcribeBufferSegmented(pcmData, recognizer);
1122
+ }
1123
+ return transcribeBuffer(pcmData, recognizer);
1124
+ }
1125
+
1126
+ /** Check if a local transcription server is reachable. */
1127
+ export async function checkLocalServer(endpoint?: string): Promise<{ ok: boolean; error?: string }> {
1128
+ const url = endpoint || DEFAULT_LOCAL_ENDPOINT;
1129
+ try {
1130
+ const resp = await fetch(`${url}/v1/models`, {
1131
+ signal: AbortSignal.timeout(5000),
1132
+ }).catch(() =>
1133
+ // whisper.cpp server doesn't have /v1/models, try root
1134
+ fetch(url, { signal: AbortSignal.timeout(5000) })
1135
+ );
1136
+ return { ok: resp.ok || resp.status === 404 }; // 404 = server is up, just no models endpoint
1137
+ } catch (err: any) {
1138
+ if (err?.cause?.code === "ECONNREFUSED") {
1139
+ return { ok: false, error: `Server not running at ${url}` };
1140
+ }
1141
+ return { ok: false, error: err?.message || String(err) };
1142
+ }
1143
+ }