@juspay/neurolink 12.48.2 → 12.49.1

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 (106) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/browser/neurolink.min.js +616 -595
  3. package/dist/cli/commands/serve.d.ts +0 -3
  4. package/dist/cli/commands/serve.js +85 -3
  5. package/dist/cli/commands/transcribe.d.ts +24 -0
  6. package/dist/cli/commands/transcribe.js +718 -0
  7. package/dist/cli/factories/commandFactory.d.ts +10 -0
  8. package/dist/cli/factories/commandFactory.js +13 -0
  9. package/dist/cli/parser.js +3 -0
  10. package/dist/factories/mediaHandlerCatalog.js +11 -9
  11. package/dist/factories/providerDescriptors.js +5 -4
  12. package/dist/factories/sttDescriptors.d.ts +63 -0
  13. package/dist/factories/sttDescriptors.js +286 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +7 -1
  16. package/dist/neurolink.d.ts +68 -1
  17. package/dist/neurolink.js +202 -4
  18. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  19. package/dist/processors/media/AudioProcessor.js +105 -276
  20. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  21. package/dist/processors/media/VideoProcessor.js +52 -51
  22. package/dist/providers/cloudflareClef.js +9 -1
  23. package/dist/server/abstract/baseServerAdapter.d.ts +7 -0
  24. package/dist/server/abstract/baseServerAdapter.js +13 -1
  25. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  26. package/dist/server/adapters/expressAdapter.js +48 -4
  27. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  28. package/dist/server/adapters/fastifyAdapter.js +38 -1
  29. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  30. package/dist/server/adapters/honoAdapter.js +83 -1
  31. package/dist/server/adapters/koaAdapter.d.ts +1 -0
  32. package/dist/server/adapters/koaAdapter.js +43 -0
  33. package/dist/server/index.d.ts +3 -2
  34. package/dist/server/index.js +3 -2
  35. package/dist/server/routes/index.d.ts +1 -0
  36. package/dist/server/routes/index.js +3 -0
  37. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  38. package/dist/server/routes/transcribeRoutes.js +506 -0
  39. package/dist/server/utils/rawBody.d.ts +32 -0
  40. package/dist/server/utils/rawBody.js +76 -0
  41. package/dist/server/utils/validation.d.ts +192 -0
  42. package/dist/server/utils/validation.js +100 -0
  43. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  44. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  45. package/dist/types/cli.d.ts +46 -0
  46. package/dist/types/index.d.ts +1 -0
  47. package/dist/types/index.js +1 -0
  48. package/dist/types/providers.d.ts +7 -1
  49. package/dist/types/server.d.ts +117 -1
  50. package/dist/types/stt.d.ts +160 -7
  51. package/dist/types/transcribe.d.ts +506 -0
  52. package/dist/types/transcribe.js +29 -0
  53. package/dist/utils/speakerTurns.d.ts +28 -0
  54. package/dist/utils/speakerTurns.js +93 -0
  55. package/dist/utils/sttProcessor.d.ts +20 -2
  56. package/dist/utils/sttProcessor.js +73 -5
  57. package/dist/voice/correction/dictionary.d.ts +49 -0
  58. package/dist/voice/correction/dictionary.js +134 -0
  59. package/dist/voice/correction/guard.d.ts +35 -0
  60. package/dist/voice/correction/guard.js +161 -0
  61. package/dist/voice/correction/index.d.ts +47 -0
  62. package/dist/voice/correction/index.js +211 -0
  63. package/dist/voice/correction/rewrite.d.ts +92 -0
  64. package/dist/voice/correction/rewrite.js +322 -0
  65. package/dist/voice/errors.d.ts +15 -0
  66. package/dist/voice/errors.js +45 -0
  67. package/dist/voice/index.d.ts +20 -2
  68. package/dist/voice/index.js +75 -9
  69. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  70. package/dist/voice/providers/AzureSTT.js +23 -75
  71. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  72. package/dist/voice/providers/DeepgramSTT.js +109 -13
  73. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  74. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  75. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  76. package/dist/voice/providers/GoogleSTT.js +21 -75
  77. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  78. package/dist/voice/providers/OpenAISTT.js +196 -21
  79. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  80. package/dist/voice/providers/WhistleSTT.js +313 -0
  81. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  82. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  83. package/dist/voice/streaming/endpointer.d.ts +81 -0
  84. package/dist/voice/streaming/endpointer.js +247 -0
  85. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  86. package/dist/voice/streaming/localAgreement.js +44 -0
  87. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  88. package/dist/voice/sttEnvCredentials.js +12 -0
  89. package/dist/voice/transcribe.d.ts +47 -0
  90. package/dist/voice/transcribe.js +519 -0
  91. package/dist/voice/transcribeStream.d.ts +4 -0
  92. package/dist/voice/transcribeStream.js +584 -0
  93. package/dist/voice/whistle/assets.d.ts +47 -0
  94. package/dist/voice/whistle/assets.js +270 -0
  95. package/dist/voice/whistle/audio.d.ts +32 -0
  96. package/dist/voice/whistle/audio.js +267 -0
  97. package/dist/voice/whistle/engine.d.ts +32 -0
  98. package/dist/voice/whistle/engine.js +193 -0
  99. package/dist/voice/whistle/worker.d.ts +18 -0
  100. package/dist/voice/whistle/worker.js +294 -0
  101. package/docs-site/static/search-index.json +71 -12
  102. package/models/whistle/README.md +18 -0
  103. package/models/whistle/needle.js +2 -0
  104. package/models/whistle/needle.wasm +0 -0
  105. package/models/whistle/whistle.cact +0 -0
  106. package/package.json +7 -3
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Streaming transcription orchestration: `neurolink.transcribeStream()`.
3
+ *
4
+ * Two ways to stream, chosen per request:
5
+ *
6
+ * - **native** — the handler streams itself (`transcribeStream` +
7
+ * `supportsStreaming`); its segments are mapped to `interim` / `final`
8
+ * events, with LocalAgreement applied to consecutive interim segments.
9
+ * - **chunked** — any batch handler, streamed on the client by the chunked
10
+ * adapter (energy gate, rolling re-transcription, LocalAgreement, language
11
+ * takeover to the fallback engine).
12
+ *
13
+ * `streaming.mode` `"auto"` (the default) and `"native"` use the native
14
+ * stream when the handler has one, else the adapter; `"chunked"` always uses
15
+ * the adapter.
16
+ *
17
+ * Every final is followed, when correction is configured, by `correcting`
18
+ * events (the streamed rewrite) and one `corrected` event. Correction runs
19
+ * beside the stream: utterance N being corrected never holds back the
20
+ * interim passes of utterance N+1. The second-opinion engine runs on the
21
+ * utterance audio in parallel with the final pass, not after it.
22
+ *
23
+ * @module voice/transcribeStream
24
+ */
25
+ import { logger } from "../utils/logger.js";
26
+ import { SpanSerializer, SpanStatus, SpanType, getMetricsAggregator, } from "../observability/index.js";
27
+ import { createWavFile } from "./audio-utils.js";
28
+ import { correctTranscript, isCorrectionEnabled } from "./correction/index.js";
29
+ import { StreamEventQueue, chunkedTranscribeStream, } from "./streaming/chunkedStreamAdapter.js";
30
+ import { ENDPOINTER_DEFAULTS, ENGINE_SAMPLE_RATE, } from "./streaming/endpointer.js";
31
+ import { createLocalAgreement, resetLocalAgreement, splitWords, updateLocalAgreement, } from "./streaming/localAgreement.js";
32
+ /** Keys of `TranscribeOptions` that are not `STTOptions` and must not reach a handler. */
33
+ const NON_STT_KEYS = new Set([
34
+ "audio",
35
+ "provider",
36
+ "model",
37
+ "language",
38
+ "format",
39
+ "prompt",
40
+ "dictionary",
41
+ "correction",
42
+ "fallback",
43
+ "streaming",
44
+ "credentials",
45
+ ]);
46
+ // `timeoutMs` is deliberately NOT in the set: it is an engine option as well
47
+ // (handlers that honour one apply it per request), so it rides through.
48
+ /** Float samples in [-1, 1] → 16 kHz mono PCM16 WAV. */
49
+ export function encodeWav16k(pcm) {
50
+ const data = Buffer.alloc(pcm.length * 2);
51
+ for (let i = 0; i < pcm.length; i++) {
52
+ const s = Math.max(-1, Math.min(1, pcm[i]));
53
+ data.writeInt16LE(Math.round(s < 0 ? s * 0x8000 : s * 0x7fff), i * 2);
54
+ }
55
+ return createWavFile(data, ENGINE_SAMPLE_RATE, 1, 16);
56
+ }
57
+ /**
58
+ * Engine options for one provider: the caller's pass-through `STTOptions`,
59
+ * dictionary terms as vocabulary and appended to the prompt, `"auto"`
60
+ * language left to the engine.
61
+ */
62
+ function buildEngineOptions(options, model, audio) {
63
+ const passthrough = {};
64
+ for (const [key, value] of Object.entries(options)) {
65
+ if (!NON_STT_KEYS.has(key) && value !== undefined) {
66
+ passthrough[key] = value;
67
+ }
68
+ }
69
+ const terms = (options.dictionary ?? [])
70
+ .map((d) => d.term.trim())
71
+ .filter(Boolean);
72
+ const vocabulary = [...(options.vocabulary ?? []), ...terms];
73
+ const promptParts = [options.prompt, options.correction?.context].filter((p) => typeof p === "string" && p.trim().length > 0);
74
+ if (terms.length) {
75
+ promptParts.push(terms.join(", "));
76
+ }
77
+ const language = options.language && options.language.toLowerCase() !== "auto"
78
+ ? options.language
79
+ : undefined;
80
+ return {
81
+ ...passthrough,
82
+ ...(model ? { model } : {}),
83
+ ...(language ? { language } : {}),
84
+ ...(vocabulary.length ? { vocabulary } : {}),
85
+ ...(promptParts.length ? { prompt: promptParts.join(" ") } : {}),
86
+ format: audio.format,
87
+ sampleRate: audio.sampleRate,
88
+ };
89
+ }
90
+ /** Uint8Array frames → Buffers (no copy), reporting each frame as it is handed over. */
91
+ async function* asBuffers(frames, onFrame) {
92
+ for await (const f of frames) {
93
+ onFrame(f.byteLength);
94
+ yield Buffer.isBuffer(f)
95
+ ? f
96
+ : Buffer.from(f.buffer, f.byteOffset, f.byteLength);
97
+ }
98
+ }
99
+ /** The second engine on one utterance; never rejects (`text: null` when it heard nothing or failed). */
100
+ async function runSecondOpinion(deps, pcm, provider, engineOptions, utterance) {
101
+ const t0 = Date.now();
102
+ try {
103
+ const r = await deps.transcribe(encodeWav16k(pcm), provider, engineOptions);
104
+ return { text: r.text?.trim() || null, ms: Date.now() - t0 };
105
+ }
106
+ catch (err) {
107
+ if (logger.shouldLog("debug")) {
108
+ logger.debug(`[transcribeStream] second opinion failed for utterance ${utterance}: ${err instanceof Error ? err.message : String(err)}`);
109
+ }
110
+ return { text: null, ms: Date.now() - t0 };
111
+ }
112
+ }
113
+ /** Text ending a sentence: Latin, Devanagari danda, CJK and ellipsis, before closing quotes or brackets. */
114
+ const SENTENCE_END = /[.?!\u0964\u3002\uFF1F\uFF01\u2026]["'\u201D\u2019)\]]*$/;
115
+ function segStart(s) {
116
+ return s.startTime ?? s.start;
117
+ }
118
+ function segEnd(s) {
119
+ return s.endTime ?? s.end;
120
+ }
121
+ /**
122
+ * One utterance of a native stream, assembled from the engine's segments.
123
+ *
124
+ * Native engines (Whistle, Deepgram live) report a final segment for each run
125
+ * of words they commit and an interim for the still-unconfirmed tail, which
126
+ * does not repeat what was already committed. A commit therefore is not the
127
+ * end of an utterance: Whistle shows "Hello there." as its tail and then
128
+ * commits just "Hello", with "there" still pending. The utterance stays open
129
+ * until a commit covers everything the engine had shown as pending (or the
130
+ * engine reports an empty tail, or the stream ends), so a final is never cut
131
+ * below what the caller already saw as one utterance, and the boundaries are
132
+ * the engine's own.
133
+ */
134
+ class NativeUtterance {
135
+ id;
136
+ floor;
137
+ /** Text of the commits so far, in order. */
138
+ commits = [];
139
+ words = [];
140
+ /** The engine's current unconfirmed tail. */
141
+ tail = "";
142
+ /** LocalAgreement over successive tails; shifted when a commit moves the base. */
143
+ la = createLocalAgreement();
144
+ startTime;
145
+ endTime;
146
+ language;
147
+ confidence;
148
+ constructor(id,
149
+ /** Where the previous utterance ended, the fallback start when the engine gives no times. */
150
+ floor) {
151
+ this.id = id;
152
+ this.floor = floor;
153
+ }
154
+ get committedText() {
155
+ return this.commits.join(" ");
156
+ }
157
+ get empty() {
158
+ return this.commits.length === 0 && !this.tail;
159
+ }
160
+ /** Start on the stream clock: the first commit's time, else where the previous one ended. */
161
+ get start() {
162
+ return this.startTime ?? this.floor;
163
+ }
164
+ }
165
+ /** Maps a native handler's segments to stream events. */
166
+ async function* nativeEvents(handler, frames, engineOptions, engine, inputRate, maxUtteranceSeconds) {
167
+ let lastFrameAt = Date.now();
168
+ let bytesIn = 0;
169
+ let lastLanguage = "";
170
+ // Seconds of audio handed to the engine: interim segments carry no times,
171
+ // so this is what an interim's length is measured against.
172
+ const clock = () => bytesIn / 2 / inputRate;
173
+ let u = new NativeUtterance(1, 0);
174
+ /**
175
+ * The current utterance as an interim. `fromEngine` is true for a new tail
176
+ * from the engine, which is one LocalAgreement pass; a tail trimmed after a
177
+ * partial commit is the same pass seen again and must not count as agreement.
178
+ */
179
+ const interim = (fromEngine) => {
180
+ let tailCommitted;
181
+ let tailRest;
182
+ if (fromEngine) {
183
+ const split = updateLocalAgreement(u.la, u.tail);
184
+ tailCommitted = split.committed;
185
+ tailRest = split.tail;
186
+ }
187
+ else {
188
+ const words = splitWords(u.tail);
189
+ tailCommitted = u.la.committedWords.join(" ");
190
+ tailRest = words.slice(u.la.committedN).join(" ");
191
+ }
192
+ return {
193
+ type: "interim",
194
+ utterance: u.id,
195
+ text: [u.committedText, u.tail].filter(Boolean).join(" "),
196
+ committed: [u.committedText, tailCommitted].filter(Boolean).join(" "),
197
+ tail: tailRest,
198
+ seconds: Math.max(0, Math.max(clock(), u.endTime ?? 0) - u.start),
199
+ // A native stream has no passes; this is how far the text trails the newest audio.
200
+ latencyMs: Date.now() - lastFrameAt,
201
+ engine,
202
+ };
203
+ };
204
+ /** Ends the current utterance; words still pending are kept, never dropped. */
205
+ const close = () => {
206
+ const done = u;
207
+ const text = [done.committedText, done.tail].filter(Boolean).join(" ");
208
+ const start = done.start;
209
+ const end = Math.max(start, done.tail ? clock() : (done.endTime ?? clock()));
210
+ u = new NativeUtterance(done.id + 1, end);
211
+ if (!text) {
212
+ return null;
213
+ }
214
+ const seconds = end - start;
215
+ return {
216
+ type: "final",
217
+ utterance: done.id,
218
+ text,
219
+ segment: {
220
+ index: done.id,
221
+ text,
222
+ isFinal: true,
223
+ startTime: start,
224
+ endTime: end,
225
+ start,
226
+ end,
227
+ ...(done.confidence !== undefined
228
+ ? { confidence: done.confidence }
229
+ : {}),
230
+ ...(done.words.length ? { words: done.words } : {}),
231
+ ...(done.language ? { language: done.language } : {}),
232
+ },
233
+ ...(done.words.length ? { words: done.words } : {}),
234
+ ...(done.language ? { language: done.language } : {}),
235
+ engine,
236
+ seconds,
237
+ };
238
+ };
239
+ const stream = handler.transcribeStream(asBuffers(frames, (bytes) => {
240
+ bytesIn += bytes;
241
+ lastFrameAt = Date.now();
242
+ }), engineOptions);
243
+ for await (const seg of stream) {
244
+ if (seg.language && seg.language !== lastLanguage) {
245
+ lastLanguage = seg.language;
246
+ yield {
247
+ type: "language",
248
+ utterance: u.id,
249
+ language: seg.language,
250
+ detected: true,
251
+ };
252
+ }
253
+ if (seg.language) {
254
+ u.language = seg.language;
255
+ }
256
+ const text = seg.text.trim();
257
+ if (!seg.isFinal) {
258
+ if (text) {
259
+ u.tail = text;
260
+ yield interim(true);
261
+ }
262
+ else if (!u.empty) {
263
+ // The engine has nothing pending any more: what it committed is the utterance.
264
+ u.tail = "";
265
+ const ev = close();
266
+ if (ev) {
267
+ yield ev;
268
+ }
269
+ }
270
+ continue;
271
+ }
272
+ if (!text) {
273
+ continue;
274
+ }
275
+ const pending = splitWords(u.tail);
276
+ const committedWords = splitWords(text);
277
+ u.commits.push(text);
278
+ u.startTime ??= segStart(seg);
279
+ u.endTime = segEnd(seg) ?? u.endTime;
280
+ if (seg.words?.length) {
281
+ u.words.push(...seg.words);
282
+ }
283
+ if (typeof seg.confidence === "number") {
284
+ u.confidence = seg.confidence;
285
+ }
286
+ if (committedWords.length >= pending.length) {
287
+ // The commit covers everything shown as pending. That ends the
288
+ // utterance when the engine ended a sentence, or the turn has run past
289
+ // `maxUtteranceSeconds`; a commit like "Let us review the" (measured on
290
+ // Whistle mid-sentence, the rest arriving a second later) leaves it open.
291
+ // An empty tail from the engine, or the end of the stream, closes it too.
292
+ u.tail = "";
293
+ if (SENTENCE_END.test(text) ||
294
+ (u.endTime ?? clock()) - u.start >= maxUtteranceSeconds) {
295
+ const ev = close();
296
+ if (ev) {
297
+ yield ev;
298
+ }
299
+ }
300
+ else {
301
+ resetLocalAgreement(u.la);
302
+ }
303
+ }
304
+ else {
305
+ // A partial commit: the rest of the shown tail is still pending in this utterance.
306
+ u.tail = pending.slice(committedWords.length).join(" ");
307
+ // Shift LocalAgreement past the committed words rather than resetting
308
+ // it, so the committed text an interim reports never shrinks.
309
+ u.la.prevWords = u.la.prevWords.slice(committedWords.length);
310
+ u.la.committedWords = u.la.committedWords.slice(committedWords.length);
311
+ u.la.committedN = u.la.committedWords.length;
312
+ yield interim(false);
313
+ }
314
+ }
315
+ // Stream over: anything committed or still shown is finalized.
316
+ const last = close();
317
+ if (last) {
318
+ yield last;
319
+ }
320
+ }
321
+ /**
322
+ * `"auto"` and `"native"` take the handler's own stream when it has one;
323
+ * `"native"` on a handler without one falls back to the adapter.
324
+ */
325
+ function chooseNative(mode, handler, provider) {
326
+ const useNative = mode !== "chunked" &&
327
+ handler?.supportsStreaming === true &&
328
+ typeof handler.transcribeStream === "function";
329
+ if (mode === "native" && !useNative && logger.shouldLog("debug")) {
330
+ logger.debug(`[transcribeStream] "${provider}" has no native stream; using the chunked adapter`);
331
+ }
332
+ return useNative;
333
+ }
334
+ export async function* runTranscribeStream(options, deps) {
335
+ const provider = deps.resolveProvider(options.provider);
336
+ const handler = deps.getHandler(provider);
337
+ const mode = options.streaming?.mode ?? "auto";
338
+ const inputRate = options.streaming?.sampleRate && options.streaming.sampleRate > 0
339
+ ? options.streaming.sampleRate
340
+ : ENGINE_SAMPLE_RATE;
341
+ const useNative = chooseNative(mode, handler, provider);
342
+ const out = new StreamEventQueue();
343
+ const span = SpanSerializer.createSpan(SpanType.STT, "stt.transcribeStream", {
344
+ "stt.operation": "transcribeStream",
345
+ "stt.provider": provider,
346
+ "stt.language": options.language,
347
+ "stt.streaming_mode": useNative ? "native" : "chunked",
348
+ });
349
+ // Optional engines resolve up front; a misconfigured one is reported and
350
+ // left out rather than ending a stream the primary engine can serve.
351
+ const resolveOptional = (name, role) => {
352
+ try {
353
+ return deps.resolveProvider(name);
354
+ }
355
+ catch (err) {
356
+ out.push({
357
+ type: "error",
358
+ message: `${role} provider "${name}" unavailable: ${err instanceof Error ? err.message : String(err)}`,
359
+ recoverable: true,
360
+ });
361
+ return undefined;
362
+ }
363
+ };
364
+ const fallbackProvider = !useNative && options.fallback
365
+ ? resolveOptional(options.fallback.provider, "fallback")
366
+ : undefined;
367
+ const correcting = isCorrectionEnabled(options.dictionary, options.correction);
368
+ const secondSpec = correcting ? options.correction?.secondOpinion : undefined;
369
+ const secondProvider = !useNative && secondSpec
370
+ ? resolveOptional(secondSpec.provider, "second-opinion")
371
+ : undefined;
372
+ const wavAudio = { format: "wav", sampleRate: ENGINE_SAMPLE_RATE };
373
+ const primaryOpts = buildEngineOptions(options, options.model, wavAudio);
374
+ const fallbackOpts = buildEngineOptions(options, options.fallback?.model, wavAudio);
375
+ const secondOpts = buildEngineOptions(options, secondSpec?.model, wavAudio);
376
+ // Per-utterance bookkeeping shared by the adapter hooks and the correction tasks.
377
+ const finalPassMs = new Map();
378
+ const endedAt = new Map();
379
+ const secondOpinions = new Map();
380
+ const run = async (pcm, pass) => {
381
+ const wav = encodeWav16k(pcm);
382
+ const t0 = Date.now();
383
+ const res = pass.engine === "fallback" && fallbackProvider
384
+ ? await deps.transcribe(wav, fallbackProvider, fallbackOpts)
385
+ : await deps.transcribe(wav, provider, primaryOpts);
386
+ if (pass.final) {
387
+ finalPassMs.set(pass.utterance, Date.now() - t0);
388
+ }
389
+ return res;
390
+ };
391
+ const source = useNative
392
+ ? nativeEvents(handler, options.audio, buildEngineOptions(options, options.model, {
393
+ format: "pcm16",
394
+ sampleRate: inputRate,
395
+ }), provider, inputRate, options.streaming?.maxUtteranceSeconds ??
396
+ ENDPOINTER_DEFAULTS.maxUtteranceSeconds)
397
+ : chunkedTranscribeStream(options.audio, run, {
398
+ ...options.streaming,
399
+ sampleRate: inputRate,
400
+ engineLabel: provider,
401
+ ...(fallbackProvider
402
+ ? {
403
+ fallback: {
404
+ label: fallbackProvider,
405
+ ...(options.fallback?.when
406
+ ? { when: options.fallback.when }
407
+ : {}),
408
+ },
409
+ }
410
+ : {}),
411
+ onUtteranceEnd: (pcm, utterance, engine) => {
412
+ endedAt.set(utterance, Date.now());
413
+ // After a takeover the fallback's text is the transcript; a second
414
+ // opinion of it would only reconcile against the primary's noise.
415
+ if (!secondProvider || engine !== "primary") {
416
+ return;
417
+ }
418
+ secondOpinions.set(utterance, runSecondOpinion(deps, pcm, secondProvider, secondOpts, utterance));
419
+ },
420
+ });
421
+ const takeSecondOpinion = async (utterance) => {
422
+ const p = secondOpinions.get(utterance);
423
+ secondOpinions.delete(utterance);
424
+ return p ? await p : undefined;
425
+ };
426
+ /** Correction of one final; pushes `correcting` partials and one `corrected`. */
427
+ const correct = async (final, preSteps, alt) => {
428
+ const utterance = final.utterance;
429
+ const started = endedAt.get(utterance) ?? Date.now();
430
+ const transcribeMs = finalPassMs.get(utterance) ?? 0;
431
+ finalPassMs.delete(utterance);
432
+ endedAt.delete(utterance);
433
+ const steps = [...preSteps];
434
+ const secondOpinion = !final.fallbackUsed && alt?.text ? alt.text : undefined;
435
+ if (secondSpec && !final.fallbackUsed && !useNative && !alt?.text) {
436
+ steps.push("second opinion returned nothing");
437
+ }
438
+ // Nothing after the consumer has gone: no second opinion is reconciled
439
+ // and no rewrite is paid for that nobody will read.
440
+ if (aborted) {
441
+ return;
442
+ }
443
+ try {
444
+ const res = await correctTranscript({
445
+ text: final.text,
446
+ ...(secondOpinion ? { secondOpinion } : {}),
447
+ ...(options.dictionary ? { dictionary: options.dictionary } : {}),
448
+ ...(options.correction ? { options: options.correction } : {}),
449
+ ...(final.languageDetected !== undefined
450
+ ? { languageDetected: final.languageDetected }
451
+ : {}),
452
+ onPartial: (text) => {
453
+ if (text) {
454
+ out.push({ type: "correcting", utterance, text });
455
+ }
456
+ },
457
+ }, deps);
458
+ out.push({
459
+ type: "corrected",
460
+ utterance,
461
+ raw: final.text,
462
+ text: res.text || final.text,
463
+ decisions: res.decisions,
464
+ steps: [...steps, ...res.steps],
465
+ timings: {
466
+ transcribeMs,
467
+ ...(alt ? { secondOpinionMs: alt.ms } : {}),
468
+ ...res.timings,
469
+ totalMs: Date.now() - started,
470
+ },
471
+ });
472
+ }
473
+ catch (err) {
474
+ // correctTranscript keeps its own timeouts and fails open; this is the
475
+ // last guard so a bug there costs the correction, never the transcript.
476
+ const message = err instanceof Error ? err.message : String(err);
477
+ logger.warn(`[transcribeStream] correction failed for utterance ${utterance}: ${message}`);
478
+ out.push({
479
+ type: "corrected",
480
+ utterance,
481
+ raw: final.text,
482
+ text: final.text,
483
+ decisions: [],
484
+ steps: [...steps, `correction failed: ${message} · raw text kept`],
485
+ timings: { transcribeMs, totalMs: Date.now() - started },
486
+ });
487
+ }
488
+ };
489
+ const pending = new Set();
490
+ const track = (p) => {
491
+ pending.add(p);
492
+ void p.finally(() => pending.delete(p));
493
+ };
494
+ let aborted = false;
495
+ let utterances = 0;
496
+ let failure;
497
+ // An explicit iterator so the consumer's exit can close the source (the
498
+ // native handler stream or the chunked adapter) instead of leaving it
499
+ // running on a live audio feed that never ends by itself.
500
+ const iterator = source[Symbol.asyncIterator]();
501
+ const producer = (async () => {
502
+ try {
503
+ for (;;) {
504
+ const { value: ev, done } = await iterator.next();
505
+ if (done || aborted) {
506
+ break;
507
+ }
508
+ if (ev.type === "final") {
509
+ utterances++;
510
+ out.push(ev);
511
+ const preSteps = ev.fallbackUsed
512
+ ? [`${provider} unsure → ${ev.engine} took over`]
513
+ : [];
514
+ if (correcting && !aborted) {
515
+ track(takeSecondOpinion(ev.utterance).then((alt) => aborted ? undefined : correct(ev, preSteps, alt)));
516
+ }
517
+ else {
518
+ secondOpinions.delete(ev.utterance);
519
+ }
520
+ }
521
+ else if (ev.type === "silence" && secondOpinions.has(ev.utterance)) {
522
+ // The primary heard nothing; the second engine may have. Its text
523
+ // becomes the final rather than letting the utterance go silent.
524
+ track(takeSecondOpinion(ev.utterance).then(async (alt) => {
525
+ if (!alt?.text || !secondProvider) {
526
+ out.push(ev);
527
+ return;
528
+ }
529
+ utterances++;
530
+ const final = {
531
+ type: "final",
532
+ utterance: ev.utterance,
533
+ text: alt.text,
534
+ segment: {
535
+ index: ev.utterance,
536
+ text: alt.text,
537
+ isFinal: true,
538
+ },
539
+ engine: secondProvider,
540
+ seconds: ev.seconds,
541
+ };
542
+ out.push(final);
543
+ await correct(final, [`${secondProvider} text used (${provider} returned nothing)`], undefined);
544
+ }));
545
+ }
546
+ else {
547
+ out.push(ev);
548
+ }
549
+ }
550
+ }
551
+ catch (err) {
552
+ failure = err;
553
+ out.push({
554
+ type: "error",
555
+ message: err instanceof Error ? err.message : String(err),
556
+ recoverable: false,
557
+ });
558
+ }
559
+ finally {
560
+ while (pending.size > 0) {
561
+ await Promise.allSettled([...pending]);
562
+ }
563
+ out.close();
564
+ }
565
+ })();
566
+ try {
567
+ yield* out.drain();
568
+ await producer;
569
+ }
570
+ finally {
571
+ aborted = true;
572
+ // Close the source; a generator parked on an `await` (waiting for the
573
+ // next frame) only honours this once that await settles, so it is not
574
+ // awaited here — the flag above already stops every later step.
575
+ void iterator.return?.().catch(() => undefined);
576
+ const ended = SpanSerializer.endSpan(span, failure === undefined ? SpanStatus.OK : SpanStatus.ERROR, failure === undefined
577
+ ? undefined
578
+ : failure instanceof Error
579
+ ? failure.message
580
+ : String(failure));
581
+ ended.attributes = { ...ended.attributes, "stt.utterances": utterances };
582
+ getMetricsAggregator().recordSpan(ended);
583
+ }
584
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Whistle assets: the pinned manifest, where the files live, and the
3
+ * first-use download.
4
+ *
5
+ * Three files make up the engine — the Cactus "needle" runtime (an Emscripten
6
+ * build: `needle.js` + `needle.wasm`) and the Whistle speech model
7
+ * (`whistle.cact`). They are fetched from pinned Hugging Face revisions on
8
+ * first use, verified against a SHA-256, and written atomically (temp file +
9
+ * rename), so an interrupted download never leaves a file that looks complete.
10
+ *
11
+ * Both upstream repositories are Apache-2.0.
12
+ *
13
+ * @module voice/whistle/assets
14
+ */
15
+ import type { STTCredentials, WhistleAsset, WhistleAssetPaths } from "../../types/index.js";
16
+ /** Pinned files, in the order they are fetched. */
17
+ export declare const WHISTLE_ASSETS: readonly WhistleAsset[];
18
+ /**
19
+ * Where Whistle keeps its files: `slice.modelDir` → `NEUROLINK_WHISTLE_DIR` →
20
+ * `NEUROLINK_MODEL_DIR/whistle` → the copy shipped with the package
21
+ * (`models/whistle`, when all three files are there) →
22
+ * `~/.neurolink/models/whistle`, the only one a download ever writes to.
23
+ */
24
+ export declare function resolveWhistleDir(slice?: STTCredentials["whistle"]): string;
25
+ /**
26
+ * The package's own `models/whistle`, found by walking up from this module
27
+ * to the directory holding NeuroLink's `package.json`. Works from the source
28
+ * tree, from `dist/` and from the single-file bundles, since all of them sit
29
+ * below the package root; `null` when nothing above looks like the package.
30
+ */
31
+ export declare function bundledWhistleDir(): string | null;
32
+ /**
33
+ * Whether missing files may be fetched: on unless `slice.autoDownload` is
34
+ * `false` or `NEUROLINK_WHISTLE_AUTO_DOWNLOAD` is `0` / `false` / `off` / `no`.
35
+ */
36
+ export declare function whistleAutoDownloadEnabled(slice?: STTCredentials["whistle"]): boolean;
37
+ /** Cheap presence check (no hashing): all three files exist in `dir`. */
38
+ export declare function whistleAssetsPresent(dir: string): boolean;
39
+ /**
40
+ * Make sure all three files are in `dir` and match their pinned hashes,
41
+ * downloading what is missing when allowed. Concurrent calls for the same
42
+ * directory share one preparation.
43
+ */
44
+ export declare function ensureWhistleAssets(dir: string, autoDownload: boolean): Promise<WhistleAssetPaths>;
45
+ /** The friendly "here is exactly what to put where" error. */
46
+ /** The three files, where they go and where they come from — the body of every "unusable" message. */
47
+ export declare function describeWhistleAssets(dir: string, reason: string): string;