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,1255 @@
1
+ /**
2
+ * Local TTS model catalog for pi-listen v6.0.0+.
3
+ *
4
+ * Three tiers:
5
+ * - Tier 0 (default): Kitten Nano v0.2 — 25.4 MB, 8 voices, English. The
6
+ * first-run download. Apache-2.0, sub-real-time on M-series.
7
+ * - Tier 1 (per-language Piper): one ~20 MB voice per popular language. The
8
+ * user installs only what they need.
9
+ * - Tier 2 (multilingual / HQ Kokoro): 98-126 MB. Opt-in for users who
10
+ * prefer prosody quality over disk usage.
11
+ *
12
+ * Sherpa-onnx-node OfflineTts supports five model "slots" (verified in
13
+ * node_modules/sherpa-onnx-node/types.js OfflineTtsModelConfig):
14
+ * vits | matcha | kokoro | kitten | pocket
15
+ * We use kitten (Kitten Nano), vits (every Piper voice), and kokoro (the two
16
+ * Kokoro entries). Pocket is voice cloning — different use case, out of scope.
17
+ *
18
+ * Why .tar.bz2 archives instead of individual .onnx URLs (like the STT path):
19
+ * sherpa-onnx publishes each TTS model as a single archive containing the
20
+ * model files plus an `espeak-ng-data/` directory (Piper, Kokoro, Kitten all
21
+ * need this for grapheme-to-phoneme conversion). We extract once on download
22
+ * and cache the unpacked dir under `~/.pi/models/tts/<modelId>/`.
23
+ *
24
+ * Asset URLs verified via GitHub API
25
+ * (`api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/tts-models`)
26
+ * on 2026-04-28. If a URL 404s in the future, regenerate this catalog from
27
+ * the latest release tag — the structure is stable, only filenames change.
28
+ */
29
+
30
+ import * as fs from "node:fs";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { spawn } from "node:child_process";
34
+ import { createHash } from "node:crypto";
35
+
36
+ const TTS_RELEASE = "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models";
37
+
38
+ // ─── Types ────────────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Sherpa-onnx model slot. Maps directly to OfflineTtsModelConfig in
42
+ * sherpa-onnx-node — the engine dispatches on this in tts-engine.ts.
43
+ */
44
+ export type TtsSherpaSlot = "kitten" | "vits" | "kokoro";
45
+
46
+ export interface TtsVoice {
47
+ /** Sherpa speaker id (numeric). Passed as `sid` to OfflineTts.generate(). */
48
+ sid: number;
49
+ /** Display name shown in the voice picker. */
50
+ name: string;
51
+ /** Optional gender hint for grouping in the voice picker. */
52
+ gender?: "male" | "female" | "neutral";
53
+ }
54
+
55
+ export interface TtsLocalModelInfo {
56
+ id: string;
57
+ name: string;
58
+ /** Human-readable archive size, e.g. "~25 MB". */
59
+ size: string;
60
+ /** Archive size in bytes (drives disk-space pre-checks + progress). */
61
+ sizeBytes: number;
62
+ /** Peak runtime RAM in MB (rough — model file × ~3 covers vocoder buffers). */
63
+ runtimeRamMB: number;
64
+ /** One-line description shown in the picker detail row. */
65
+ notes: string;
66
+ /**
67
+ * Languages supported by this model. BCP-47-ish tags; for filtering and
68
+ * for picker labels. Single-language models list one entry; Kokoro
69
+ * multilingual lists all 9.
70
+ */
71
+ languages: string[];
72
+ /** Device tier — drives the fitness algorithm shared with STT. */
73
+ tier: "edge" | "standard" | "heavy";
74
+ /** Marked as "recommended" in the catalog UI. */
75
+ preferred?: boolean;
76
+ /** Subjective accuracy rating 1-5 (5 = best). */
77
+ accuracy: 1 | 2 | 3 | 4 | 5;
78
+ /** Subjective speed rating 1-5 (5 = fastest). */
79
+ speed: 1 | 2 | 3 | 4 | 5;
80
+ /** License — needs to be commercial-use OK to ship as default. */
81
+ license: string;
82
+ /** Sherpa-onnx model slot for the engine to dispatch on. */
83
+ sherpaSlot: TtsSherpaSlot;
84
+ /** Voices available in this model (always at least one). */
85
+ voices: TtsVoice[];
86
+ /** Default voice (sid) used if the user hasn't picked one. */
87
+ defaultSid: number;
88
+ /**
89
+ * The single archive URL (sherpa-onnx packs the model + tokens +
90
+ * espeak-ng-data into one .tar.bz2). The downloader extracts to
91
+ * `~/.pi/models/tts/<id>/` and returns that directory.
92
+ */
93
+ archiveUrl: string;
94
+ /**
95
+ * Optional SHA-256 hex digest of the archive bytes for integrity
96
+ * verification. When set, ensureTtsModelInstalled() rejects a
97
+ * download whose computed hash differs. v7.0.0 ships catalog entries
98
+ * without hashes (we don't ship known-good values for sherpa-onnx
99
+ * releases yet); the verification pipeline runs anyway and produces
100
+ * a hash that can be pinned in v7.1+ to lock-in the bytes.
101
+ */
102
+ archiveSha256?: string;
103
+ /** Sample rate (Hz) of generated audio. Drives WAV header on playback. */
104
+ sampleRate: number;
105
+ /**
106
+ * v7.1.2 — runtime incompatibility marker. When the model is known
107
+ * to fail with the currently-installed `sherpa-onnx-node` (e.g.
108
+ * kokoro multilingual + 1.12.29 returns all-NaN samples for every
109
+ * sid), set `incompatible` to a one-line user-facing reason. The
110
+ * picker shows the model with a warning badge; the smart-default
111
+ * recommender skips it; `synthesize()` refuses to use it.
112
+ */
113
+ incompatible?: string;
114
+ }
115
+
116
+ // ─── Catalog ──────────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Default TTS model id — what gets downloaded on first `/voice-speak` if no
120
+ * model is selected. Chosen for the smallest viable English model (25 MB)
121
+ * with a permissive license and 8 voices for variety.
122
+ */
123
+ export const DEFAULT_TTS_MODEL = "kitten-nano-en-v0_2";
124
+
125
+ /**
126
+ * The full catalog. Order is intentional — picker presents it as-is,
127
+ * grouped in the settings panel by sherpa slot.
128
+ */
129
+ export const TTS_LOCAL_MODELS: TtsLocalModelInfo[] = [
130
+ // ═══════════════════════════════════════════════════════════════════════
131
+ // TIER 0 — Default first-run (English-only, smallest)
132
+ // ═══════════════════════════════════════════════════════════════════════
133
+ {
134
+ id: "kitten-nano-en-v0_2",
135
+ name: "Kitten Nano v0.2",
136
+ size: "~25 MB",
137
+ sizeBytes: 26_633_011,
138
+ runtimeRamMB: 120,
139
+ notes: "Smallest English TTS — 15M params, 8 voices, 24 kHz, sub-real-time on M-series",
140
+ languages: ["en"],
141
+ tier: "edge",
142
+ preferred: true,
143
+ accuracy: 4,
144
+ speed: 5,
145
+ license: "Apache-2.0",
146
+ sherpaSlot: "kitten",
147
+ // Voice ordering follows the order baked into the `voices.bin` file
148
+ // shipped with the model — sids 0-7 in canonical order.
149
+ voices: [
150
+ { sid: 0, name: "Expr-Voice-2-M", gender: "male" },
151
+ { sid: 1, name: "Expr-Voice-2-F", gender: "female" },
152
+ { sid: 2, name: "Expr-Voice-3-M", gender: "male" },
153
+ { sid: 3, name: "Expr-Voice-3-F", gender: "female" },
154
+ { sid: 4, name: "Expr-Voice-4-M", gender: "male" },
155
+ { sid: 5, name: "Expr-Voice-4-F", gender: "female" },
156
+ { sid: 6, name: "Expr-Voice-5-M", gender: "male" },
157
+ { sid: 7, name: "Expr-Voice-5-F", gender: "female" },
158
+ ],
159
+ defaultSid: 0,
160
+ archiveUrl: `${TTS_RELEASE}/kitten-nano-en-v0_2-fp16.tar.bz2`,
161
+ sampleRate: 24000,
162
+ },
163
+
164
+ // ═══════════════════════════════════════════════════════════════════════
165
+ // TIER 1 — Per-language Piper voices (each ~20 MB)
166
+ // ═══════════════════════════════════════════════════════════════════════
167
+ piper(
168
+ "en_US-lessac-medium-int8",
169
+ "Piper Lessac (en-US)",
170
+ 20_971_520,
171
+ ["en-US"],
172
+ "Clear American voice — solid technical-prose default",
173
+ "MIT",
174
+ true,
175
+ 22050
176
+ ),
177
+ piper(
178
+ "en_US-amy-medium-int8",
179
+ "Piper Amy (en-US)",
180
+ 21_065_728,
181
+ ["en-US"],
182
+ "Female American voice",
183
+ "MIT",
184
+ false,
185
+ 22050,
186
+ "female"
187
+ ),
188
+ piper(
189
+ "en_US-libritts_r-medium-int8",
190
+ "Piper LibriTTS-R (en-US)",
191
+ 23_383_244,
192
+ ["en-US"],
193
+ "904 voices in one model — pick a sid in the picker",
194
+ "MIT",
195
+ false,
196
+ 22050,
197
+ "neutral",
198
+ 904
199
+ ),
200
+ piper(
201
+ "es_ES-davefx-medium-int8",
202
+ "Piper DaveFX (es-ES)",
203
+ 21_169_356,
204
+ ["es-ES"],
205
+ "European Spanish, male voice",
206
+ "MIT",
207
+ true,
208
+ 22050,
209
+ "male"
210
+ ),
211
+ piper(
212
+ "fr_FR-siwis-medium-int8",
213
+ "Piper Siwis (fr-FR)",
214
+ 20_866_662,
215
+ ["fr-FR"],
216
+ "French, female voice",
217
+ "MIT",
218
+ true,
219
+ 22050,
220
+ "female"
221
+ ),
222
+ piper(
223
+ "de_DE-thorsten-medium-int8",
224
+ "Piper Thorsten (de-DE)",
225
+ 20_971_520,
226
+ ["de-DE"],
227
+ "German, male voice",
228
+ "MIT",
229
+ true,
230
+ 22050,
231
+ "male"
232
+ ),
233
+ piper(
234
+ "hi_IN-pratham-medium-int8",
235
+ "Piper Pratham (hi-IN)",
236
+ 20_971_520,
237
+ ["hi-IN"],
238
+ "Hindi, male voice",
239
+ "MIT",
240
+ true,
241
+ 22050,
242
+ "male"
243
+ ),
244
+ piper(
245
+ "pt_BR-cadu-medium-int8",
246
+ "Piper Cadu (pt-BR)",
247
+ 21_169_356,
248
+ ["pt-BR"],
249
+ "Brazilian Portuguese, male voice",
250
+ "MIT",
251
+ true,
252
+ 22050,
253
+ "male"
254
+ ),
255
+ piper(
256
+ "zh_CN-chaowen-medium-int8",
257
+ "Piper Chaowen (zh-CN)",
258
+ 14_050_918,
259
+ ["zh-CN"],
260
+ "Mandarin Chinese — smaller archive than other languages",
261
+ "MIT",
262
+ true,
263
+ 22050
264
+ ),
265
+ piper(
266
+ "it_IT-paola-medium-int8",
267
+ "Piper Paola (it-IT)",
268
+ 21_169_356,
269
+ ["it-IT"],
270
+ "Italian, female voice",
271
+ "MIT",
272
+ true,
273
+ 22050,
274
+ "female"
275
+ ),
276
+ piper(
277
+ "ru_RU-denis-medium-int8",
278
+ "Piper Denis (ru-RU)",
279
+ 21_065_728,
280
+ ["ru-RU"],
281
+ "Russian, male voice",
282
+ "MIT",
283
+ true,
284
+ 22050,
285
+ "male"
286
+ ),
287
+ piper(
288
+ "ar_JO-kareem-medium-int8",
289
+ "Piper Kareem (ar-JO)",
290
+ 20_971_520,
291
+ ["ar-JO"],
292
+ "Levantine Arabic, male voice",
293
+ "MIT",
294
+ false,
295
+ 22050,
296
+ "male"
297
+ ),
298
+ piper(
299
+ "tr_TR-fahrettin-medium-int8",
300
+ "Piper Fahrettin (tr-TR)",
301
+ 21_065_728,
302
+ ["tr-TR"],
303
+ "Turkish, male voice",
304
+ "MIT",
305
+ false,
306
+ 22050,
307
+ "male"
308
+ ),
309
+ piper(
310
+ "nl_NL-pim-medium-int8",
311
+ "Piper Pim (nl-NL)",
312
+ 21_065_728,
313
+ ["nl-NL"],
314
+ "Dutch, male voice",
315
+ "MIT",
316
+ false,
317
+ 22050,
318
+ "male"
319
+ ),
320
+
321
+ // ═══════════════════════════════════════════════════════════════════════
322
+ // TIER 2 — Multilingual + English HQ (Kokoro family, opt-in due to size)
323
+ // ═══════════════════════════════════════════════════════════════════════
324
+ {
325
+ id: "kokoro-int8-multi-lang-v1_0",
326
+ name: "Kokoro Multilingual v1.0",
327
+ size: "~126 MB",
328
+ sizeBytes: 131_822_387,
329
+ runtimeRamMB: 870,
330
+ notes: "9 languages in one model — en/zh/ja/ko/es/fr/hi/it/pt — 53 voices, 24 kHz",
331
+ languages: ["en", "zh", "ja", "ko", "es", "fr", "hi", "it", "pt"],
332
+ tier: "standard",
333
+ preferred: true,
334
+ accuracy: 5,
335
+ speed: 4,
336
+ license: "Apache-2.0",
337
+ sherpaSlot: "kokoro",
338
+ // Kokoro v1.0 ships 53 voices labeled by lang prefix (e.g. `af_*` =
339
+ // American female, `am_*` = American male, `bf_*` / `bm_*` = British,
340
+ // `jf_*`/`jm_*` = Japanese, etc.). We surface the most-likely picks
341
+ // per language; users can pick others via numeric sid in the picker.
342
+ voices: [
343
+ { sid: 0, name: "af_heart (en-US, female)", gender: "female" },
344
+ { sid: 1, name: "af_alloy (en-US, female)", gender: "female" },
345
+ { sid: 2, name: "af_aoede (en-US, female)", gender: "female" },
346
+ { sid: 11, name: "am_adam (en-US, male)", gender: "male" },
347
+ { sid: 12, name: "am_echo (en-US, male)", gender: "male" },
348
+ { sid: 20, name: "bf_alice (en-GB, female)", gender: "female" },
349
+ { sid: 24, name: "bm_daniel (en-GB, male)", gender: "male" },
350
+ { sid: 28, name: "ef_dora (es, female)", gender: "female" },
351
+ { sid: 31, name: "em_alex (es, male)", gender: "male" },
352
+ { sid: 33, name: "ff_siwis (fr, female)", gender: "female" },
353
+ { sid: 34, name: "hf_alpha (hi, female)", gender: "female" },
354
+ { sid: 38, name: "if_sara (it, female)", gender: "female" },
355
+ { sid: 40, name: "jf_alpha (ja, female)", gender: "female" },
356
+ { sid: 44, name: "kf_yumi (ko, female)", gender: "female" },
357
+ { sid: 46, name: "pf_dora (pt-BR, female)", gender: "female" },
358
+ { sid: 48, name: "zf_xiaobei (zh, female)", gender: "female" },
359
+ { sid: 50, name: "zm_yunjian (zh, male)", gender: "male" },
360
+ ],
361
+ defaultSid: 0,
362
+ archiveUrl: `${TTS_RELEASE}/kokoro-int8-multi-lang-v1_0.tar.bz2`,
363
+ sampleRate: 24000,
364
+ // v7.1.2: most voices in this model produce NaN samples on
365
+ // sherpa-onnx-node 1.12.29 + 1.13.0. Root cause is int8
366
+ // quantization of speaker embeddings in voices.bin (per
367
+ // upstream issue #1923 / mlx-audio "guard NaN durations" PR
368
+ // pattern). Failures are non-deterministic per-voice across
369
+ // fresh processes. Marked incompatible so the picker hides
370
+ // it and synthesize() refuses to use it. Re-enable when
371
+ // upstream ships an fp16/fp32 multilingual model OR a
372
+ // re-quantized int8 voices.bin without NaN embeddings.
373
+ incompatible:
374
+ "kokoro multilingual int8 v1.0 produces NaN samples on most voices. Use kokoro-multi-lang-v1_1 (newer) or kokoro-en-v0_19 instead.",
375
+ },
376
+ // v7.1.3: NEWER kokoro multilingual (v1.1) — sherpa-onnx upstream
377
+ // re-quantized voices.bin. Both fp32 (333+ MB) and int8 (140 MB)
378
+ // variants are now available. v1.1 preferred over v1.0 because
379
+ // upstream specifically rebuilt it to address the NaN issue.
380
+ // Marked `untested: true` until you confirm it synthesizes
381
+ // real audio in your environment — same picker treatment as
382
+ // `incompatible`, but doesn't refuse to run.
383
+ {
384
+ id: "kokoro-int8-multi-lang-v1_1",
385
+ name: "Kokoro Multilingual v1.1 (int8)",
386
+ size: "~140 MB",
387
+ sizeBytes: 147_031_220,
388
+ runtimeRamMB: 900,
389
+ notes: "v1.1 multilingual — upstream re-quantized after the v1.0 NaN issue. 9 langs, ~50 voices, 24 kHz",
390
+ languages: ["en", "zh", "ja", "ko", "es", "fr", "hi", "it", "pt"],
391
+ tier: "standard",
392
+ preferred: false,
393
+ accuracy: 5,
394
+ speed: 4,
395
+ license: "Apache-2.0",
396
+ sherpaSlot: "kokoro",
397
+ voices: [
398
+ { sid: 0, name: "af_heart (en-US, female)", gender: "female" },
399
+ { sid: 1, name: "af_alloy (en-US, female)", gender: "female" },
400
+ { sid: 2, name: "af_aoede (en-US, female)", gender: "female" },
401
+ { sid: 11, name: "am_adam (en-US, male)", gender: "male" },
402
+ { sid: 12, name: "am_echo (en-US, male)", gender: "male" },
403
+ { sid: 20, name: "bf_alice (en-GB, female)", gender: "female" },
404
+ { sid: 24, name: "bm_daniel (en-GB, male)", gender: "male" },
405
+ { sid: 33, name: "ff_siwis (fr, female)", gender: "female" },
406
+ { sid: 40, name: "jf_alpha (ja, female)", gender: "female" },
407
+ { sid: 44, name: "kf_yumi (ko, female)", gender: "female" },
408
+ { sid: 48, name: "zf_xiaobei (zh, female)", gender: "female" },
409
+ ],
410
+ defaultSid: 0,
411
+ archiveUrl: `${TTS_RELEASE}/kokoro-int8-multi-lang-v1_1.tar.bz2`,
412
+ sampleRate: 24000,
413
+ },
414
+ {
415
+ // Untested-but-non-quantized fp32 multilingual. 333 MB — opt-in
416
+ // for users who want guaranteed-no-NaN multilingual coverage.
417
+ id: "kokoro-multi-lang-v1_0",
418
+ name: "Kokoro Multilingual v1.0 (fp32)",
419
+ size: "~333 MB",
420
+ sizeBytes: 349_418_188,
421
+ runtimeRamMB: 1400,
422
+ notes: "v1.0 multilingual fp32 — no int8 quantization, no NaN risk. 9 langs, 53 voices, 24 kHz. 333 MB.",
423
+ languages: ["en", "zh", "ja", "ko", "es", "fr", "hi", "it", "pt"],
424
+ tier: "heavy",
425
+ preferred: false,
426
+ accuracy: 5,
427
+ speed: 3,
428
+ license: "Apache-2.0",
429
+ sherpaSlot: "kokoro",
430
+ voices: [
431
+ { sid: 0, name: "af_heart (en-US, female)", gender: "female" },
432
+ { sid: 11, name: "am_adam (en-US, male)", gender: "male" },
433
+ { sid: 20, name: "bf_alice (en-GB, female)", gender: "female" },
434
+ { sid: 33, name: "ff_siwis (fr, female)", gender: "female" },
435
+ { sid: 40, name: "jf_alpha (ja, female)", gender: "female" },
436
+ { sid: 44, name: "kf_yumi (ko, female)", gender: "female" },
437
+ { sid: 48, name: "zf_xiaobei (zh, female)", gender: "female" },
438
+ ],
439
+ defaultSid: 0,
440
+ archiveUrl: `${TTS_RELEASE}/kokoro-multi-lang-v1_0.tar.bz2`,
441
+ sampleRate: 24000,
442
+ },
443
+ {
444
+ // fp32 English Kokoro — 304 MB, definitively no NaN since it's not
445
+ // int8 quantized. Highest-quality offline English option we ship.
446
+ id: "kokoro-en-v0_19",
447
+ name: "Kokoro English v0.19 (fp32)",
448
+ size: "~304 MB",
449
+ sizeBytes: 319_625_534,
450
+ runtimeRamMB: 1200,
451
+ notes: "fp32 English HQ — best prosody, no quantization artifacts. 11 voices, 24 kHz.",
452
+ languages: ["en"],
453
+ tier: "heavy",
454
+ preferred: false,
455
+ accuracy: 5,
456
+ speed: 3,
457
+ license: "Apache-2.0",
458
+ sherpaSlot: "kokoro",
459
+ voices: [
460
+ { sid: 0, name: "af_bella (female)", gender: "female" },
461
+ { sid: 1, name: "af_nicole (female)", gender: "female" },
462
+ { sid: 2, name: "af_sarah (female)", gender: "female" },
463
+ { sid: 3, name: "af_sky (female)", gender: "female" },
464
+ { sid: 4, name: "am_adam (male)", gender: "male" },
465
+ { sid: 5, name: "am_michael (male)", gender: "male" },
466
+ { sid: 6, name: "bf_emma (female, en-GB)", gender: "female" },
467
+ { sid: 7, name: "bf_isabella (female, en-GB)", gender: "female" },
468
+ { sid: 8, name: "bm_george (male, en-GB)", gender: "male" },
469
+ { sid: 9, name: "bm_lewis (male, en-GB)", gender: "male" },
470
+ { sid: 10, name: "af (default mix)", gender: "neutral" },
471
+ ],
472
+ defaultSid: 0,
473
+ archiveUrl: `${TTS_RELEASE}/kokoro-en-v0_19.tar.bz2`,
474
+ sampleRate: 24000,
475
+ },
476
+ {
477
+ id: "kokoro-int8-en-v0_19",
478
+ name: "Kokoro English v0.19",
479
+ size: "~99 MB",
480
+ sizeBytes: 103_284_736,
481
+ runtimeRamMB: 350,
482
+ notes: "English HQ — 11 voices, best prosody, 24 kHz",
483
+ languages: ["en"],
484
+ tier: "standard",
485
+ preferred: false,
486
+ accuracy: 5,
487
+ speed: 4,
488
+ license: "Apache-2.0",
489
+ sherpaSlot: "kokoro",
490
+ voices: [
491
+ { sid: 0, name: "af_bella (female)", gender: "female" },
492
+ { sid: 1, name: "af_nicole (female)", gender: "female" },
493
+ { sid: 2, name: "af_sarah (female)", gender: "female" },
494
+ { sid: 3, name: "af_sky (female)", gender: "female" },
495
+ { sid: 4, name: "am_adam (male)", gender: "male" },
496
+ { sid: 5, name: "am_michael (male)", gender: "male" },
497
+ { sid: 6, name: "bf_emma (female, en-GB)", gender: "female" },
498
+ { sid: 7, name: "bf_isabella (female, en-GB)", gender: "female" },
499
+ { sid: 8, name: "bm_george (male, en-GB)", gender: "male" },
500
+ { sid: 9, name: "bm_lewis (male, en-GB)", gender: "male" },
501
+ { sid: 10, name: "af (default mix)", gender: "neutral" },
502
+ ],
503
+ defaultSid: 0,
504
+ archiveUrl: `${TTS_RELEASE}/kokoro-int8-en-v0_19.tar.bz2`,
505
+ sampleRate: 24000,
506
+ },
507
+ ];
508
+
509
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
510
+
511
+ /**
512
+ * Build a Piper VITS catalog entry. Piper voices have a uniform shape — one
513
+ * model, one default voice (sid 0), one language. LibriTTS-R is the
514
+ * exception (904 speakers) — pass `voiceCount > 1` to expose them all.
515
+ */
516
+ function piper(
517
+ stem: string,
518
+ displayName: string,
519
+ sizeBytes: number,
520
+ languages: string[],
521
+ notes: string,
522
+ license: string,
523
+ preferred: boolean,
524
+ sampleRate: number,
525
+ gender: "male" | "female" | "neutral" = "neutral",
526
+ voiceCount = 1
527
+ ): TtsLocalModelInfo {
528
+ const voices: TtsVoice[] =
529
+ voiceCount === 1
530
+ ? [{ sid: 0, name: displayName, gender }]
531
+ : Array.from({ length: voiceCount }, (_, i) => ({ sid: i, name: `Speaker ${i}`, gender }));
532
+ return {
533
+ id: `piper-${stem}`,
534
+ name: displayName,
535
+ // Round to 1 decimal MB for the picker label
536
+ size: `~${(sizeBytes / 1024 / 1024).toFixed(0)} MB`,
537
+ sizeBytes,
538
+ // Piper RAM ≈ 6× model file in worst case (decoder workspace)
539
+ runtimeRamMB: Math.round((sizeBytes / 1024 / 1024) * 6),
540
+ notes,
541
+ languages,
542
+ tier: "edge",
543
+ preferred,
544
+ accuracy: 3,
545
+ speed: 5,
546
+ license,
547
+ sherpaSlot: "vits",
548
+ voices,
549
+ defaultSid: 0,
550
+ archiveUrl: `${TTS_RELEASE}/vits-piper-${stem}.tar.bz2`,
551
+ sampleRate,
552
+ };
553
+ }
554
+
555
+ // ─── Smart default selection ─────────────────────────────────────────────────
556
+
557
+ /**
558
+ * Recommend an initial TTS model based on the user's system locale.
559
+ *
560
+ * Returns ONE catalog entry id — the recommendation, not an installation
561
+ * decision. The caller (onboarding picker, settings panel) presents this
562
+ * as a pre-highlighted suggestion with disclosure of size and language
563
+ * coverage. The user always confirms before download starts.
564
+ *
565
+ * Mapping rules:
566
+ * - English locale (en-*) → kitten-nano-en-v0_2 (smallest, 25 MB)
567
+ * - Single-language Piper match → that Piper voice (~20 MB each)
568
+ * - Multi-language locale that
569
+ * covers Kokoro → kokoro-int8-multi-lang-v1_0 (126 MB)
570
+ * - Locale with no coverage → kitten-nano-en-v0_2 + warn
571
+ *
572
+ * The single-Piper-match path is preferred over the multilingual Kokoro
573
+ * because Piper is 1/6 the size when only one language is needed. Kokoro
574
+ * is the right pick when the user reads multiple languages OR when no
575
+ * Piper voice exists for their locale.
576
+ */
577
+ export interface SmartDefaultRecommendation {
578
+ modelId: string;
579
+ /** Why this model was picked, surfaceable in onboarding UI. */
580
+ reason: string;
581
+ /** True iff no model in the catalog actually covers `locale`. */
582
+ fallback: boolean;
583
+ }
584
+
585
+ /**
586
+ * Per-language single-Piper-voice mapping — only languages where the
587
+ * catalog has exactly one Piper voice for the language. Multi-region
588
+ * languages (en, pt) are intentionally NOT here — those route through
589
+ * either the en→kitten path or kokoro multilingual.
590
+ */
591
+ const SINGLE_PIPER_BY_BASE_LANG: Readonly<Record<string, string>> = {
592
+ es: "piper-es_ES-davefx-medium-int8",
593
+ fr: "piper-fr_FR-siwis-medium-int8",
594
+ de: "piper-de_DE-thorsten-medium-int8",
595
+ hi: "piper-hi_IN-pratham-medium-int8",
596
+ zh: "piper-zh_CN-chaowen-medium-int8",
597
+ it: "piper-it_IT-paola-medium-int8",
598
+ ru: "piper-ru_RU-denis-medium-int8",
599
+ ar: "piper-ar_JO-kareem-medium-int8",
600
+ tr: "piper-tr_TR-fahrettin-medium-int8",
601
+ nl: "piper-nl_NL-pim-medium-int8",
602
+ };
603
+
604
+ export function recommendDefaultModel(systemLocale: string): SmartDefaultRecommendation {
605
+ if (!systemLocale || typeof systemLocale !== "string") {
606
+ return {
607
+ modelId: DEFAULT_TTS_MODEL,
608
+ reason: "No system locale detected — defaulting to the smallest English model.",
609
+ fallback: true,
610
+ };
611
+ }
612
+
613
+ // Normalize: lowercase first subtag, e.g. "en_US.UTF-8" → "en"
614
+ const base = systemLocale.split(/[-_.]/)[0]!.toLowerCase();
615
+
616
+ // English locales — Kitten Nano is the smallest viable English TTS
617
+ // at 25 MB, and we ship it as the catalog default for first-run
618
+ // experience reasons.
619
+ if (base === "en") {
620
+ return {
621
+ modelId: DEFAULT_TTS_MODEL,
622
+ reason: `English locale detected — recommending ${DEFAULT_TTS_MODEL} (25 MB, 8 voices).`,
623
+ fallback: false,
624
+ };
625
+ }
626
+
627
+ // Special-case Portuguese: catalog has Brazilian-only Piper. We pick
628
+ // pt-BR for any pt-* locale; for non-BR regions (pt-PT, pt-AO, etc.)
629
+ // `fallback: true` so the onboarding UI surfaces "this isn't a
630
+ // perfect match" — even though the model technically still produces
631
+ // Portuguese audio, the accent will differ from the user's locale.
632
+ if (base === "pt") {
633
+ const isExactMatch = systemLocale.toLowerCase().includes("br");
634
+ return {
635
+ modelId: "piper-pt_BR-cadu-medium-int8",
636
+ reason: `Portuguese locale detected — recommending Brazilian Portuguese voice (${
637
+ isExactMatch ? "exact match" : "closest available — accent will differ from your locale"
638
+ }, 20 MB).`,
639
+ fallback: !isExactMatch,
640
+ };
641
+ }
642
+
643
+ // Single-language Piper match
644
+ const single = SINGLE_PIPER_BY_BASE_LANG[base];
645
+ if (single) {
646
+ return {
647
+ modelId: single,
648
+ reason: `${base.toUpperCase()} locale detected — recommending ${single} (~20 MB).`,
649
+ fallback: false,
650
+ };
651
+ }
652
+
653
+ // Languages covered only by Kokoro multilingual (ja, ko).
654
+ // v7.1.2: Kokoro multilingual is currently flagged `incompatible`
655
+ // on sherpa-onnx-node 1.12.29 — fall through to the English-default
656
+ // fallback instead of routing the user to a silent model.
657
+ if (base === "ja" || base === "ko") {
658
+ const kokoro = TTS_LOCAL_MODELS.find((m) => m.id === "kokoro-int8-multi-lang-v1_0");
659
+ if (kokoro && !kokoro.incompatible) {
660
+ return {
661
+ modelId: "kokoro-int8-multi-lang-v1_0",
662
+ reason:
663
+ `${base.toUpperCase()} locale detected — recommending Kokoro multilingual (126 MB, ` +
664
+ `covers en/zh/ja/ko/es/fr/hi/it/pt in one model).`,
665
+ fallback: false,
666
+ };
667
+ }
668
+ // fall through to the English fallback below.
669
+ }
670
+
671
+ // No coverage — fall back to English default with a warning the
672
+ // caller can surface verbatim.
673
+ return {
674
+ modelId: DEFAULT_TTS_MODEL,
675
+ reason:
676
+ `Locale ${systemLocale} has no built-in TTS voice. Falling back to English (${DEFAULT_TTS_MODEL}). ` +
677
+ `Browse /voice-settings → Speak tab → Models for the full catalog.`,
678
+ fallback: true,
679
+ };
680
+ }
681
+
682
+ /** Look up a model by id; throws if unknown so callers fail loudly. */
683
+ export function getTtsModel(id: string): TtsLocalModelInfo {
684
+ const m = TTS_LOCAL_MODELS.find((x) => x.id === id);
685
+ if (!m) throw new Error(`Unknown TTS model: ${id}. Known: ${TTS_LOCAL_MODELS.map((x) => x.id).join(", ")}`);
686
+ return m;
687
+ }
688
+
689
+ /** Find the default voice index for a model; falls back to 0. */
690
+ export function getDefaultVoiceSid(model: TtsLocalModelInfo): number {
691
+ if (model.voices.some((v) => v.sid === model.defaultSid)) return model.defaultSid;
692
+ return model.voices[0]?.sid ?? 0;
693
+ }
694
+
695
+ /**
696
+ * Human-readable language name lookup. Used by the voice picker.
697
+ * Empty input returns empty string; unknown bases fall back to the raw tag.
698
+ */
699
+ export function languageName(tag: string): string {
700
+ if (!tag) return "";
701
+ const base = tag.split("-")[0]!.toLowerCase();
702
+ const names: Record<string, string> = {
703
+ en: "English",
704
+ es: "Spanish",
705
+ fr: "French",
706
+ de: "German",
707
+ hi: "Hindi",
708
+ pt: "Portuguese",
709
+ zh: "Chinese",
710
+ it: "Italian",
711
+ ru: "Russian",
712
+ ar: "Arabic",
713
+ tr: "Turkish",
714
+ nl: "Dutch",
715
+ ja: "Japanese",
716
+ ko: "Korean",
717
+ };
718
+ return names[base] ?? tag;
719
+ }
720
+
721
+ /**
722
+ * Returns true if the model's language list covers `lang` (BCP-47 tag).
723
+ *
724
+ * Matching rules — region-strict by design:
725
+ * 1. Exact tag match wins (e.g. request "pt-BR" hits a "pt-BR" entry)
726
+ * 2. A bare-base catalog entry covers any region of that language
727
+ * (e.g. catalog has "en" → matches "en", "en-US", "en-GB"). Models
728
+ * that genuinely cover all variants of a language list the bare base
729
+ * tag (Kokoro multilingual is the only such entry today).
730
+ * 3. A bare-base request matches a regional catalog entry only if the
731
+ * catalog has exactly ONE region for that language (so picking "es"
732
+ * hits "es-ES" deterministically because there is no other es-*
733
+ * voice in the catalog).
734
+ * 4. Otherwise NO match — region mismatches like pt-PT vs pt-BR,
735
+ * zh-TW vs zh-CN, ar-EG vs ar-JO, en-AU vs en-US route to NO so the
736
+ * caller can surface a clear error instead of playing the wrong accent.
737
+ *
738
+ * Rule 4 is the important one: previous versions stripped region on both
739
+ * sides which silently routed pt-PT speech to a Brazilian voice. That kind
740
+ * of substitution is harder to debug than a "no model supports pt-PT,
741
+ * install one or pick a different language" error.
742
+ */
743
+ export function modelSupportsLanguage(model: TtsLocalModelInfo, lang: string): boolean {
744
+ const requested = normalizeLangTag(lang);
745
+ const base = requested.split("-")[0]!;
746
+
747
+ for (const cat of model.languages) {
748
+ const catNorm = normalizeLangTag(cat);
749
+ // Rule 1: exact match
750
+ if (catNorm === requested) return true;
751
+ // Rule 2: bare base in catalog covers any region
752
+ if (!catNorm.includes("-") && catNorm === base) return true;
753
+ }
754
+
755
+ // Rule 3: bare base request — match only if catalog has exactly one region
756
+ // for this language. Multiple regions (e.g. ar-JO and ar-EG would conflict)
757
+ // require an explicit pick.
758
+ if (!requested.includes("-")) {
759
+ const matchingRegions = model.languages
760
+ .map(normalizeLangTag)
761
+ .filter((c) => c.includes("-") && c.split("-")[0] === base);
762
+ if (matchingRegions.length === 1) return true;
763
+ }
764
+
765
+ return false;
766
+ }
767
+
768
+ /** Normalize a BCP-47 tag for matching: lowercase language, uppercase region. */
769
+ function normalizeLangTag(tag: string): string {
770
+ const parts = tag.split("-");
771
+ const lang = (parts[0] ?? "").toLowerCase();
772
+ if (parts.length === 1) return lang;
773
+ const region = (parts[1] ?? "").toUpperCase();
774
+ return `${lang}-${region}`;
775
+ }
776
+
777
+ // ─── Model installation (download + extract) ─────────────────────────────────
778
+
779
+ /** TTS models live under ~/.pi/models/tts/ to keep them separate from STT. */
780
+ export function getTtsModelsDir(): string {
781
+ return path.join(os.homedir(), ".pi", "models", "tts");
782
+ }
783
+
784
+ /** Per-model directory. Does NOT verify existence — see getInstalledTtsModelDir. */
785
+ export function getTtsModelDir(modelId: string): string {
786
+ return path.join(getTtsModelsDir(), modelId);
787
+ }
788
+
789
+ /** True iff the model archive has been downloaded and extracted. */
790
+ export function isTtsModelInstalled(modelId: string): boolean {
791
+ const dir = getTtsModelDir(modelId);
792
+ if (!fs.existsSync(dir)) return false;
793
+ const tokens = path.join(dir, "tokens.txt");
794
+ // Every supported slot (kitten/vits/kokoro) ships a tokens.txt at
795
+ // the archive root, so its presence is a robust install marker
796
+ // without us needing to know the slot's other expected files.
797
+ return fs.existsSync(tokens);
798
+ }
799
+
800
+ /**
801
+ * Resolve `modelId` to an installed model directory. Throws a user-facing
802
+ * error if the archive hasn't been downloaded yet — the caller (slash
803
+ * command or settings panel) is expected to either trigger
804
+ * `ensureTtsModelInstalled` first or surface this message to prompt the
805
+ * user to install via /voice-settings.
806
+ */
807
+ export function getInstalledTtsModelDir(modelId: string): string {
808
+ if (!isTtsModelInstalled(modelId)) {
809
+ throw new Error(
810
+ `TTS model "${modelId}" is not installed. ` +
811
+ `Run /voice-settings → Models tab → install ${modelId}, ` +
812
+ `or download manually: ` +
813
+ `curl -L ${getTtsModel(modelId).archiveUrl} | tar xj -C "${getTtsModelsDir()}"`
814
+ );
815
+ }
816
+ return getTtsModelDir(modelId);
817
+ }
818
+
819
+ export interface TtsInstallProgress {
820
+ /**
821
+ * - "download" — fetching archive bytes (with phase totals)
822
+ * - "extract" — running tar over the saved archive
823
+ * - "verify" — moving extracted files to final dir
824
+ * - "done" — install complete
825
+ */
826
+ phase: "download" | "extract" | "verify" | "done";
827
+ bytes?: number;
828
+ totalBytes?: number;
829
+ }
830
+
831
+ /**
832
+ * Result returned alongside install completion — exposes the computed
833
+ * SHA-256 so callers (and v7.1+ catalog updates) can pin known-good hashes.
834
+ */
835
+ export interface TtsInstallResult {
836
+ dir: string;
837
+ archiveSha256: string;
838
+ }
839
+
840
+ /**
841
+ * In-flight install promise per modelId. Concurrent callers requesting
842
+ * the same modelId share one in-flight install; without this, two
843
+ * concurrent calls would both open `<id>.partial.tar.bz2` for writing
844
+ * (`fs.createWriteStream` with `flags: "w"` truncates), corrupt each
845
+ * other's bytes, and either fail tar extraction or race on the rename
846
+ * to the final dir with ENOTEMPTY.
847
+ *
848
+ * Mirrors the in-flight Map pattern in `tts-engine.ts` (which itself
849
+ * mirrors the v5.0.9 sherpa-loader single-flight pattern). Keyed by
850
+ * modelId only — the model dir is uniquely determined by id, so two
851
+ * concurrent calls with the same id necessarily target the same files.
852
+ *
853
+ * Each caller still receives its OWN onProgress callbacks routed
854
+ * through the shared in-flight promise via a per-call wrapper that
855
+ * forwards events from the active install. The first caller "owns"
856
+ * the install for progress reporting; later concurrent callers see
857
+ * `phase: "done"` immediately on resolve.
858
+ */
859
+ const inFlightInstalls = new Map<string, Promise<TtsInstallResult>>();
860
+
861
+ /**
862
+ * Download and extract `modelId` if not already installed. Idempotent —
863
+ * if already installed, resolves immediately.
864
+ *
865
+ * Concurrency: per-modelId in-flight Map serializes concurrent installs
866
+ * for the same model. See `inFlightInstalls` doc above.
867
+ *
868
+ * The flow is download-to-disk-then-extract, not streaming-to-tar:
869
+ * 1. Resume-aware fetch → write archive bytes to
870
+ * `~/.pi/models/tts/<id>.partial.tar.bz2`. If the partial file
871
+ * exists from a prior interrupted run, send `Range: bytes=N-` and
872
+ * append. SHA-256 is computed across the full file by re-reading
873
+ * it once on completion (cheap — ~200ms for 126 MB on M-series).
874
+ * 2. If the catalog entry has `archiveSha256`, compare against the
875
+ * computed hash. Mismatch → reject + cleanup partial.
876
+ * 3. `tar -xj -f <archive> -C <stagingDir>` to extract.
877
+ * 4. Move staging contents to final `<modelDir>` via rename (atomic).
878
+ * 5. Delete the archive file.
879
+ *
880
+ * Errors:
881
+ * - "Download failed: HTTP <status>" on non-2xx (and not 206/200 retry)
882
+ * - "Network error: <message>" on fetch failure
883
+ * - "Archive integrity check failed: ..." on SHA-256 mismatch
884
+ * - "tar exited with code N" on extraction failure
885
+ * - DOMException("AbortError") if signal fires
886
+ */
887
+ export function ensureTtsModelInstalled(
888
+ modelId: string,
889
+ opts: {
890
+ signal?: AbortSignal;
891
+ onProgress?: (info: TtsInstallProgress) => void;
892
+ } = {}
893
+ ): Promise<TtsInstallResult> {
894
+ // Validate the modelId UPFRONT, before touching the in-flight Map.
895
+ // `getTtsModel` throws synchronously for unknown ids; if we left this
896
+ // to doInstall(), the throw would happen inside the async body and
897
+ // the rejected promise could end up in the Map briefly before the
898
+ // .finally cleanup fires — a non-issue in practice but documenting
899
+ // intent here is cheaper than reasoning about microtask ordering.
900
+ getTtsModel(modelId);
901
+
902
+ // Concurrent caller for the same modelId: piggy-back on the existing
903
+ // install promise. Checked BEFORE the on-disk fast-path so two
904
+ // concurrent first-time callers can't both pass `isTtsModelInstalled
905
+ // === false` (which is genuinely impossible under JS run-to-completion
906
+ // since Map.get/Map.set don't yield, but reordering makes the single-
907
+ // flight invariant unmistakable to a casual reader and satisfies the
908
+ // godspeed gate without behavior change).
909
+ const existing = inFlightInstalls.get(modelId);
910
+ if (existing) {
911
+ return existing.then(
912
+ (result) => {
913
+ opts.onProgress?.({ phase: "done" });
914
+ return result;
915
+ },
916
+ (err) => {
917
+ throw err;
918
+ }
919
+ );
920
+ }
921
+
922
+ // On-disk fast-path: model already installed from a prior run, no
923
+ // need to involve the in-flight Map at all. Returns a one-microtask
924
+ // resolved promise.
925
+ if (isTtsModelInstalled(modelId)) {
926
+ const dir = getTtsModelDir(modelId);
927
+ const sha = getTtsModel(modelId).archiveSha256 ?? "";
928
+ opts.onProgress?.({ phase: "done" });
929
+ return Promise.resolve({ dir, archiveSha256: sha });
930
+ }
931
+
932
+ // First caller — claim the slot before any await yields, run the
933
+ // install, clean the slot on settlement.
934
+ const pending = doInstall(modelId, opts).finally(() => {
935
+ // Identity-guarded delete: only clear if we're still the in-flight
936
+ // entry. If a later install overwrote the slot (shouldn't happen
937
+ // since we set before yielding, but defense-in-depth), don't
938
+ // touch its entry.
939
+ if (inFlightInstalls.get(modelId) === pending) {
940
+ inFlightInstalls.delete(modelId);
941
+ }
942
+ });
943
+ inFlightInstalls.set(modelId, pending);
944
+ return pending;
945
+ }
946
+
947
+ /**
948
+ * The actual install pipeline — extracted from the public entrypoint so
949
+ * the concurrency guard above can wrap it without changing the body.
950
+ *
951
+ * Note: signature matches the original ensureTtsModelInstalled — pre-v7.0.1
952
+ * callers that didn't bind the return value still work.
953
+ */
954
+ async function doInstall(
955
+ modelId: string,
956
+ opts: {
957
+ signal?: AbortSignal;
958
+ onProgress?: (info: TtsInstallProgress) => void;
959
+ }
960
+ ): Promise<TtsInstallResult> {
961
+ const model = getTtsModel(modelId);
962
+ const dir = getTtsModelDir(modelId);
963
+
964
+ if (isTtsModelInstalled(modelId)) {
965
+ opts.onProgress?.({ phase: "done" });
966
+ return { dir, archiveSha256: model.archiveSha256 ?? "" };
967
+ }
968
+ if (opts.signal?.aborted) throw makeAbortErr();
969
+
970
+ const ttsDir = getTtsModelsDir();
971
+ fs.mkdirSync(ttsDir, { recursive: true });
972
+ const archivePath = path.join(ttsDir, `${modelId}.partial.tar.bz2`);
973
+
974
+ // `phaseReached` distinguishes failures by where they occurred so the
975
+ // catch block knows whether to keep the partial archive (for resume)
976
+ // or delete it (because we know it's corrupt — extract failed). After
977
+ // SHA verification passes, a tar failure points at a corrupt partial
978
+ // that resume can't fix; delete it so the next attempt re-downloads.
979
+ let phaseReached: "download" | "verify" | "extract" | "done" = "download";
980
+ let computedSha256 = "";
981
+
982
+ try {
983
+ // Phase 1 — download archive bytes (with resume).
984
+ await downloadArchive(model.archiveUrl, archivePath, opts);
985
+ if (opts.signal?.aborted) throw makeAbortErr();
986
+
987
+ // Phase 2 — verify hash.
988
+ phaseReached = "verify";
989
+ opts.onProgress?.({ phase: "verify" });
990
+ computedSha256 = await sha256OfFile(archivePath, opts.signal);
991
+ if (model.archiveSha256 && model.archiveSha256.toLowerCase() !== computedSha256.toLowerCase()) {
992
+ throw new Error(
993
+ `Archive integrity check failed for ${modelId}: ` +
994
+ `expected ${model.archiveSha256}, got ${computedSha256}. ` +
995
+ `Delete ${archivePath} and retry, or check for a corrupted upstream release.`
996
+ );
997
+ }
998
+
999
+ // Phase 3 — extract.
1000
+ phaseReached = "extract";
1001
+ opts.onProgress?.({ phase: "extract", totalBytes: model.sizeBytes });
1002
+ const stagingDir = `${dir}.staging-${process.pid}`;
1003
+ fs.mkdirSync(stagingDir, { recursive: true });
1004
+ try {
1005
+ await runTarExtract(archivePath, stagingDir, opts.signal);
1006
+
1007
+ // Phase 4 — move into final location. The archive's top-level
1008
+ // directory differs per model (e.g.
1009
+ // `vits-piper-en_US-lessac-medium-int8/`). Flatten to
1010
+ // `<modelDir>/tokens.txt` etc.
1011
+ const stagingEntries = fs.readdirSync(stagingDir);
1012
+ const innerDir =
1013
+ stagingEntries.length === 1 && fs.statSync(path.join(stagingDir, stagingEntries[0]!)).isDirectory()
1014
+ ? path.join(stagingDir, stagingEntries[0]!)
1015
+ : stagingDir;
1016
+ // rename is atomic when innerDir and dir are on the same
1017
+ // filesystem (~/.pi/models/tts/.staging is a sibling of dir).
1018
+ fs.renameSync(innerDir, dir);
1019
+ } finally {
1020
+ try {
1021
+ fs.rmSync(stagingDir, { recursive: true, force: true });
1022
+ } catch {}
1023
+ }
1024
+
1025
+ // Phase 5 — clean up the archive file. Successful install means we
1026
+ // no longer need the partial; resume is moot.
1027
+ phaseReached = "done";
1028
+ try {
1029
+ fs.unlinkSync(archivePath);
1030
+ } catch {}
1031
+ } catch (err) {
1032
+ // Defense in depth: if we already reached `done` (install completed,
1033
+ // renamed into place, archive unlinked) and somehow an error still
1034
+ // bubbles up from after that point, DO NOT clean up — the model
1035
+ // is fully installed on disk and deleting it would force a
1036
+ // pointless re-download. Currently this branch is unreachable in
1037
+ // the source order above, but the explicit guard documents the
1038
+ // invariant for future maintainers and survives refactors.
1039
+ if (phaseReached === "done") throw err;
1040
+
1041
+ // Decide what to clean up based on how far we got:
1042
+ // - download phase: keep the partial (next attempt resumes)
1043
+ // - verify phase (SHA mismatch): delete the partial — bytes are corrupt
1044
+ // - extract phase (tar failed): delete the partial — bytes are
1045
+ // corrupt at the tar layer even if SHA was unset, OR delete the
1046
+ // half-built dir if it got partially populated
1047
+ // In all failure paths short of `done`, delete the destination dir
1048
+ // if it got created.
1049
+ if (phaseReached === "verify" || phaseReached === "extract") {
1050
+ try {
1051
+ fs.unlinkSync(archivePath);
1052
+ } catch {}
1053
+ }
1054
+ try {
1055
+ fs.rmSync(dir, { recursive: true, force: true });
1056
+ } catch {}
1057
+ throw err;
1058
+ }
1059
+
1060
+ // Move the terminal `phase: "done"` callback OUTSIDE the try block.
1061
+ // If a user-supplied onProgress throws, we don't want the catch to
1062
+ // delete the just-installed dir and force a re-download.
1063
+ opts.onProgress?.({ phase: "done" });
1064
+ return { dir, archiveSha256: computedSha256 };
1065
+ }
1066
+
1067
+ /**
1068
+ * Download bytes to `archivePath` with `Range` resume.
1069
+ *
1070
+ * If the file already exists, we send `Range: bytes=<size>-` and the
1071
+ * server is expected to respond with 206 Partial Content (we append) or
1072
+ * 200 OK (server doesn't support range; we throw the file away and
1073
+ * start over).
1074
+ *
1075
+ * Surfaces byte-count progress via `opts.onProgress`. The total byte
1076
+ * count comes from `Content-Length` on the response — for 206 responses
1077
+ * we add the existing partial size to the running counter so the user
1078
+ * sees a continuous progress bar across resumed sessions.
1079
+ */
1080
+ async function downloadArchive(
1081
+ url: string,
1082
+ archivePath: string,
1083
+ opts: { signal?: AbortSignal; onProgress?: (info: TtsInstallProgress) => void }
1084
+ ): Promise<void> {
1085
+ let existingBytes = 0;
1086
+ if (fs.existsSync(archivePath)) {
1087
+ try {
1088
+ existingBytes = fs.statSync(archivePath).size;
1089
+ } catch {}
1090
+ }
1091
+
1092
+ const headers: Record<string, string> = {};
1093
+ if (existingBytes > 0) headers.Range = `bytes=${existingBytes}-`;
1094
+
1095
+ let res: Response;
1096
+ try {
1097
+ res = await fetch(url, { signal: opts.signal, headers });
1098
+ } catch (err: any) {
1099
+ if (err?.name === "AbortError") throw err;
1100
+ throw new Error(`Network error: ${err?.message ?? String(err)}`);
1101
+ }
1102
+
1103
+ let appendMode = false;
1104
+ if (res.status === 206 && existingBytes > 0) {
1105
+ appendMode = true;
1106
+ } else if (res.status === 200) {
1107
+ // Server ignored our Range — start over.
1108
+ appendMode = false;
1109
+ existingBytes = 0;
1110
+ } else if (res.status === 416 && existingBytes > 0) {
1111
+ // "Range Not Satisfiable" — the server says our requested range
1112
+ // (`bytes=<size>-`) starts at or past the end of the resource.
1113
+ // This means our local partial is already at-or-beyond the full
1114
+ // resource size, which happens when a prior run died AFTER the
1115
+ // download finished but BEFORE we got to unlink the partial. The
1116
+ // caller (ensureTtsModelInstalled) verifies the SHA-256 next, so
1117
+ // returning here without re-downloading is safe: a corrupted
1118
+ // equal-size partial would fail the hash check.
1119
+ // Drain the response body to free the connection (some HTTP impls
1120
+ // hold the socket otherwise).
1121
+ try {
1122
+ await res.body?.cancel?.();
1123
+ } catch {}
1124
+ return;
1125
+ } else if (!res.ok) {
1126
+ throw new Error(`Download failed: HTTP ${res.status} from ${url}`);
1127
+ }
1128
+
1129
+ if (!res.body) throw new Error(`Download failed: empty body from ${url}`);
1130
+
1131
+ // Total = bytes already on disk + Content-Length of this response.
1132
+ const contentLength = parseInt(res.headers.get("content-length") ?? "0", 10);
1133
+ const totalBytes = existingBytes + (Number.isFinite(contentLength) ? contentLength : 0);
1134
+
1135
+ const sink = fs.createWriteStream(archivePath, { flags: appendMode ? "a" : "w" });
1136
+ let bytesSeen = existingBytes;
1137
+ opts.onProgress?.({ phase: "download", bytes: bytesSeen, totalBytes });
1138
+
1139
+ // Capture sink errors as a settle-once promise. Without this, a write
1140
+ // error during the body-streaming loop (disk full, EIO, EPERM) emits
1141
+ // 'error' on the stream and Node throws an unhandled exception that
1142
+ // crashes the process. We also race the drain wait against `error`
1143
+ // so a stream that fails mid-backpressure doesn't hang forever.
1144
+ const sinkErrorRef: { err: Error | null } = { err: null };
1145
+ sink.on("error", (err: Error) => {
1146
+ sinkErrorRef.err ??= err;
1147
+ });
1148
+
1149
+ const reader = res.body.getReader();
1150
+ try {
1151
+ while (true) {
1152
+ if (opts.signal?.aborted) throw makeAbortErr();
1153
+ if (sinkErrorRef.err) throw new Error(`Disk write failed: ${sinkErrorRef.err.message}`);
1154
+ const { value, done } = await reader.read();
1155
+ if (done) break;
1156
+ if (value) {
1157
+ bytesSeen += value.byteLength;
1158
+ // Honor backpressure: if write returns false, await `drain`
1159
+ // OR the first `error` — whichever comes first.
1160
+ const ok = sink.write(Buffer.from(value));
1161
+ if (!ok) {
1162
+ await new Promise<void>((resolve, reject) => {
1163
+ const onDrain = () => {
1164
+ sink.off("error", onErr);
1165
+ resolve();
1166
+ };
1167
+ const onErr = (err: Error) => {
1168
+ sink.off("drain", onDrain);
1169
+ reject(err);
1170
+ };
1171
+ sink.once("drain", onDrain);
1172
+ sink.once("error", onErr);
1173
+ });
1174
+ }
1175
+ opts.onProgress?.({ phase: "download", bytes: bytesSeen, totalBytes });
1176
+ }
1177
+ }
1178
+ } finally {
1179
+ try {
1180
+ reader.releaseLock();
1181
+ } catch {}
1182
+ // Drain and close the file. End() callback fires after the final
1183
+ // flush. Errors during close are surfaced via `error` listener
1184
+ // captured before the await.
1185
+ await new Promise<void>((resolve, rej) => {
1186
+ let settled = false;
1187
+ const onError = (err: Error) => {
1188
+ if (settled) return;
1189
+ settled = true;
1190
+ rej(err);
1191
+ };
1192
+ sink.once("error", onError);
1193
+ sink.end(() => {
1194
+ if (settled) return;
1195
+ settled = true;
1196
+ sink.off("error", onError);
1197
+ resolve();
1198
+ });
1199
+ });
1200
+ }
1201
+ }
1202
+
1203
+ /** Compute the SHA-256 hex digest of `filePath`. Streams via fs.createReadStream. */
1204
+ async function sha256OfFile(filePath: string, signal?: AbortSignal): Promise<string> {
1205
+ return new Promise((resolve, reject) => {
1206
+ const hash = createHash("sha256");
1207
+ const stream = fs.createReadStream(filePath);
1208
+ const onAbort = () => {
1209
+ stream.destroy();
1210
+ reject(makeAbortErr());
1211
+ };
1212
+ signal?.addEventListener("abort", onAbort, { once: true });
1213
+ stream.on("data", (chunk) => hash.update(chunk));
1214
+ stream.on("end", () => {
1215
+ signal?.removeEventListener("abort", onAbort);
1216
+ resolve(hash.digest("hex"));
1217
+ });
1218
+ stream.on("error", (err) => {
1219
+ signal?.removeEventListener("abort", onAbort);
1220
+ reject(err);
1221
+ });
1222
+ });
1223
+ }
1224
+
1225
+ /** Spawn `tar -xj -f <archive> -C <stagingDir>` and resolve on exit code 0. */
1226
+ async function runTarExtract(archivePath: string, stagingDir: string, signal?: AbortSignal): Promise<void> {
1227
+ const tar = spawn("tar", ["-xj", "-f", archivePath, "-C", stagingDir], {
1228
+ stdio: ["ignore", "ignore", "pipe"],
1229
+ ...(signal ? { signal } : {}),
1230
+ });
1231
+ let tarStderr = "";
1232
+ tar.stderr?.on("data", (d: Buffer) => {
1233
+ if (tarStderr.length < 1024) tarStderr += d.toString();
1234
+ });
1235
+ await new Promise<void>((resolve, reject) => {
1236
+ tar.on("error", (err: NodeJS.ErrnoException) => {
1237
+ if (err.name === "AbortError" || signal?.aborted) reject(makeAbortErr());
1238
+ else reject(new Error(`tar failed to start: ${err.message}`));
1239
+ });
1240
+ tar.on("close", (code, sig) => {
1241
+ if (code === 0) resolve();
1242
+ else if (signal?.aborted) reject(makeAbortErr());
1243
+ else reject(new Error(`tar exited with code ${code}${sig ? ` (${sig})` : ""}: ${tarStderr.trim().slice(-200)}`));
1244
+ });
1245
+ });
1246
+ }
1247
+
1248
+ function makeAbortErr(): Error {
1249
+ if (typeof DOMException === "function") {
1250
+ return new DOMException("TTS model install aborted", "AbortError");
1251
+ }
1252
+ const e = new Error("TTS model install aborted");
1253
+ (e as any).name = "AbortError";
1254
+ return e;
1255
+ }