@juspay/neurolink 13.7.0 → 13.8.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 (81) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +480 -460
  3. package/dist/cli/commands/serve.js +11 -1
  4. package/dist/cli/commands/transcribe.js +39 -3
  5. package/dist/constants/langfuse.d.ts +2 -0
  6. package/dist/constants/langfuse.js +2 -0
  7. package/dist/factories/sttDescriptors.js +7 -0
  8. package/dist/index.d.ts +3 -2
  9. package/dist/index.js +4 -2
  10. package/dist/neurolink.js +33 -1
  11. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  12. package/dist/observability/exporters/langfuseExporter.js +29 -8
  13. package/dist/observability/otlpEncoding.js +2 -2
  14. package/dist/server/abstract/baseServerAdapter.js +2 -0
  15. package/dist/server/adapters/expressAdapter.js +1 -1
  16. package/dist/server/adapters/fastifyAdapter.js +4 -1
  17. package/dist/server/adapters/honoAdapter.js +1 -1
  18. package/dist/server/adapters/koaAdapter.js +1 -1
  19. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  20. package/dist/server/routes/transcribeRoutes.js +130 -3
  21. package/dist/server/utils/validation.d.ts +409 -15
  22. package/dist/server/utils/validation.js +106 -4
  23. package/dist/server/websocket/frameQueue.d.ts +16 -0
  24. package/dist/server/websocket/frameQueue.js +55 -0
  25. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  26. package/dist/server/websocket/realtimeTranscription.js +603 -0
  27. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  28. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  29. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  30. package/dist/types/cli.d.ts +6 -0
  31. package/dist/types/exporter.d.ts +3 -1
  32. package/dist/types/server.d.ts +304 -2
  33. package/dist/types/stt.d.ts +38 -3
  34. package/dist/types/transcribe.d.ts +401 -5
  35. package/dist/voice/audioLevel.d.ts +14 -0
  36. package/dist/voice/audioLevel.js +60 -0
  37. package/dist/voice/codeMix.d.ts +40 -0
  38. package/dist/voice/codeMix.js +282 -0
  39. package/dist/voice/correction/align.d.ts +12 -0
  40. package/dist/voice/correction/align.js +48 -0
  41. package/dist/voice/correction/guards.d.ts +39 -0
  42. package/dist/voice/correction/guards.js +122 -0
  43. package/dist/voice/correction/index.d.ts +25 -3
  44. package/dist/voice/correction/index.js +132 -4
  45. package/dist/voice/correction/rewrite.d.ts +64 -3
  46. package/dist/voice/correction/rewrite.js +295 -25
  47. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  48. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  49. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  50. package/dist/voice/eval/sttEvalStore.js +397 -0
  51. package/dist/voice/index.js +3 -0
  52. package/dist/voice/languages.d.ts +34 -0
  53. package/dist/voice/languages.js +290 -0
  54. package/dist/voice/modelAssets.d.ts +28 -0
  55. package/dist/voice/modelAssets.js +113 -0
  56. package/dist/voice/output/render.d.ts +32 -0
  57. package/dist/voice/output/render.js +368 -0
  58. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  59. package/dist/voice/providers/OpenAISTT.js +57 -6
  60. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  61. package/dist/voice/session/transcriptionSession.js +225 -0
  62. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  63. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  64. package/dist/voice/streaming/endpointer.d.ts +50 -17
  65. package/dist/voice/streaming/endpointer.js +115 -19
  66. package/dist/voice/transcribe.d.ts +18 -1
  67. package/dist/voice/transcribe.js +459 -18
  68. package/dist/voice/transcribeStream.d.ts +18 -1
  69. package/dist/voice/transcribeStream.js +413 -16
  70. package/dist/voice/vad/assets.d.ts +36 -0
  71. package/dist/voice/vad/assets.js +142 -0
  72. package/dist/voice/vad/frameScorer.d.ts +28 -0
  73. package/dist/voice/vad/frameScorer.js +90 -0
  74. package/dist/voice/vad/sileroVad.d.ts +43 -0
  75. package/dist/voice/vad/sileroVad.js +178 -0
  76. package/dist/voice/whistle/assets.d.ts +3 -4
  77. package/dist/voice/whistle/assets.js +11 -95
  78. package/docs-site/static/search-index.json +21 -6
  79. package/models/silero-vad/README.md +19 -0
  80. package/models/silero-vad/silero_vad.onnx +0 -0
  81. package/package.json +3 -1
