pi-voicekit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/extensions/voice/config.ts +395 -0
- package/extensions/voice/deepgram.ts +33 -0
- package/extensions/voice/device.ts +382 -0
- package/extensions/voice/hold-to-talk.ts +69 -0
- package/extensions/voice/local.ts +1143 -0
- package/extensions/voice/model-download.ts +636 -0
- package/extensions/voice/onboarding.ts +739 -0
- package/extensions/voice/release-controller.ts +55 -0
- package/extensions/voice/settings-panel.ts +1602 -0
- package/extensions/voice/sherpa-engine.ts +464 -0
- package/extensions/voice/sherpa-loader.ts +143 -0
- package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
- package/extensions/voice/speak.ts +430 -0
- package/extensions/voice/tts-deepgram.ts +454 -0
- package/extensions/voice/tts-engine.ts +653 -0
- package/extensions/voice/tts-install-progress.ts +257 -0
- package/extensions/voice/tts-local-models.ts +1255 -0
- package/extensions/voice/tts-onboarding-overlay.ts +186 -0
- package/extensions/voice/tts-onboarding.ts +87 -0
- package/extensions/voice/tts-playback-indicator.ts +127 -0
- package/extensions/voice/tts-playback.ts +675 -0
- package/extensions/voice/tts-text-filter.ts +404 -0
- package/extensions/voice/ui-aura.ts +272 -0
- package/extensions/voice/ui-help-overlay.ts +161 -0
- package/extensions/voice/ui-icons.ts +124 -0
- package/extensions/voice/ui-locale-labels.ts +110 -0
- package/extensions/voice/ui-picker.ts +209 -0
- package/extensions/voice/ui-render-ticker.ts +171 -0
- package/extensions/voice/ui-widget-base.ts +219 -0
- package/extensions/voice/ui-width.ts +112 -0
- package/extensions/voice.ts +3644 -0
- package/package.json +75 -0
|
@@ -0,0 +1,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
|
+
}
|