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,464 @@
1
+ /**
2
+ * sherpa-onnx in-process transcription engine.
3
+ *
4
+ * Provides zero-config local STT by loading ONNX models directly
5
+ * into the extension process via sherpa-onnx-node (N-API bindings).
6
+ *
7
+ * Supports: Whisper, Moonshine v1/v2, SenseVoice, GigaAM, Parakeet
8
+ *
9
+ * Recognizer instances are cached and reused (model loading is expensive).
10
+ * Destroyed on: model change, language change, extension deactivation.
11
+ *
12
+ * API verified against:
13
+ * https://github.com/k2-fsa/sherpa-onnx/tree/master/nodejs-addon-examples
14
+ * - acceptWaveform({sampleRate, samples}) — object parameter
15
+ * - Config requires featConfig: {sampleRate: 16000, featureDim: 80}
16
+ * - Moonshine v2: {encoder, mergedDecoder} (2 files)
17
+ * - Moonshine v1: {preprocessor, encoder, uncachedDecoder, cachedDecoder} (4 files)
18
+ * - SenseVoice: useInverseTextNormalization is 1/0, not true/false
19
+ */
20
+
21
+ import * as path from "node:path";
22
+ import * as fs from "node:fs";
23
+ import * as os from "node:os";
24
+ import type { LocalModelInfo } from "./local";
25
+ import { loadSherpa, getSherpaModule, getSherpaError, isSherpaAvailable } from "./sherpa-loader";
26
+
27
+ // ─── Types ───────────────────────────────────────────────────────────────────
28
+
29
+ /** sherpa-onnx recognizer — opaque handle from the native module */
30
+ type SherpaRecognizer = any;
31
+
32
+ /** Cached recognizer for the currently loaded model + language */
33
+ let cachedRecognizer: { modelId: string; language: string; recognizer: SherpaRecognizer } | null = null;
34
+
35
+ // ─── Initialization ──────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Initialize the sherpa-onnx module. Kept as a thin alias around `loadSherpa()`
39
+ * so existing call sites (extension lifecycle, settings panel, /voice test)
40
+ * continue to compile and behave identically. New code should call
41
+ * `loadSherpa()` directly.
42
+ */
43
+ export async function initSherpa(): Promise<boolean> {
44
+ return loadSherpa();
45
+ }
46
+
47
+ // Re-export the loader's status helpers so STT call sites that imported them
48
+ // from `./sherpa-engine` keep working.
49
+ export { getSherpaError, isSherpaAvailable };
50
+
51
+ // ─── Recognizer management ──────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Get or create a recognizer for a model.
55
+ * Returns a cached instance if the model hasn't changed.
56
+ */
57
+ export function getOrCreateRecognizer(model: LocalModelInfo, modelDir: string, language: string): SherpaRecognizer {
58
+ // getSherpaModule() throws if loadSherpa() hasn't run yet — same contract as
59
+ // the previous "if (!sherpaModule) throw" check, but routed through the
60
+ // shared loader so STT and TTS share the single-flight init.
61
+ getSherpaModule();
62
+
63
+ // Strip regional suffix for local models (e.g. "pt-BR" → "pt")
64
+ const baseLang = language.split("-")[0] || language;
65
+
66
+ if (cachedRecognizer && cachedRecognizer.modelId === model.id && cachedRecognizer.language === baseLang) {
67
+ return cachedRecognizer.recognizer;
68
+ }
69
+
70
+ // Destroy previous recognizer
71
+ clearRecognizerCache();
72
+
73
+ const recognizer = createRecognizer(model, modelDir, baseLang);
74
+ cachedRecognizer = { modelId: model.id, language: baseLang, recognizer };
75
+ return recognizer;
76
+ }
77
+
78
+ /** Destroy cached recognizer and free memory. */
79
+ export function clearRecognizerCache(): void {
80
+ if (cachedRecognizer) {
81
+ // sherpa-onnx-node ships no `.free()` / `.release()` / `.dispose()`
82
+ // method on `OfflineRecognizer` (verified in
83
+ // node_modules/sherpa-onnx-node/non-streaming-asr.js). Native ONNX
84
+ // resources are released via N-API finalizers when the JS object is
85
+ // garbage-collected. Dropping our last reference (cachedRecognizer
86
+ // set to null) makes the recognizer eligible for GC, which the
87
+ // platform-level concurrent / generational GC eventually reclaims.
88
+ // If sherpa-onnx-node ever exposes an explicit dispose API, this is
89
+ // the call site to wire it up.
90
+ cachedRecognizer = null;
91
+ }
92
+ }
93
+
94
+ // ─── Transcription ───────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Transcribe PCM audio buffer using a sherpa recognizer.
98
+ *
99
+ * @param pcmData - Raw 16-bit signed LE PCM at 16kHz mono
100
+ * @param recognizer - sherpa OfflineRecognizer instance
101
+ * @returns Transcribed text
102
+ */
103
+ export async function transcribeBuffer(pcmData: Buffer, recognizer: SherpaRecognizer): Promise<string> {
104
+ // Throws if loadSherpa() hasn't run; callers always call initSherpa first.
105
+ getSherpaModule();
106
+
107
+ // Convert 16-bit PCM to Float32Array (sherpa expects float samples in [-1, 1])
108
+ const samples = pcmToFloat32(pcmData);
109
+
110
+ // Create a stream, accept waveform, decode asynchronously
111
+ // API: stream.acceptWaveform({sampleRate, samples}) — verified from official examples
112
+ // decodeAsync runs inference on ONNX Runtime's background thread pool (N-API AsyncWorker),
113
+ // keeping the event loop free for UI updates during the 5-15s decode
114
+ const stream = recognizer.createStream();
115
+ stream.acceptWaveform({ sampleRate: 16000, samples });
116
+ await recognizer.decodeAsync(stream);
117
+
118
+ const result = recognizer.getResult(stream);
119
+ return (result?.text || "").trim();
120
+ }
121
+
122
+ // ─── Internal: Recognizer creation per model type ────────────────────────────
123
+
124
+ function createRecognizer(model: LocalModelInfo, modelDir: string, language: string): SherpaRecognizer {
125
+ const modelType = model.sherpaModel?.type;
126
+
127
+ switch (modelType) {
128
+ case "whisper":
129
+ return createWhisperRecognizer(model, modelDir, language);
130
+ case "moonshine":
131
+ return createMoonshineRecognizer(model, modelDir);
132
+ case "sense_voice":
133
+ return createSenseVoiceRecognizer(model, modelDir, language);
134
+ case "nemo_ctc":
135
+ return createNemoCtcRecognizer(model, modelDir);
136
+ case "transducer":
137
+ return createTransducerRecognizer(model, modelDir);
138
+ case "paraformer":
139
+ return createParaformerRecognizer(model, modelDir);
140
+ case "qwen3_asr":
141
+ return createQwen3Recognizer(model, modelDir);
142
+ default:
143
+ throw new Error(`Unknown sherpa model type: ${modelType} for model ${model.id}`);
144
+ }
145
+ }
146
+
147
+ // Verified against: nodejs-addon-examples/test_asr_non_streaming_whisper.js
148
+ function createWhisperRecognizer(model: LocalModelInfo, modelDir: string, language: string): SherpaRecognizer {
149
+ const files = model.sherpaModel!.files;
150
+ const sherpa = getSherpaModule();
151
+ return new sherpa.OfflineRecognizer({
152
+ featConfig: {
153
+ sampleRate: 16000,
154
+ featureDim: 80,
155
+ },
156
+ modelConfig: {
157
+ whisper: {
158
+ encoder: path.join(modelDir, files.encoder!),
159
+ decoder: path.join(modelDir, files.decoder!),
160
+ language: language || "en",
161
+ task: "transcribe",
162
+ },
163
+ tokens: path.join(modelDir, files.tokens!),
164
+ numThreads: getNumThreads(),
165
+ provider: "cpu",
166
+ },
167
+ });
168
+ }
169
+
170
+ // Verified against: nodejs-addon-examples/test_asr_non_streaming_moonshine_v2.js
171
+ // Moonshine v2: {encoder, mergedDecoder} — 2 files
172
+ // Moonshine v1: {preprocessor, encoder, uncachedDecoder, cachedDecoder} — 4 files
173
+ function createMoonshineRecognizer(model: LocalModelInfo, modelDir: string): SherpaRecognizer {
174
+ const files = model.sherpaModel!.files;
175
+
176
+ // Detect v1 vs v2 by presence of mergedDecoder field
177
+ const moonshineConfig: Record<string, string> = {};
178
+
179
+ if (files.mergedDecoder) {
180
+ // Moonshine v2: encoder + mergedDecoder
181
+ moonshineConfig.encoder = path.join(modelDir, files.encoder!);
182
+ moonshineConfig.mergedDecoder = path.join(modelDir, files.mergedDecoder!);
183
+ } else {
184
+ // Moonshine v1: preprocessor + encoder + uncachedDecoder + cachedDecoder
185
+ moonshineConfig.preprocessor = path.join(modelDir, files.preprocessor!);
186
+ moonshineConfig.encoder = path.join(modelDir, files.encoder!);
187
+ moonshineConfig.uncachedDecoder = path.join(modelDir, files.uncachedDecoder!);
188
+ moonshineConfig.cachedDecoder = path.join(modelDir, files.cachedDecoder!);
189
+ }
190
+
191
+ const sherpa = getSherpaModule();
192
+ return new sherpa.OfflineRecognizer({
193
+ featConfig: {
194
+ sampleRate: 16000,
195
+ featureDim: 80,
196
+ },
197
+ modelConfig: {
198
+ moonshine: moonshineConfig,
199
+ tokens: path.join(modelDir, files.tokens!),
200
+ numThreads: getNumThreads(),
201
+ provider: "cpu",
202
+ },
203
+ });
204
+ }
205
+
206
+ // Verified against: nodejs-addon-examples/test_asr_non_streaming_sense_voice.js
207
+ function createSenseVoiceRecognizer(model: LocalModelInfo, modelDir: string, language: string): SherpaRecognizer {
208
+ const files = model.sherpaModel!.files;
209
+ const sherpa = getSherpaModule();
210
+ return new sherpa.OfflineRecognizer({
211
+ featConfig: {
212
+ sampleRate: 16000,
213
+ featureDim: 80,
214
+ },
215
+ modelConfig: {
216
+ senseVoice: {
217
+ model: path.join(modelDir, files.model!),
218
+ language: language || "auto",
219
+ useInverseTextNormalization: 1,
220
+ },
221
+ tokens: path.join(modelDir, files.tokens!),
222
+ numThreads: getNumThreads(),
223
+ provider: "cpu",
224
+ },
225
+ });
226
+ }
227
+
228
+ // Verified against: nodejs-addon-examples/test_asr_non_streaming_nemo_ctc.js
229
+ function createNemoCtcRecognizer(model: LocalModelInfo, modelDir: string): SherpaRecognizer {
230
+ const files = model.sherpaModel!.files;
231
+ const sherpa = getSherpaModule();
232
+ return new sherpa.OfflineRecognizer({
233
+ featConfig: {
234
+ sampleRate: 16000,
235
+ featureDim: 80,
236
+ },
237
+ modelConfig: {
238
+ nemoCtc: {
239
+ model: path.join(modelDir, files.model!),
240
+ },
241
+ tokens: path.join(modelDir, files.tokens!),
242
+ numThreads: getNumThreads(),
243
+ provider: "cpu",
244
+ },
245
+ });
246
+ }
247
+
248
+ // Verified against: nodejs-addon-examples/test_asr_non_streaming_transducer.js
249
+ //
250
+ // Tuning notes:
251
+ // - `provider: "cpu"` is intentionally NOT "coreml". For transformer / TDT
252
+ // graphs CoreML is currently a regression on Apple Silicon (sherpa-onnx
253
+ // issue #2910 — RTF 0.470 with CoreML vs 0.427 CPU on M2 Max). Revisit
254
+ // when sherpa-onnx upstream lands the partition-aware CoreML EP.
255
+ // - `numThreads` uses the higher transducer cap (6 vs Whisper's 4). Parakeet
256
+ // TDT v3's encoder-decoder-joiner scales to ~6 P-cores; 4 leaves modern
257
+ // M-series chips idle. Per RTF curves at
258
+ // https://k2-fsa.github.io/sherpa/onnx/pretrained_models/offline-transducer/nemo-transducer-models.html.
259
+ function createTransducerRecognizer(model: LocalModelInfo, modelDir: string): SherpaRecognizer {
260
+ const files = model.sherpaModel!.files;
261
+ const sherpa = getSherpaModule();
262
+ return new sherpa.OfflineRecognizer({
263
+ featConfig: {
264
+ sampleRate: 16000,
265
+ featureDim: 80,
266
+ },
267
+ modelConfig: {
268
+ transducer: {
269
+ encoder: path.join(modelDir, files.encoder!),
270
+ decoder: path.join(modelDir, files.decoder!),
271
+ joiner: path.join(modelDir, files.joiner!),
272
+ },
273
+ tokens: path.join(modelDir, files.tokens!),
274
+ numThreads: getNumThreads(TRANSDUCER_MAX_THREADS),
275
+ provider: "cpu",
276
+ // debug: 1 — uncomment to log per-stage (encoder/decoder/joiner) timings.
277
+ },
278
+ });
279
+ }
280
+
281
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
282
+
283
+ /** Convert 16-bit signed LE PCM buffer to Float32Array.
284
+ * Uses Int16Array typed view for ~5-10x speedup over per-sample readInt16LE.
285
+ * Safe on all sherpa-onnx platforms (x86/ARM LE). Respects Buffer.byteOffset for pooled buffers.
286
+ */
287
+ function pcmToFloat32(pcm: Buffer): Float32Array {
288
+ const numSamples = Math.floor(pcm.length / 2);
289
+ const float32 = new Float32Array(numSamples);
290
+
291
+ // Pooled Buffer instances can start at odd byte offsets, which makes a
292
+ // direct Int16Array view throw. Fall back to readInt16LE in that case.
293
+ if ((pcm.byteOffset & 1) !== 0) {
294
+ for (let i = 0; i < numSamples; i++) {
295
+ float32[i] = pcm.readInt16LE(i * 2) / 32768.0;
296
+ }
297
+ return float32;
298
+ }
299
+
300
+ const int16 = new Int16Array(pcm.buffer, pcm.byteOffset, numSamples);
301
+ for (let i = 0; i < numSamples; i++) {
302
+ float32[i] = int16[i]! / 32768.0;
303
+ }
304
+ return float32;
305
+ }
306
+
307
+ /**
308
+ * Get optimal thread count for inference (leave 1-2 cores free for the rest
309
+ * of the agent UI / Pi runtime).
310
+ *
311
+ * `maxThreads` is the per-model-class cap. ONNX Runtime's intra-op pool stops
312
+ * scaling well past a model-specific elbow:
313
+ * - Whisper: autoregressive decoder, threadpool-bound differently → cap 4
314
+ * - SenseVoice / NeMo CTC: encoder-only, modest scaling → cap 4
315
+ * - Transducer (Parakeet TDT, Zipformer): encoder-decoder-joiner, scales
316
+ * to ~6 threads on Apple Silicon performance cores. Caller passes 6.
317
+ *
318
+ * Numbers come from sherpa-onnx published RTF curves
319
+ * (https://k2-fsa.github.io/sherpa/onnx/pretrained_models/offline-transducer/nemo-transducer-models.html)
320
+ * and the threadpool-saturation discussion in
321
+ * https://github.com/k2-fsa/sherpa-onnx/issues/2910.
322
+ */
323
+ function getNumThreads(maxThreads = 4): number {
324
+ const cpus = os.cpus().length || 2;
325
+ if (cpus <= 2) return 1;
326
+ if (cpus <= 4) return 2;
327
+ return Math.min(maxThreads, cpus - 2);
328
+ }
329
+
330
+ /** Transducer (Parakeet, Zipformer) thread budget — see getNumThreads(). */
331
+ const TRANSDUCER_MAX_THREADS = 6;
332
+
333
+ // ─── Paraformer / Qwen3-ASR (zh-centric, added via local sherpa-onnx patch) ─
334
+
335
+ // Verified against: sherpa-onnx offline-paraformer / offline-qwen3-asr configs.
336
+ function createParaformerRecognizer(model: LocalModelInfo, modelDir: string): SherpaRecognizer {
337
+ const files = model.sherpaModel!.files;
338
+ const sherpa = getSherpaModule();
339
+ return new sherpa.OfflineRecognizer({
340
+ featConfig: {
341
+ sampleRate: 16000,
342
+ featureDim: 80,
343
+ },
344
+ modelConfig: {
345
+ paraformer: { model: path.join(modelDir, files.model!) },
346
+ tokens: path.join(modelDir, files.tokens!),
347
+ numThreads: getNumThreads(),
348
+ provider: "cpu",
349
+ },
350
+ });
351
+ }
352
+
353
+ function createQwen3Recognizer(model: LocalModelInfo, modelDir: string): SherpaRecognizer {
354
+ const files = model.sherpaModel!.files;
355
+ const sherpa = getSherpaModule();
356
+ // tokenizer expects a DIRECTORY containing vocab.json / merges.txt /
357
+ // tokenizer_config.json — the downloader flattens those onto modelDir root.
358
+ return new sherpa.OfflineRecognizer({
359
+ featConfig: {
360
+ sampleRate: 16000,
361
+ featureDim: 80,
362
+ },
363
+ modelConfig: {
364
+ qwen3Asr: {
365
+ convFrontend: path.join(modelDir, files.convFrontend!),
366
+ encoder: path.join(modelDir, files.encoder!),
367
+ decoder: path.join(modelDir, files.decoder!),
368
+ tokenizer: modelDir,
369
+ },
370
+ numThreads: getNumThreads(),
371
+ provider: "cpu",
372
+ },
373
+ });
374
+ }
375
+
376
+ // ─── Long-audio segmentation (Silero VAD) ─────────────────────────────────
377
+ // LLM-based ASR (Qwen3-ASR) caps context at max_total_len=512 tokens, which
378
+ // truncates recordings past ~18s. Long audio is split into speech-bounded
379
+ // chunks before decoding. Opt-in per model (see local.ts transcribeInProcess).
380
+
381
+ /** Path to the local Silero VAD model (~/.pi/models/vad/silero_vad.onnx). */
382
+ function getSileroVadPath(): string | null {
383
+ const p = path.join(os.homedir(), ".pi", "models", "vad", "silero_vad.onnx");
384
+ return fs.existsSync(p) ? p : null;
385
+ }
386
+
387
+ /**
388
+ * Split Float32 PCM into speech segments via Silero VAD.
389
+ * Each returned chunk is ≤ maxSpeechSecs of speech. Falls back to the whole
390
+ * buffer when the VAD model is unavailable (graceful degradation).
391
+ */
392
+ export function segmentPcmForLongAudio(samples: Float32Array, sampleRate: number, maxSpeechSecs = 10): Float32Array[] {
393
+ const vadModel = getSileroVadPath();
394
+ if (!vadModel) return [samples];
395
+
396
+ const sherpa = getSherpaModule();
397
+ const vad = new sherpa.Vad(
398
+ {
399
+ sileroVad: {
400
+ model: vadModel,
401
+ threshold: 0.5,
402
+ minSilenceDuration: 0.25,
403
+ minSpeechDuration: 0.25,
404
+ maxSpeechDuration: maxSpeechSecs,
405
+ windowSize: 512,
406
+ },
407
+ sampleRate,
408
+ numThreads: 1,
409
+ provider: "cpu",
410
+ },
411
+ 60
412
+ );
413
+
414
+ // Feed one silero window at a time, draining segments as they appear —
415
+ // mirrors the official nodejs-addon-examples VAD usage. Silero only enqueues
416
+ // speech segments, so no isDetected() gate is needed (the node binding's
417
+ // isDetected() reports false even for genuine speech).
418
+ const ws: number = vad.config?.sileroVad?.windowSize ?? 512;
419
+ const segments: Float32Array[] = [];
420
+ for (let i = 0; i < samples.length; i += ws) {
421
+ vad.acceptWaveform(samples.subarray(i, i + ws));
422
+ while (!vad.isEmpty()) {
423
+ segments.push(vad.front().samples);
424
+ vad.pop();
425
+ }
426
+ }
427
+ vad.flush();
428
+ while (!vad.isEmpty()) {
429
+ segments.push(vad.front().samples);
430
+ vad.pop();
431
+ }
432
+ return segments.length > 0 ? segments : [samples];
433
+ }
434
+
435
+ /**
436
+ * Transcribe PCM with automatic VAD segmentation for long recordings.
437
+ * Byte-identical fast path (single decode) for audio ≤ thresholdSecs.
438
+ */
439
+ export async function transcribeBufferSegmented(
440
+ pcmData: Buffer,
441
+ recognizer: SherpaRecognizer,
442
+ thresholdSecs = 10
443
+ ): Promise<string> {
444
+ getSherpaModule();
445
+ const samples = pcmToFloat32(pcmData);
446
+ if (samples.length / 16000 <= thresholdSecs) {
447
+ const stream = recognizer.createStream();
448
+ stream.acceptWaveform({ sampleRate: 16000, samples });
449
+ await recognizer.decodeAsync(stream);
450
+ const r = recognizer.getResult(stream);
451
+ return (r?.text || "").trim();
452
+ }
453
+
454
+ const parts: string[] = [];
455
+ for (const seg of segmentPcmForLongAudio(samples, 16000)) {
456
+ const stream = recognizer.createStream();
457
+ stream.acceptWaveform({ sampleRate: 16000, samples: seg });
458
+ await recognizer.decodeAsync(stream);
459
+ const r = recognizer.getResult(stream);
460
+ const t = (r?.text || "").trim();
461
+ if (t) parts.push(t);
462
+ }
463
+ return parts.join(" ");
464
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Shared sherpa-onnx-node loader used by both the STT engine
3
+ * (`sherpa-engine.ts`) and the TTS engine (`tts-engine.ts`).
4
+ *
5
+ * Why a shared loader: the native module load is expensive (50-200ms) and
6
+ * has platform compatibility checks that need to run exactly once per
7
+ * process. Without this, an STT-then-TTS or TTS-then-STT call sequence
8
+ * would either re-run the platform checks or — worse — race two concurrent
9
+ * `import("sherpa-onnx-node")` calls when the user enables both backends.
10
+ *
11
+ * Concurrency contract: JavaScript is single-threaded with run-to-completion
12
+ * semantics. The `sherpaInitialized` fast-path check, the `initPromise ??=
13
+ * …` slot claim, and the synchronous start of the async body in
14
+ * `doLoadSherpa()` all happen in the same tick — there is no preemption
15
+ * window between them. A second caller arriving on a later microtask sees a
16
+ * non-null `initPromise` and awaits the same in-flight promise. Once the
17
+ * promise settles, `sherpaInitialized` is true so every later caller takes
18
+ * the synchronous fast-path. No race window where two concurrent callers
19
+ * can both enter the platform-check / dynamic-import path.
20
+ *
21
+ * Behavior is byte-identical to the previous `initSherpa()` in
22
+ * `sherpa-engine.ts`. STT continues to call `initSherpa()` (kept as a
23
+ * thin alias) so this is a zero-behavior-change refactor.
24
+ */
25
+
26
+ import * as fs from "node:fs";
27
+
28
+ /** sherpa-onnx-node module — populated by loadSherpa() on first call. */
29
+ let sherpaModule: any = null;
30
+ let sherpaInitialized = false;
31
+ let sherpaError: string | null = null;
32
+
33
+ /**
34
+ * Promise cache for in-flight initialization. Concurrent callers (e.g. one
35
+ * voice command and one settings-panel diagnostic firing within the same
36
+ * tick) all await the same promise instead of each running platform checks
37
+ * and dynamically importing the native module independently.
38
+ */
39
+ let initPromise: Promise<boolean> | null = null;
40
+
41
+ /**
42
+ * Load (and cache) the sherpa-onnx-node native module. Returns true on
43
+ * success, false on platform incompatibility or load failure. Subsequent
44
+ * calls return the cached result synchronously through the resolved
45
+ * promise — no work is repeated.
46
+ */
47
+ export async function loadSherpa(): Promise<boolean> {
48
+ if (sherpaInitialized) return !sherpaError;
49
+ // `??=` is a single expression: assign-if-nullish. It claims the slot
50
+ // before yielding, so two concurrent callers cannot both enter doLoadSherpa.
51
+ initPromise ??= doLoadSherpa();
52
+ return initPromise;
53
+ }
54
+
55
+ async function doLoadSherpa(): Promise<boolean> {
56
+ try {
57
+ // Early platform checks — fail fast with clear messages
58
+ if (process.arch === "arm") {
59
+ throw new Error(
60
+ "ARM32 (armv7l) is not supported by sherpa-onnx-node. Use 64-bit OS or the Deepgram cloud backend."
61
+ );
62
+ }
63
+ if (process.platform === "linux") {
64
+ try {
65
+ const ldd = fs.readFileSync("/usr/bin/ldd", "utf-8");
66
+ if (ldd.includes("musl")) {
67
+ throw new Error(
68
+ "Alpine Linux (musl libc) is not supported by sherpa-onnx-node. Use a glibc-based distribution or the Deepgram cloud backend."
69
+ );
70
+ }
71
+ } catch (e: any) {
72
+ if (e?.message?.includes("musl")) throw e;
73
+ // /usr/bin/ldd not readable — not Alpine, continue
74
+ }
75
+ }
76
+
77
+ // Note: LD_LIBRARY_PATH/DYLD_LIBRARY_PATH set at runtime have no effect on dlopen().
78
+ // The native .node binary uses $ORIGIN/@loader_path to find sibling .so/.dylib files,
79
+ // so library resolution works without env var manipulation.
80
+
81
+ // CJS-vs-ESM interop. sherpa-onnx-node ships as CommonJS with
82
+ // `module.exports = { OfflineTts, OfflineRecognizer, ... }`.
83
+ // Three runtimes return three different shapes:
84
+ //
85
+ // - Bun: namespace exposes all symbols at top-level (works directly).
86
+ // - Node native: top-level is empty, full module on `.default`.
87
+ // - jiti (Pi's loader): top-level has STUB classes (e.g.
88
+ // `OfflineTts` is a function) but their static methods (like
89
+ // `OfflineTts.createAsync`) are MISSING. The real, fully-
90
+ // populated module sits on `.default`. This is the failure
91
+ // mode reported as "sherpa.OfflineTts.createAsync is not a
92
+ // function" on first /voice-speak.
93
+ //
94
+ // We sniff for `createAsync` specifically because that's the
95
+ // thing the TTS engine needs. If the top-level OfflineTts has
96
+ // it, the namespace is fully-populated (Bun); otherwise prefer
97
+ // `.default` when it carries createAsync; final fallback is the
98
+ // namespace as-is so future runtime variants degrade gracefully.
99
+ const ns = await import("sherpa-onnx-node");
100
+ const top = ns as any;
101
+ const def = (ns as any).default;
102
+ const topHasCreateAsync = typeof top?.OfflineTts?.createAsync === "function";
103
+ const defHasCreateAsync = typeof def?.OfflineTts?.createAsync === "function";
104
+ sherpaModule = topHasCreateAsync ? top : defHasCreateAsync ? def : top;
105
+ sherpaInitialized = true;
106
+ return true;
107
+ } catch (err: any) {
108
+ sherpaError = err?.message || String(err);
109
+ sherpaInitialized = true;
110
+ return false;
111
+ } finally {
112
+ // Drop the in-flight reference once the promise settles. From here on
113
+ // the synchronous `sherpaInitialized` fast-path at the top of loadSherpa
114
+ // serves every caller — keeping the resolved promise around is dead
115
+ // weight. Setting to null in the finally is safe because every code
116
+ // path through the try/catch sets `sherpaInitialized = true` before
117
+ // reaching here, so any later caller will fast-path and never read
118
+ // the now-null `initPromise`.
119
+ initPromise = null;
120
+ }
121
+ }
122
+
123
+ /** Get the loader error message, if any. */
124
+ export function getSherpaError(): string | null {
125
+ return sherpaError;
126
+ }
127
+
128
+ /** True when sherpa-onnx is loaded and usable. */
129
+ export function isSherpaAvailable(): boolean {
130
+ return sherpaInitialized && !sherpaError && sherpaModule != null;
131
+ }
132
+
133
+ /**
134
+ * Get the cached sherpa-onnx-node module. Throws if loadSherpa() has not
135
+ * succeeded yet — callers must `await loadSherpa()` first and check the
136
+ * boolean return before calling this.
137
+ */
138
+ export function getSherpaModule(): any {
139
+ if (!sherpaInitialized || sherpaError || sherpaModule == null) {
140
+ throw new Error("sherpa-onnx not loaded. Call loadSherpa() first and verify it returned true.");
141
+ }
142
+ return sherpaModule;
143
+ }
@@ -0,0 +1,4 @@
1
+ declare module "sherpa-onnx-node" {
2
+ const sherpa: any;
3
+ export = sherpa;
4
+ }