@@ -23,9 +23,13 @@
23
23
  * @module voice/transcribeStream
24
24
  */
25
25
  import { logger } from "../utils/logger.js";
26
+ import { collapseRepetitions, isPromptEcho, loopShare, } from "./correction/guards.js";
26
27
  import { SpanSerializer, SpanStatus, SpanType, getMetricsAggregator, } from "../observability/index.js";
27
28
  import { createWavFile } from "./audio-utils.js";
28
- import { correctTranscript, isCorrectionEnabled } from "./correction/index.js";
29
+ import { normalizeSpeechLevel } from "./audioLevel.js";
30
+ import { normalizeLanguageCode } from "./languages.js";
31
+ import { outputLanguagesOf, renderOutputs } from "./output/render.js";
32
+ import { correctTranscript, correctTranscriptSentences, isCorrectionEnabled, } from "./correction/index.js";
29
33
  import { StreamEventQueue, chunkedTranscribeStream, } from "./streaming/chunkedStreamAdapter.js";
30
34
  import { ENDPOINTER_DEFAULTS, ENGINE_SAMPLE_RATE, } from "./streaming/endpointer.js";
31
35
  import { createLocalAgreement, resetLocalAgreement, splitWords, updateLocalAgreement, } from "./streaming/localAgreement.js";
@@ -40,6 +44,9 @@ const NON_STT_KEYS = new Set([
40
44
  "dictionary",
41
45
  "correction",
42
46
  "fallback",
47
+ "routing",
48
+ "chunking",
49
+ "output",
43
50
  "streaming",
44
51
  "credentials",
45
52
  ]);
@@ -59,6 +66,100 @@ export function encodeWav16k(pcm) {
59
66
  * dictionary terms as vocabulary and appended to the prompt, `"auto"`
60
67
  * language left to the engine.
61
68
  */
69
+ /** The words an engine is prompted with (dictionary terms, the caller's vocabulary, the fixed phrasing). */
70
+ export function promptTermsOf(options) {
71
+ return [
72
+ ...(options.vocabulary ?? []),
73
+ ...(options.dictionary ?? []).flatMap((d) => [
74
+ d.term,
75
+ ...(d.heardAs ?? []),
76
+ ]),
77
+ "Names and terms used",
78
+ ].filter((t) => typeof t === "string" && t.trim().length > 0);
79
+ }
80
+ function wordCount(text) {
81
+ return text.split(/\s+/u).filter(Boolean).length;
82
+ }
83
+ /**
84
+ * Engine output through the guards: a looping phrase is collapsed and the
85
+ * share of words it took is recorded on `metadata.loopShare`; an output
86
+ * that is only the prompt echoed back becomes empty. `note` receives a
87
+ * human-readable line for each guard that fired.
88
+ */
89
+ export function guardEngineResult(res, promptTerms, note) {
90
+ const original = res.text ?? "";
91
+ if (!original.trim()) {
92
+ return res;
93
+ }
94
+ if (isPromptEcho(original, promptTerms)) {
95
+ note("prompt echo discarded");
96
+ return {
97
+ ...res,
98
+ text: "",
99
+ words: undefined,
100
+ segments: undefined,
101
+ metadata: {
102
+ ...(res.metadata ?? { latency: 0 }),
103
+ hallucination: "prompt-echo",
104
+ },
105
+ };
106
+ }
107
+ const collapsed = collapseRepetitions(original);
108
+ if (collapsed.removed === 0) {
109
+ return res;
110
+ }
111
+ const share = loopShare(collapsed.removed, wordCount(original));
112
+ note(`repetition collapsed (${collapsed.removed} words)`);
113
+ const metadata = {
114
+ ...(res.metadata ?? { latency: 0 }),
115
+ loopShare: share,
116
+ };
117
+ if (!res.segments || res.segments.length <= 1) {
118
+ return {
119
+ ...res,
120
+ text: collapsed.text,
121
+ // Word timings no longer line up with the collapsed text.
122
+ words: undefined,
123
+ segments: res.segments?.length
124
+ ? [{ ...res.segments[0], text: collapsed.text }]
125
+ : undefined,
126
+ metadata,
127
+ };
128
+ }
129
+ // Many segments (a long, diarized file): collapse each segment on its own,
130
+ // so speaker turns and timings survive, and drop word timings only inside
131
+ // the segments whose text changed.
132
+ const changed = [];
133
+ const segments = res.segments.map((segment) => {
134
+ const own = collapseRepetitions(segment.text);
135
+ if (own.removed === 0) {
136
+ return segment;
137
+ }
138
+ changed.push({
139
+ start: segment.start ?? Number.NEGATIVE_INFINITY,
140
+ end: segment.end ?? Number.POSITIVE_INFINITY,
141
+ });
142
+ return { ...segment, text: own.text };
143
+ });
144
+ if (changed.length === 0) {
145
+ // The loop ran across segment boundaries: keep the segments as the
146
+ // engine wrote them, and only the joined text collapsed.
147
+ return { ...res, text: collapsed.text, metadata };
148
+ }
149
+ const words = res.words?.filter((w) => {
150
+ const { start, end } = w;
151
+ return (start === undefined ||
152
+ end === undefined ||
153
+ !changed.some((c) => start >= c.start && end <= c.end));
154
+ });
155
+ return {
156
+ ...res,
157
+ text: segments.map((s) => s.text).join(" "),
158
+ segments,
159
+ words,
160
+ metadata,
161
+ };
162
+ }
62
163
  function buildEngineOptions(options, model, audio) {
63
164
  const passthrough = {};
64
165
  for (const [key, value] of Object.entries(options)) {
@@ -87,6 +188,14 @@ function buildEngineOptions(options, model, audio) {
87
188
  sampleRate: audio.sampleRate,
88
189
  };
89
190
  }
191
+ /**
192
+ * A final's language when it can be trusted: an unsure verdict ("hi?", the
193
+ * Indic engine's fallback label on speech it could not place) must never
194
+ * decide a rewrite's rules or make a rendering look like the same language.
195
+ */
196
+ function confidentLanguage(final) {
197
+ return final.languageDetected === false ? undefined : final.language;
198
+ }
90
199
  /** Uint8Array frames → Buffers (no copy), reporting each frame as it is handed over. */
91
200
  async function* asBuffers(frames, onFrame) {
92
201
  for await (const f of frames) {
@@ -365,7 +474,16 @@ export async function* runTranscribeStream(options, deps) {
365
474
  ? resolveOptional(options.fallback.provider, "fallback")
366
475
  : undefined;
367
476
  const correcting = isCorrectionEnabled(options.dictionary, options.correction);
368
- const secondSpec = correcting ? options.correction?.secondOpinion : undefined;
477
+ const secondSetting = correcting
478
+ ? options.correction?.secondOpinion
479
+ : undefined;
480
+ // "auto": the fallback engine reads every utterance the primary leads, so
481
+ // the rewrite can reconcile both readings of code-mixed speech.
482
+ const secondSpec = secondSetting === "auto"
483
+ ? options.fallback
484
+ ? { provider: options.fallback.provider, model: options.fallback.model }
485
+ : undefined
486
+ : secondSetting;
369
487
  const secondProvider = !useNative && secondSpec
370
488
  ? resolveOptional(secondSpec.provider, "second-opinion")
371
489
  : undefined;
@@ -373,16 +491,174 @@ export async function* runTranscribeStream(options, deps) {
373
491
  const primaryOpts = buildEngineOptions(options, options.model, wavAudio);
374
492
  const fallbackOpts = buildEngineOptions(options, options.fallback?.model, wavAudio);
375
493
  const secondOpts = buildEngineOptions(options, secondSpec?.model, wavAudio);
494
+ const otherOpts = buildEngineOptions(options, undefined, wavAudio);
495
+ // Language routing: which provider transcribes which language.
496
+ const routes = new Map();
497
+ const pinnedLanguage = normalizeLanguageCode(options.language) !== undefined;
498
+ if (!useNative && !pinnedLanguage) {
499
+ for (const [lang, name] of Object.entries(options.routing?.engines ?? {})) {
500
+ const key = lang.trim() === "*" ? "*" : normalizeLanguageCode(lang);
501
+ const resolved = key && typeof name === "string" && name.trim()
502
+ ? resolveOptional(name, `routing (${lang})`)
503
+ : undefined;
504
+ if (key && resolved) {
505
+ routes.set(key, resolved);
506
+ }
507
+ }
508
+ }
509
+ const optionsFor = (name) => name === provider
510
+ ? primaryOpts
511
+ : name === fallbackProvider
512
+ ? fallbackOpts
513
+ : otherOpts;
514
+ const normalize = options.streaming?.normalize !== false;
515
+ const levelled = (pcm) => normalize ? normalizeSpeechLevel(pcm) : pcm;
376
516
  // Per-utterance bookkeeping shared by the adapter hooks and the correction tasks.
377
517
  const finalPassMs = new Map();
378
518
  const endedAt = new Map();
379
519
  const secondOpinions = new Map();
380
- const run = async (pcm, pass) => {
381
- const wav = encodeWav16k(pcm);
520
+ // Words the engines were prompted with: an output made only of these is
521
+ // the prompt echoed back on a pause, not speech.
522
+ const promptTerms = promptTermsOf(options);
523
+ const guardNotes = new Map();
524
+ const noteGuard = (utterance, note) => {
525
+ const notes = guardNotes.get(utterance) ?? [];
526
+ if (!notes.includes(note)) {
527
+ notes.push(note);
528
+ }
529
+ guardNotes.set(utterance, notes);
530
+ };
531
+ // `fileModeStreamOptions` turns interim passes off with an infinite cadence;
532
+ // a live caller cannot send one (JSON has no Infinity).
533
+ const fileMode = options.streaming?.intervalMs === Number.POSITIVE_INFINITY;
534
+ const callEngine = (wav, engine, prior, hint) => engine === "fallback" && fallbackProvider
535
+ ? deps.transcribe(wav, fallbackProvider, hint ? { ...fallbackOpts, language: hint } : fallbackOpts)
536
+ : deps.transcribe(wav, provider, prior ? { ...primaryOpts, languagePrior: prior } : primaryOpts);
537
+ /** The utterance as two halves, joined: what an engine that choked on the whole gets instead. */
538
+ const runHalves = async (pcm, pass) => {
539
+ const mid = Math.floor(pcm.length / 2);
540
+ const halves = await Promise.all([
541
+ callEngine(encodeWav16k(pcm.subarray(0, mid)), pass.engine, pass.languagePrior, pass.languageHint),
542
+ callEngine(encodeWav16k(pcm.subarray(mid)), pass.engine, pass.languagePrior, pass.languageHint),
543
+ ]);
544
+ return guardEngineResult({
545
+ ...halves[0],
546
+ text: [halves[0].text, halves[1].text]
547
+ .filter((t) => t.trim())
548
+ .join(" "),
549
+ words: undefined,
550
+ segments: undefined,
551
+ }, promptTerms, () => undefined);
552
+ };
553
+ /** Utterances handed to another engine by routing: later passes go straight to it. */
554
+ const routedUtterances = new Map();
555
+ /** One pass on a routed engine, tagged with it; `undefined` when it heard nothing or failed. */
556
+ const runRouted = async (pcm, pass, target, lang, fallbackLanguage) => {
557
+ try {
558
+ const alt = guardEngineResult(await deps.transcribe(encodeWav16k(pcm), target, {
559
+ ...optionsFor(target),
560
+ languagePrior: lang,
561
+ }), promptTerms, (note) => noteGuard(pass.utterance, note));
562
+ if (alt.text.trim() && alt.metadata?.languageDetected !== false) {
563
+ return {
564
+ ...alt,
565
+ language: alt.language ?? fallbackLanguage,
566
+ metadata: {
567
+ ...(alt.metadata ?? { latency: 0 }),
568
+ routedProvider: target,
569
+ },
570
+ };
571
+ }
572
+ noteGuard(pass.utterance, `routing ${lang} → ${target} heard nothing`);
573
+ }
574
+ catch (error) {
575
+ noteGuard(pass.utterance, `routing ${lang} → ${target} failed (${error instanceof Error ? error.message : String(error)})`);
576
+ }
577
+ return undefined;
578
+ };
579
+ /**
580
+ * A pass whose detected language belongs to another engine is read again
581
+ * by that engine, and the rest of the utterance goes to it; its text is
582
+ * used when it heard words and reads the language. Any failure keeps the
583
+ * text already in hand.
584
+ */
585
+ const routeFinal = async (res, pcm, pass) => {
586
+ if (routes.size === 0) {
587
+ return res;
588
+ }
589
+ const lang = normalizeLanguageCode(res.language);
590
+ if (!lang) {
591
+ return res;
592
+ }
593
+ const target = routes.get(lang) ?? routes.get("*");
594
+ const used = pass.engine === "fallback" && fallbackProvider
595
+ ? fallbackProvider
596
+ : provider;
597
+ if (!target || target === used) {
598
+ return res;
599
+ }
600
+ const alt = await runRouted(pcm, pass, target, lang, res.language);
601
+ if (!alt) {
602
+ return res;
603
+ }
604
+ noteGuard(pass.utterance, `routed ${lang} → ${target}`);
605
+ routedUtterances.set(pass.utterance, { target, lang });
606
+ return alt;
607
+ };
608
+ const run = async (rawPcm, pass) => {
382
609
  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);
610
+ const pcm = levelled(rawPcm);
611
+ const routed = routedUtterances.get(pass.utterance);
612
+ if (routed) {
613
+ const direct = await runRouted(pcm, pass, routed.target, routed.lang, routed.lang);
614
+ if (direct) {
615
+ if (pass.final) {
616
+ finalPassMs.set(pass.utterance, Date.now() - t0);
617
+ }
618
+ return direct;
619
+ }
620
+ }
621
+ let res;
622
+ try {
623
+ res = await callEngine(encodeWav16k(pcm), pass.engine, pass.languagePrior, pass.languageHint);
624
+ }
625
+ catch (error) {
626
+ // A final the engine could not finish (a decoding loop that ran into
627
+ // the timeout, most often) is tried as two halves before it is given
628
+ // up: a loop is a property of the chunk, not of the speech, and a
629
+ // retry of the same audio would only stall as long again.
630
+ if (!pass.final || pcm.length < ENGINE_SAMPLE_RATE * 4) {
631
+ throw error;
632
+ }
633
+ let halves;
634
+ try {
635
+ halves = await runHalves(pcm, pass);
636
+ }
637
+ catch {
638
+ throw error;
639
+ }
640
+ noteGuard(pass.utterance, `engine failed on the whole utterance (${error instanceof Error ? error.message : String(error)}) · re-run as two halves`);
641
+ if (pass.final) {
642
+ finalPassMs.set(pass.utterance, Date.now() - t0);
643
+ }
644
+ return halves;
645
+ }
646
+ res = guardEngineResult(res, promptTerms, (note) => noteGuard(pass.utterance, note));
647
+ // A final that was mostly a loop is, on a file, worth a second attempt
648
+ // as two halves: the loop is a property of the chunk, not the speech.
649
+ const loop = res.metadata?.loopShare;
650
+ if (fileMode &&
651
+ pass.final &&
652
+ typeof loop === "number" &&
653
+ loop >= 0.5 &&
654
+ pcm.length >= ENGINE_SAMPLE_RATE * 4) {
655
+ const joined = await runHalves(pcm, pass).catch(() => undefined);
656
+ if (joined && wordCount(joined.text) > wordCount(res.text)) {
657
+ noteGuard(pass.utterance, "loop: utterance re-run as two halves");
658
+ res = joined;
659
+ }
660
+ }
661
+ res = await routeFinal(res, pcm, pass);
386
662
  if (pass.final) {
387
663
  finalPassMs.set(pass.utterance, Date.now() - t0);
388
664
  }
@@ -398,6 +674,7 @@ export async function* runTranscribeStream(options, deps) {
398
674
  ...options.streaming,
399
675
  sampleRate: inputRate,
400
676
  engineLabel: provider,
677
+ ...(options.routing ? { routing: options.routing } : {}),
401
678
  ...(fallbackProvider
402
679
  ? {
403
680
  fallback: {
@@ -415,7 +692,7 @@ export async function* runTranscribeStream(options, deps) {
415
692
  if (!secondProvider || engine !== "primary") {
416
693
  return;
417
694
  }
418
- secondOpinions.set(utterance, runSecondOpinion(deps, pcm, secondProvider, secondOpts, utterance));
695
+ secondOpinions.set(utterance, runSecondOpinion(deps, levelled(pcm), secondProvider, secondOpts, utterance));
419
696
  },
420
697
  });
421
698
  const takeSecondOpinion = async (utterance) => {
@@ -423,6 +700,72 @@ export async function* runTranscribeStream(options, deps) {
423
700
  secondOpinions.delete(utterance);
424
701
  return p ? await p : undefined;
425
702
  };
703
+ // Corrected utterances, oldest first: the rewrite's read-only context.
704
+ const contextUtterances = Math.max(0, Math.floor(options.correction?.contextUtterances ?? 0));
705
+ const recentCorrected = [];
706
+ const previousFor = (utterance) => contextUtterances > 0
707
+ ? recentCorrected
708
+ .filter((r) => r.utterance < utterance)
709
+ .slice(-contextUtterances)
710
+ .map((r) => r.text)
711
+ : undefined;
712
+ // Output languages: every utterance rendered once into the languages in
713
+ // effect when it settles, with earlier utterances as context.
714
+ const outputContext = Math.max(0, Math.floor(options.output?.contextUtterances ?? 3));
715
+ const outputHistory = [];
716
+ const emitOutputs = async (utterance, source, corrected, language, basedOn) => {
717
+ const languages = outputLanguagesOf(options.output);
718
+ if (languages.length === 0 || aborted || !source.trim()) {
719
+ return;
720
+ }
721
+ const rendered = await renderOutputs({
722
+ source: [source],
723
+ ...(corrected && corrected !== source
724
+ ? { corrected: [corrected] }
725
+ : {}),
726
+ ...(language ? { language } : {}),
727
+ languages,
728
+ previous: outputContext > 0
729
+ ? outputHistory
730
+ .filter((h) => h.utterance < utterance)
731
+ .slice(-outputContext)
732
+ .map((h) => ({ text: h.text, outputs: h.outputs }))
733
+ : [],
734
+ ...(options.dictionary ? { dictionary: options.dictionary } : {}),
735
+ ...(options.output ? { options: options.output } : {}),
736
+ }, deps);
737
+ if (aborted) {
738
+ return;
739
+ }
740
+ const texts = {};
741
+ for (const tag of languages) {
742
+ const r = rendered[tag];
743
+ if (!r) {
744
+ continue;
745
+ }
746
+ if (r.text) {
747
+ texts[tag] = r.text;
748
+ }
749
+ out.push({
750
+ type: "output",
751
+ utterance,
752
+ language: tag,
753
+ text: r.text,
754
+ status: r.status,
755
+ basedOn,
756
+ ms: r.ms,
757
+ ...(r.reason ? { reason: r.reason } : {}),
758
+ });
759
+ }
760
+ outputHistory.push({
761
+ utterance,
762
+ text: corrected ?? source,
763
+ outputs: texts,
764
+ });
765
+ if (outputHistory.length > 20) {
766
+ outputHistory.shift();
767
+ }
768
+ };
426
769
  /** Correction of one final; pushes `correcting` partials and one `corrected`. */
427
770
  const correct = async (final, preSteps, alt) => {
428
771
  const utterance = final.utterance;
@@ -431,17 +774,39 @@ export async function* runTranscribeStream(options, deps) {
431
774
  finalPassMs.delete(utterance);
432
775
  endedAt.delete(utterance);
433
776
  const steps = [...preSteps];
434
- const secondOpinion = !final.fallbackUsed && alt?.text ? alt.text : undefined;
435
- if (secondSpec && !final.fallbackUsed && !useNative && !alt?.text) {
777
+ // The final's own alternative (both engines ran on an utterance whose
778
+ // language they disagreed on) is the better second reading: it is the
779
+ // engine that lost, whichever one led.
780
+ const alternative = secondSetting === "auto" && final.alternative?.text.trim()
781
+ ? final.alternative
782
+ : undefined;
783
+ const secondOpinion = alternative
784
+ ? alternative.text
785
+ : !final.fallbackUsed && alt?.text
786
+ ? alt.text
787
+ : undefined;
788
+ if (alternative) {
789
+ steps.push(`reconciled with ${alternative.engine}'s reading`);
790
+ }
791
+ if (alternative) {
792
+ // The step above already says which reading was reconciled.
793
+ }
794
+ else if (secondSpec && !final.fallbackUsed && !useNative && !alt?.text) {
436
795
  steps.push("second opinion returned nothing");
437
796
  }
797
+ else if (secondOpinion && secondProvider) {
798
+ steps.push(`second opinion ${secondProvider}`);
799
+ }
438
800
  // Nothing after the consumer has gone: no second opinion is reconciled
439
801
  // and no rewrite is paid for that nobody will read.
440
802
  if (aborted) {
441
803
  return;
442
804
  }
443
805
  try {
444
- const res = await correctTranscript({
806
+ const correctFn = options.correction?.perSentence === false
807
+ ? correctTranscript
808
+ : correctTranscriptSentences;
809
+ const res = await correctFn({
445
810
  text: final.text,
446
811
  ...(secondOpinion ? { secondOpinion } : {}),
447
812
  ...(options.dictionary ? { dictionary: options.dictionary } : {}),
@@ -449,6 +814,12 @@ export async function* runTranscribeStream(options, deps) {
449
814
  ...(final.languageDetected !== undefined
450
815
  ? { languageDetected: final.languageDetected }
451
816
  : {}),
817
+ ...(confidentLanguage(final)
818
+ ? { language: confidentLanguage(final) }
819
+ : {}),
820
+ ...(previousFor(utterance)?.length
821
+ ? { previous: previousFor(utterance) }
822
+ : {}),
452
823
  onPartial: (text) => {
453
824
  if (text) {
454
825
  out.push({ type: "correcting", utterance, text });
@@ -469,6 +840,12 @@ export async function* runTranscribeStream(options, deps) {
469
840
  totalMs: Date.now() - started,
470
841
  },
471
842
  });
843
+ const settled = res.text || final.text;
844
+ recentCorrected.push({ utterance, text: settled });
845
+ if (recentCorrected.length > 20) {
846
+ recentCorrected.shift();
847
+ }
848
+ await emitOutputs(utterance, final.text, settled, confidentLanguage(final), "corrected");
472
849
  }
473
850
  catch (err) {
474
851
  // correctTranscript keeps its own timeouts and fails open; this is the
@@ -484,6 +861,7 @@ export async function* runTranscribeStream(options, deps) {
484
861
  steps: [...steps, `correction failed: ${message} · raw text kept`],
485
862
  timings: { transcribeMs, totalMs: Date.now() - started },
486
863
  });
864
+ await emitOutputs(utterance, final.text, undefined, confidentLanguage(final), "final");
487
865
  }
488
866
  };
489
867
  const pending = new Set();
@@ -501,21 +879,40 @@ export async function* runTranscribeStream(options, deps) {
501
879
  const producer = (async () => {
502
880
  try {
503
881
  for (;;) {
504
- const { value: ev, done } = await iterator.next();
505
- if (done || aborted) {
882
+ const next = await iterator.next();
883
+ if (next.done || aborted) {
506
884
  break;
507
885
  }
886
+ const raw = next.value;
887
+ if (raw.type === "silence") {
888
+ routedUtterances.delete(raw.utterance);
889
+ }
890
+ // A final reused from a routed interim names the engine that wrote it.
891
+ const routedTo = raw.type === "final"
892
+ ? routedUtterances.get(raw.utterance)
893
+ : undefined;
894
+ const ev = raw.type === "final" && routedTo && !raw.fallbackUsed
895
+ ? { ...raw, engine: routedTo.target }
896
+ : raw;
508
897
  if (ev.type === "final") {
509
898
  utterances++;
899
+ routedUtterances.delete(ev.utterance);
510
900
  out.push(ev);
511
- const preSteps = ev.fallbackUsed
512
- ? [`${provider} unsure → ${ev.engine} took over`]
513
- : [];
901
+ const preSteps = [
902
+ ...(ev.fallbackUsed
903
+ ? [`${provider} unsure → ${ev.engine} took over`]
904
+ : []),
905
+ ...(guardNotes.get(ev.utterance) ?? []),
906
+ ];
907
+ guardNotes.delete(ev.utterance);
514
908
  if (correcting && !aborted) {
515
909
  track(takeSecondOpinion(ev.utterance).then((alt) => aborted ? undefined : correct(ev, preSteps, alt)));
516
910
  }
517
911
  else {
518
912
  secondOpinions.delete(ev.utterance);
913
+ if (!aborted) {
914
+ track(emitOutputs(ev.utterance, ev.text, undefined, confidentLanguage(ev), "final"));
915
+ }
519
916
  }
520
917
  }
521
918
  else if (ev.type === "silence" && secondOpinions.has(ev.utterance)) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Silero VAD model file: the pinned revision, where it lives and the
3
+ * first-use download. The same rules as Whistle's assets, for one file
4
+ * instead of three: an explicit directory, then `NEUROLINK_VAD_DIR`, then
5
+ * `NEUROLINK_MODEL_DIR/silero-vad`, then the copy shipped with the package
6
+ * (`models/silero-vad`), then `~/.neurolink/models/silero-vad`, the only
7
+ * directory a download ever writes to.
8
+ *
9
+ * Upstream: `snakers4/silero-vad`, MIT.
10
+ *
11
+ * @module voice/vad/assets
12
+ */
13
+ import type { LocalModelAsset, STTVadOptions } from "../../types/index.js";
14
+ /** The pinned model: Silero VAD v6.2, 2.3 MB, 8 and 16 kHz. */
15
+ export declare const SILERO_VAD_ASSET: LocalModelAsset;
16
+ /**
17
+ * Where the model lives: `options.modelDir` → `NEUROLINK_VAD_DIR` →
18
+ * `NEUROLINK_MODEL_DIR/silero-vad` → the copy shipped with the package →
19
+ * `~/.neurolink/models/silero-vad`.
20
+ */
21
+ export declare function resolveVadDir(options?: STTVadOptions): string;
22
+ /**
23
+ * Whether a missing model may be fetched: on unless `options.autoDownload`
24
+ * is `false` or `NEUROLINK_VAD_AUTO_DOWNLOAD` is `0` / `false` / `off` / `no`.
25
+ */
26
+ export declare function vadAutoDownloadEnabled(options?: STTVadOptions): boolean;
27
+ /** Cheap presence check (no hashing). */
28
+ export declare function vadModelPresent(dir: string): boolean;
29
+ /**
30
+ * Makes sure the model is in `dir` and matches its pinned hash, downloading
31
+ * it when missing and allowed. Resolves to the model's path. Concurrent
32
+ * calls for the same directory share one preparation.
33
+ */
34
+ export declare function ensureVadModel(dir: string, autoDownload: boolean): Promise<string>;
35
+ /** The "here is exactly what to put where" body of every "unusable" message. */
36
+ export declare function describeVadModel(dir: string, reason: string): string;