@juspay/neurolink 13.6.5 → 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 (87) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/dist/auth/sessionManager.js +8 -3
  3. package/dist/browser/neurolink.min.js +480 -460
  4. package/dist/cli/commands/serve.js +11 -1
  5. package/dist/cli/commands/transcribe.js +39 -3
  6. package/dist/constants/langfuse.d.ts +2 -0
  7. package/dist/constants/langfuse.js +2 -0
  8. package/dist/core/redisConversationMemoryManager.js +6 -16
  9. package/dist/factories/sttDescriptors.js +7 -0
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +4 -2
  12. package/dist/neurolink.js +33 -1
  13. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  14. package/dist/observability/exporters/langfuseExporter.js +29 -8
  15. package/dist/observability/otlpEncoding.js +2 -2
  16. package/dist/server/abstract/baseServerAdapter.js +2 -0
  17. package/dist/server/adapters/expressAdapter.js +1 -1
  18. package/dist/server/adapters/fastifyAdapter.js +4 -1
  19. package/dist/server/adapters/honoAdapter.js +1 -1
  20. package/dist/server/adapters/koaAdapter.js +1 -1
  21. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  22. package/dist/server/routes/transcribeRoutes.js +130 -3
  23. package/dist/server/utils/validation.d.ts +409 -15
  24. package/dist/server/utils/validation.js +106 -4
  25. package/dist/server/websocket/frameQueue.d.ts +16 -0
  26. package/dist/server/websocket/frameQueue.js +55 -0
  27. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  28. package/dist/server/websocket/realtimeTranscription.js +603 -0
  29. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  30. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  31. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  32. package/dist/telemetry/attributes.d.ts +1 -1
  33. package/dist/telemetry/attributes.js +84 -8
  34. package/dist/types/cli.d.ts +6 -0
  35. package/dist/types/exporter.d.ts +3 -1
  36. package/dist/types/server.d.ts +304 -2
  37. package/dist/types/stt.d.ts +38 -3
  38. package/dist/types/transcribe.d.ts +401 -5
  39. package/dist/utils/redis.d.ts +17 -0
  40. package/dist/utils/redis.js +31 -0
  41. package/dist/voice/audioLevel.d.ts +14 -0
  42. package/dist/voice/audioLevel.js +60 -0
  43. package/dist/voice/codeMix.d.ts +40 -0
  44. package/dist/voice/codeMix.js +282 -0
  45. package/dist/voice/correction/align.d.ts +12 -0
  46. package/dist/voice/correction/align.js +48 -0
  47. package/dist/voice/correction/guards.d.ts +39 -0
  48. package/dist/voice/correction/guards.js +122 -0
  49. package/dist/voice/correction/index.d.ts +25 -3
  50. package/dist/voice/correction/index.js +132 -4
  51. package/dist/voice/correction/rewrite.d.ts +64 -3
  52. package/dist/voice/correction/rewrite.js +295 -25
  53. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  54. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  55. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  56. package/dist/voice/eval/sttEvalStore.js +397 -0
  57. package/dist/voice/index.js +3 -0
  58. package/dist/voice/languages.d.ts +34 -0
  59. package/dist/voice/languages.js +290 -0
  60. package/dist/voice/modelAssets.d.ts +28 -0
  61. package/dist/voice/modelAssets.js +113 -0
  62. package/dist/voice/output/render.d.ts +32 -0
  63. package/dist/voice/output/render.js +368 -0
  64. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  65. package/dist/voice/providers/OpenAISTT.js +57 -6
  66. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  67. package/dist/voice/session/transcriptionSession.js +225 -0
  68. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  69. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  70. package/dist/voice/streaming/endpointer.d.ts +50 -17
  71. package/dist/voice/streaming/endpointer.js +115 -19
  72. package/dist/voice/transcribe.d.ts +18 -1
  73. package/dist/voice/transcribe.js +459 -18
  74. package/dist/voice/transcribeStream.d.ts +18 -1
  75. package/dist/voice/transcribeStream.js +413 -16
  76. package/dist/voice/vad/assets.d.ts +36 -0
  77. package/dist/voice/vad/assets.js +142 -0
  78. package/dist/voice/vad/frameScorer.d.ts +28 -0
  79. package/dist/voice/vad/frameScorer.js +90 -0
  80. package/dist/voice/vad/sileroVad.d.ts +43 -0
  81. package/dist/voice/vad/sileroVad.js +178 -0
  82. package/dist/voice/whistle/assets.d.ts +3 -4
  83. package/dist/voice/whistle/assets.js +11 -95
  84. package/docs-site/static/search-index.json +21 -6
  85. package/models/silero-vad/README.md +19 -0
  86. package/models/silero-vad/silero_vad.onnx +0 -0
  87. package/package.json +4 -1
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Client-side streaming for batch STT engines.
3
3
  *
4
- * PCM16LE frames → energy-gated utterances (see `endpointer.ts`) → the
4
+ * PCM16LE frames → utterances found by a speech detector (Silero VAD by
5
+ * default) or the energy gate (see `endpointer.ts`) → the
5
6
  * current utterance is re-transcribed every `intervalMs` → LocalAgreement
6
7
  * commits the prefix two passes agree on → a final pass when the utterance
7
8
  * ends. The adapter knows nothing about providers or correction: it calls
@@ -26,10 +27,37 @@
26
27
  */
27
28
  import { logger } from "../../utils/logger.js";
28
29
  import { STTError } from "../errors.js";
30
+ import { SpeechFrameScorer } from "../vad/frameScorer.js";
31
+ import { loadSileroDetector, resolveVadEngine, resolveVadThresholds, } from "../vad/sileroVad.js";
29
32
  import { Endpointer, ENGINE_SAMPLE_RATE, resolveEndpointerConfig, } from "./endpointer.js";
30
33
  import { createLocalAgreement, resetLocalAgreement, splitWords, updateLocalAgreement, } from "./localAgreement.js";
31
34
  /** Default cadence of interim passes. */
32
35
  const DEFAULT_INTERVAL_MS = 900;
36
+ /**
37
+ * With a speech detector, audio after the last speech block beyond this
38
+ * margin is not sent to the engine: the closing pause (750 ms of it on every
39
+ * utterance that ends in silence) is where a batch model invents endings.
40
+ */
41
+ const TRIM_TAIL_MS = 300;
42
+ /**
43
+ * Digital silence appended to every chunk an engine gets, so it always ends
44
+ * in a clean pause: a chunk cut off right after speech sent Qwen3-ASR into a
45
+ * decoding loop until its timeout (180 s on a file, the utterance lost);
46
+ * 200 ms of zeros was enough to stop it, this is the margin.
47
+ */
48
+ export const ENGINE_TAIL_PAD_MS = 400;
49
+ /**
50
+ * A short utterance whose text would mean more words per second of detected
51
+ * speech than anyone can say (fast speech is 5–6) is an engine filling a
52
+ * pause, not a sentence. Only judged while the detector counted under 1.5 s
53
+ * of speech, where the rate is meaningful and the loss, if ever wrong, is a
54
+ * word or two.
55
+ */
56
+ const MAX_WORDS_PER_SPEECH_SECOND = 12;
57
+ const WORD_RATE_GUARD_MAX_SPEECH_MS = 1500;
58
+ /** Logged once per process: streams fall back quietly after that. */
59
+ let detectorUnavailableLogged = false;
60
+ let autoDetectorRetryAt = 0;
33
61
  /** An interim pass needs at least this much audio the previous pass did not see. */
34
62
  const MIN_NEW_AUDIO_INTERIM_S = 0.8;
35
63
  /** Below this much new audio since the last pass, the final reuses the interim text. */
@@ -45,6 +73,98 @@ const MIN_NEW_AUDIO_FINAL_S = 0.4;
45
73
  const TAKEOVER_MIN_AUDIO_S = 3;
46
74
  const TAKEOVER_MIN_SPEECH_MS = 1500;
47
75
  const TAKEOVER_UNSURE_PASSES = 2;
76
+ /**
77
+ * Probe thresholds. The first pass of an utterance runs the primary and the
78
+ * fallback in parallel once there is this much audio and speech, and the
79
+ * utterance's engine is chosen from that pass: the fallback leads when the
80
+ * primary is unsure or empty and the fallback produced Latin-script words.
81
+ * Under these amounts even a parallel pass tells nothing (a language score
82
+ * is noise below ~1 s), so the probe waits for the next pass instead.
83
+ */
84
+ const PROBE_MIN_AUDIO_S = 0.8;
85
+ const PROBE_MIN_SPEECH_MS = 500;
86
+ /**
87
+ * Two verdicts on less audio than this only pick who leads for now: the
88
+ * Indic engine is unsure of nearly everything under ~2.5 s (a Hinglish
89
+ * sentence it later scores −0.10 reads as unsure at 1 s), so an early
90
+ * "English heard, primary unsure" must not lock or pin the utterance.
91
+ */
92
+ const AGREE_MIN_AUDIO_S = 2.5;
93
+ /**
94
+ * A primary that is "sure" while the fallback heard English shows its own
95
+ * text only on a strong score. Measured on the IndicConformer wrapper: Indian
96
+ * languages −0.01…−0.10, Hinglish −0.09…−0.14, Indian-accented English over
97
+ * the −0.18 cutoff on a short stream pass at −0.156 (and −0.35…−0.58 once
98
+ * the utterance is whole). Between the two, the fallback's text is shown and
99
+ * the final decides on the whole utterance.
100
+ */
101
+ const MIXED_PRIMARY_MIN_SCORE = -0.12;
102
+ /**
103
+ * "Unsure" is only evidence against an Indian language well below the
104
+ * cutoff: Telugu read −0.181 on a 2.3 s stream pass (the cutoff is −0.18)
105
+ * while English sits at −0.35 and lower. Between the two, the fallback's
106
+ * "English" (which Qwen3-ASR also says of Telugu, a language it does not
107
+ * know) neither settles nor pins the utterance.
108
+ */
109
+ const CLEAR_UNSURE_MAX_SCORE = -0.25;
110
+ /**
111
+ * Probes shorter than this do not count toward the utterance's score: a
112
+ * second of audio reads −0.3 for Hinglish and −0.9 for English alike.
113
+ * Longer ones are averaged, weighted by length, so one pass that dips (−0.153
114
+ * among Hinglish passes at −0.06…−0.11) cannot flip the utterance.
115
+ */
116
+ const SCORE_MIN_AUDIO_S = 1.5;
117
+ /** Indian languages: what an Indic primary engine is for; any other language belongs to the fallback. */
118
+ const INDIC_LANGUAGES = new Set([
119
+ "as",
120
+ "bn",
121
+ "brx",
122
+ "doi",
123
+ "gu",
124
+ "hi",
125
+ "kn",
126
+ "kok",
127
+ "ks",
128
+ "mai",
129
+ "ml",
130
+ "mni",
131
+ "mr",
132
+ "ne",
133
+ "or",
134
+ "pa",
135
+ "sa",
136
+ "sat",
137
+ "sd",
138
+ "ta",
139
+ "te",
140
+ "ur",
141
+ ]);
142
+ /** The ISO 639 part of an engine's language report, lower case; `undefined` for none or "und". */
143
+ function langCode(value) {
144
+ const code = value?.trim().toLowerCase().split(/[-_]/u)[0];
145
+ return code && code !== "und" && code !== "auto" ? code : undefined;
146
+ }
147
+ /** Share of the text's letters in an Indic script. */
148
+ function indicShare(text) {
149
+ return 1 - latinShare(text);
150
+ }
151
+ /** Letters outside the Latin blocks (Indic, Arabic, Thai, Burmese, CJK, Hangul). */
152
+ const NON_LATIN_LETTER = /[\u0900-\u0DFF\u0600-\u06FF\u0E00-\u0E7F\u1000-\u109F\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/gu;
153
+ const LATIN_LETTER = /[A-Za-z\u00C0-\u024F]/gu;
154
+ /** Share of the text's letters that are Latin; 1 for text with no letters at all. */
155
+ function latinShare(text) {
156
+ const latin = text.match(LATIN_LETTER)?.length ?? 0;
157
+ const other = text.match(NON_LATIN_LETTER)?.length ?? 0;
158
+ return latin + other === 0 ? 1 : latin / (latin + other);
159
+ }
160
+ /** Probe passes before an utterance's engine locks when the caller sets none. */
161
+ const DEFAULT_LOCK_AFTER_PASSES = 2;
162
+ /** How long auto mode uses the energy gate after the speech detector failed to load. */
163
+ const AUTO_DETECTOR_RETRY_MS = 10 * 60_000;
164
+ /** Below this share of speech blocks an utterance is mostly pause. */
165
+ const NEAR_SILENT_SPEECH_SHARE = 0.25;
166
+ /** An output this short on a mostly-pause utterance is a filler, not speech. */
167
+ const NEAR_SILENT_MAX_WORDS = 3;
48
168
  const DEFAULT_FALLBACK_WHEN = [
49
169
  "unsure",
50
170
  "empty",
@@ -101,10 +221,38 @@ class UtteranceRun {
101
221
  shown = "";
102
222
  switched = false;
103
223
  confidentOnce = false;
224
+ /** Passes on which the primary was confident; two veto the takeover for good, one only delays it. */
225
+ confidentPasses = 0;
104
226
  unsureN = 0;
105
227
  languageDetected;
106
228
  language;
107
229
  lastLanguageKey = "";
230
+ /** Probe passes run so far; the engine locks after `routing.lockAfterPasses`. */
231
+ probePasses = 0;
232
+ /** Probe passes on which the primary was confident / unsure, for the lock rule. */
233
+ confidentProbes = 0;
234
+ unsureProbes = 0;
235
+ locked = false;
236
+ /** The engines disagreed on the language: the final runs both on the whole utterance. */
237
+ lookAgain = false;
238
+ /** Consecutive probes (with enough audio) on which both verdicts agreed. */
239
+ agreedProbes = 0;
240
+ /** The fallback transcribes in this language from now on (the utterance was settled as it). */
241
+ fallbackPin;
242
+ /** The reading the probe did not show, from the latest probe: kept for the final when the engines disagreed. */
243
+ alternative;
244
+ /** The primary's language score on each probe of at least `SCORE_MIN_AUDIO_S`, with its audio length. */
245
+ primaryScores = [];
246
+ /** The primary's score over the utterance so far, weighted by audio; `undefined` before a long enough probe. */
247
+ get primaryScore() {
248
+ let sum = 0;
249
+ let weight = 0;
250
+ for (const { score, seconds } of this.primaryScores) {
251
+ sum += score * seconds;
252
+ weight += seconds;
253
+ }
254
+ return weight > 0 ? sum / weight : undefined;
255
+ }
108
256
  constructor(u) {
109
257
  this.u = u;
110
258
  }
@@ -139,6 +287,7 @@ function liftLanguage(res) {
139
287
  return {
140
288
  language: language ?? "und",
141
289
  detected: flag ?? true,
290
+ flagged: flag !== undefined,
142
291
  ...(scores.length ? { scores } : {}),
143
292
  };
144
293
  }
@@ -168,9 +317,28 @@ class ChunkedStreamSession {
168
317
  queue = new StreamEventQueue();
169
318
  runs = new Map();
170
319
  fallbackWhen;
320
+ probe;
321
+ /** Set once the primary has failed and the fallback took over, so the warning is logged once per stream. */
322
+ primaryFailureWarned = false;
323
+ lockAfterPasses;
324
+ carryPrior;
325
+ /** The last language an engine was confident about, carried into later utterances. */
326
+ sessionPrior;
327
+ /** Latin share of the last final with more than three words; the script the session speaks. */
328
+ sessionLatinShare;
171
329
  engineTail = Promise.resolve();
172
330
  busy = 0;
331
+ inFlight = 0;
332
+ slotWaiters = [];
333
+ /** Passes queued or running, settled ones removed; the end-of-input wait drains it. */
334
+ pendingPasses = new Set();
335
+ concurrency;
336
+ finalRetries;
173
337
  aborted = false;
338
+ /** Scores the stream for the endpointer when a speech detector is in use. */
339
+ scorer = null;
340
+ /** What found the utterances, as reported on events. */
341
+ vadName = "energy";
174
342
  constructor(run, options) {
175
343
  this.run = run;
176
344
  this.options = options;
@@ -186,6 +354,11 @@ class ChunkedStreamSession {
186
354
  this.fallbackWhen = options.fallback?.when?.length
187
355
  ? options.fallback.when
188
356
  : DEFAULT_FALLBACK_WHEN;
357
+ this.probe = !!options.fallback && options.routing?.probe !== false;
358
+ this.lockAfterPasses = Math.max(1, Math.floor(options.routing?.lockAfterPasses ?? DEFAULT_LOCK_AFTER_PASSES));
359
+ this.carryPrior = options.routing?.prior !== "none";
360
+ this.concurrency = Math.max(1, Math.floor(options.concurrency ?? 1));
361
+ this.finalRetries = Math.max(0, Math.floor(options.finalRetries ?? 0));
189
362
  }
190
363
  async *events(frames) {
191
364
  const iterator = frames[Symbol.asyncIterator]();
@@ -201,6 +374,27 @@ class ChunkedStreamSession {
201
374
  }
202
375
  }
203
376
  }
377
+ /**
378
+ * A mostly-pause utterance whose output is a word or two in a script the
379
+ * session has not spoken (a lone "不。" or "हाँ" on an English meeting):
380
+ * discarded as noise. Text in the session's own script is always kept.
381
+ */
382
+ nearSilentNoise(r, text, seconds) {
383
+ if (seconds <= 0 ||
384
+ r.u.speechMs / (seconds * 1000) >= NEAR_SILENT_SPEECH_SHARE) {
385
+ return false;
386
+ }
387
+ if (text.split(/\s+/u).filter(Boolean).length > NEAR_SILENT_MAX_WORDS) {
388
+ return false;
389
+ }
390
+ const share = latinShare(text);
391
+ const sessionLatin = this.sessionLatinShare;
392
+ // Unknown session script: only non-Latin fillers are suspect.
393
+ if (sessionLatin === undefined) {
394
+ return share < 0.5;
395
+ }
396
+ return Math.abs(share - sessionLatin) >= 0.5;
397
+ }
204
398
  label(role) {
205
399
  return role === "fallback"
206
400
  ? (this.options.fallback?.label ?? "fallback")
@@ -208,6 +402,13 @@ class ChunkedStreamSession {
208
402
  }
209
403
  /** Reads frames until the source ends, then finalizes and closes the queue. */
210
404
  async pump(iterator) {
405
+ const fatal = await this.initDetector();
406
+ if (fatal !== undefined) {
407
+ this.queue.push({ type: "error", message: fatal, recoverable: false });
408
+ this.queue.close();
409
+ void Promise.resolve(iterator.return?.()).catch(() => undefined);
410
+ return;
411
+ }
211
412
  let carry = null;
212
413
  let sourceError;
213
414
  try {
@@ -234,6 +435,9 @@ class ChunkedStreamSession {
234
435
  samples[s] = (v >= 0x8000 ? v - 0x10000 : v) / 0x8000;
235
436
  }
236
437
  carry = total % 2 === 1 ? byteAt(total - 1) : null;
438
+ if (this.scorer) {
439
+ await this.scoreFrames(samples);
440
+ }
237
441
  this.handle(this.endpointer.push(samples));
238
442
  this.maybeInterim();
239
443
  }
@@ -244,12 +448,12 @@ class ChunkedStreamSession {
244
448
  // Whatever was being said when the source ended (or failed) is still finalized.
245
449
  this.handle(this.endpointer.flush());
246
450
  try {
247
- // Every final is chained on the engine queue; wait until the chain is empty.
248
- let tail;
249
- do {
250
- tail = this.engineTail;
251
- await tail;
252
- } while (tail !== this.engineTail);
451
+ // Every final is on the engine queue; wait until nothing is queued or
452
+ // in flight. With several slots the passes are not a chain, so the
453
+ // set of pending passes is what is waited on, not the last one started.
454
+ while (this.pendingPasses.size > 0) {
455
+ await Promise.allSettled([...this.pendingPasses]);
456
+ }
253
457
  }
254
458
  catch {
255
459
  // Individual passes report their own errors; nothing to add here.
@@ -263,6 +467,90 @@ class ChunkedStreamSession {
263
467
  }
264
468
  this.queue.close();
265
469
  }
470
+ /**
471
+ * Chooses the speech detector for this stream: the caller's own, or Silero
472
+ * per `vad` / `NEUROLINK_STT_VAD`, or none (the energy gate). Returns the
473
+ * message of a fatal problem — a detector asked for by name that cannot
474
+ * load — or `undefined`.
475
+ */
476
+ async initDetector() {
477
+ const injected = this.options.speechDetector;
478
+ let detector = null;
479
+ if (injected) {
480
+ detector = injected;
481
+ }
482
+ else if (injected === undefined) {
483
+ const engine = resolveVadEngine(this.options.vad);
484
+ // In auto mode a failed load is not retried by every new stream (a
485
+ // model download can take minutes to time out); it is tried again
486
+ // after AUTO_DETECTOR_RETRY_MS.
487
+ const skipAuto = engine !== "silero" && Date.now() < autoDetectorRetryAt;
488
+ if (engine !== "energy" && !skipAuto) {
489
+ try {
490
+ detector = await loadSileroDetector(this.options.vad);
491
+ autoDetectorRetryAt = 0;
492
+ }
493
+ catch (err) {
494
+ if (engine === "silero") {
495
+ return errorMessage(err);
496
+ }
497
+ autoDetectorRetryAt = Date.now() + AUTO_DETECTOR_RETRY_MS;
498
+ if (!detectorUnavailableLogged) {
499
+ detectorUnavailableLogged = true;
500
+ logger.warn(`[transcribeStream] speech detector unavailable, streams use the energy gate: ${errorMessage(err)}`);
501
+ }
502
+ }
503
+ }
504
+ }
505
+ if (!detector) {
506
+ return undefined;
507
+ }
508
+ const scorer = new SpeechFrameScorer(detector, this.inputRate);
509
+ this.scorer = scorer;
510
+ this.vadName = detector.name;
511
+ this.endpointer.attachSpeech({
512
+ ...resolveVadThresholds(this.options.vad),
513
+ probabilityAt: (seconds) => scorer.probabilityAt(seconds),
514
+ });
515
+ return undefined;
516
+ }
517
+ /** Scores the frames these samples complete; a detector that breaks hands the stream to the energy gate. */
518
+ async scoreFrames(samples) {
519
+ const scorer = this.scorer;
520
+ if (!scorer) {
521
+ return;
522
+ }
523
+ try {
524
+ await scorer.push(samples);
525
+ }
526
+ catch (err) {
527
+ logger.warn(`[transcribeStream] speech detector failed (${errorMessage(err)}); the energy gate takes over`);
528
+ this.scorer = null;
529
+ this.vadName = "energy";
530
+ this.endpointer.attachSpeech(null);
531
+ }
532
+ }
533
+ /** How an utterance's audio is cut for the engine: the closing pause trimmed when a detector marks the speech, a silence pad always. */
534
+ engineCut() {
535
+ return this.scorer
536
+ ? { trimTailMs: TRIM_TAIL_MS, padMs: ENGINE_TAIL_PAD_MS }
537
+ : { padMs: ENGINE_TAIL_PAD_MS };
538
+ }
539
+ /**
540
+ * More words per second of detected speech than anyone can say, on a short
541
+ * utterance: the engine filled a pause with a stock phrase.
542
+ */
543
+ impossibleWordRate(r, text) {
544
+ if (!this.scorer || !r.u.speech) {
545
+ return false;
546
+ }
547
+ const speechMs = r.u.speechMs;
548
+ if (speechMs <= 0 || speechMs > WORD_RATE_GUARD_MAX_SPEECH_MS) {
549
+ return false;
550
+ }
551
+ const n = text.split(/\s+/u).filter(Boolean).length;
552
+ return n / (speechMs / 1000) > MAX_WORDS_PER_SPEECH_SECOND;
553
+ }
266
554
  handle(events) {
267
555
  for (const ev of events) {
268
556
  if (ev.type === "start") {
@@ -282,13 +570,33 @@ class ChunkedStreamSession {
282
570
  }
283
571
  }
284
572
  }
285
- /** Runs `fn` after every queued engine call; at most one is in flight. */
573
+ /**
574
+ * Runs `fn` once a slot is free. Live streaming has one slot, so passes
575
+ * run in order and a caption never shows a stale pass; the file path sets
576
+ * `concurrency` higher and finals of different utterances overlap.
577
+ */
286
578
  withEngine(fn) {
287
579
  this.busy++;
288
- const next = this.engineTail.then(fn).finally(() => {
289
- this.busy--;
290
- });
580
+ const slots = this.concurrency;
581
+ const run = async () => {
582
+ while (this.inFlight >= slots) {
583
+ await new Promise((resolve) => this.slotWaiters.push(resolve));
584
+ }
585
+ this.inFlight++;
586
+ try {
587
+ await fn();
588
+ }
589
+ finally {
590
+ this.inFlight--;
591
+ this.busy--;
592
+ this.slotWaiters.shift()?.();
593
+ }
594
+ };
595
+ const next = slots > 1 ? run() : this.engineTail.then(run);
291
596
  this.engineTail = next.catch(() => undefined);
597
+ const tracked = next.catch(() => undefined);
598
+ this.pendingPasses.add(tracked);
599
+ void tracked.finally(() => this.pendingPasses.delete(tracked));
292
600
  return next;
293
601
  }
294
602
  maybeInterim() {
@@ -302,8 +610,10 @@ class ChunkedStreamSession {
302
610
  }
303
611
  // The cadence runs on the audio clock: a live source advances it in real
304
612
  // time, and a file replayed faster than real time gets the same passes
305
- // per second of audio, limited only by the one-in-flight rule.
306
- if (this.endpointer.audioSeconds - r.lastReqAt < this.intervalS) {
613
+ // per second of audio, limited only by the one-in-flight rule. An
614
+ // infinite cadence means no interim passes at all (the file path).
615
+ if (!Number.isFinite(this.intervalS) ||
616
+ this.endpointer.audioSeconds - r.lastReqAt < this.intervalS) {
307
617
  return;
308
618
  }
309
619
  if (u.length - r.sentLen < MIN_NEW_AUDIO_INTERIM_S * this.inputRate) {
@@ -315,7 +625,9 @@ class ChunkedStreamSession {
315
625
  }
316
626
  });
317
627
  }
318
- emitLanguage(r, lang) {
628
+ emitLanguage(r, lang,
629
+ /** Whether this verdict may become the stream's language (only settled ones may, in the fused path). */
630
+ carry = true) {
319
631
  if (!lang) {
320
632
  return;
321
633
  }
@@ -323,6 +635,10 @@ class ChunkedStreamSession {
323
635
  r.languageDetected = lang.detected;
324
636
  if (lang.detected) {
325
637
  r.confidentOnce = true;
638
+ r.confidentPasses++;
639
+ if (carry && lang.language && lang.language !== "und") {
640
+ this.sessionPrior = lang.language;
641
+ }
326
642
  }
327
643
  const key = `${lang.language}|${lang.detected}`;
328
644
  if (key === r.lastLanguageKey) {
@@ -337,13 +653,17 @@ class ChunkedStreamSession {
337
653
  ...(lang.scores ? { scores: lang.scores } : {}),
338
654
  });
339
655
  }
340
- async callEngine(r, pcm, final) {
656
+ async callEngine(r, pcm, final, engine = r.engine, pin = r.fallbackPin) {
341
657
  try {
342
658
  const res = await this.run(pcm, {
343
659
  utterance: r.u.id,
344
660
  final,
345
- engine: r.engine,
661
+ engine,
346
662
  seconds: r.u.length / this.inputRate,
663
+ ...(this.carryPrior && engine === "primary" && this.sessionPrior
664
+ ? { languagePrior: this.sessionPrior }
665
+ : {}),
666
+ ...(engine === "fallback" && pin ? { languageHint: pin } : {}),
347
667
  });
348
668
  return { res };
349
669
  }
@@ -351,6 +671,286 @@ class ChunkedStreamSession {
351
671
  return { err };
352
672
  }
353
673
  }
674
+ /**
675
+ * The utterance's engine and language from both engines' verdicts:
676
+ *
677
+ * - the fallback heard English (or any language outside India) and the
678
+ * primary is unsure: the fallback leads, its language is the label, and
679
+ * it is pinned to that language for the rest of the utterance;
680
+ * - the fallback heard English and the primary is confident: code-mixed
681
+ * speech (or an accent the primary over-reads); the primary leads, and
682
+ * the final looks again with both engines on the whole utterance;
683
+ * - the fallback heard an Indian language and the primary is confident:
684
+ * the primary leads, both agreed;
685
+ * - the primary is unsure, the stream has been speaking a language outside
686
+ * India, and the fallback names another one: the fallback is asked again
687
+ * in the stream's language (a short pass of accented English is where it
688
+ * drifts into Devanagari); still another script means a real switch;
689
+ * - the fallback heard an Indian language and the primary is unsure: the
690
+ * primary leads when it has words.
691
+ */
692
+ fuse(r, s) {
693
+ // IndicConformer's Hindi and Punjabi (or Marathi, Nepali) scores are
694
+ // near-ties on Hindi speech: Hindi within 0.05 of the top score is Hindi,
695
+ // which keeps Hinglish labelled Hindi and open to the fallback's verdict.
696
+ const topScore = s.primary?.scores?.[0]?.score;
697
+ const hindiScore = s.primary?.scores?.find((x) => langCode(x.language) === "hi")?.score;
698
+ const primaryLang = topScore !== undefined &&
699
+ hindiScore !== undefined &&
700
+ topScore - hindiScore <= 0.05
701
+ ? "hi"
702
+ : langCode(s.primary?.language);
703
+ // Sure means the engine said so: a result that carries no verdict at all
704
+ // (a gateway dropped the field) is not a confident one.
705
+ const primarySure = s.primary?.detected === true && s.primary.flagged && !!s.primaryText;
706
+ const passScore = s.primary?.scores?.[0]?.score;
707
+ if (passScore !== undefined &&
708
+ r.u.length / this.inputRate >= SCORE_MIN_AUDIO_S) {
709
+ r.primaryScores.push({
710
+ score: passScore,
711
+ seconds: r.u.length / this.inputRate,
712
+ });
713
+ }
714
+ const primaryScore = r.primaryScore ?? passScore;
715
+ // Clearly not an Indian language: no words at all, or a score far below
716
+ // the cutoff (an engine without scores keeps its own unsure verdict).
717
+ const clearlyUnsure = !s.primaryText ||
718
+ (!primarySure &&
719
+ (primaryScore === undefined || primaryScore <= CLEAR_UNSURE_MAX_SCORE));
720
+ const heardIndic = INDIC_LANGUAGES.has(s.heard);
721
+ // Before any confident verdict, a provisional pass treats the stream as
722
+ // English: a one-second answer in an Indic script from the fallback on
723
+ // audio the primary cannot place is a guess, and showing it is worse
724
+ // than romanized text the primary replaces once it is sure.
725
+ const provisional = r.u.length / this.inputRate < AGREE_MIN_AUDIO_S;
726
+ // Only for an Indian-language primary (the accent case this exists for):
727
+ // a Spanish utterance on Whisper must never be re-asked in English.
728
+ const indicPrimary = primaryLang !== undefined && INDIC_LANGUAGES.has(primaryLang);
729
+ const stream = this.sessionPrior ?? (provisional && indicPrimary ? "en" : undefined);
730
+ // A stream that has been speaking a language outside India, a primary
731
+ // that cannot place this audio, and a fallback naming another language:
732
+ // a short pass of accented speech is exactly where the fallback guesses
733
+ // (Hindi, even Chinese, for one second of Indian English). Ask it again
734
+ // in the stream's language; an answer still in another script means the
735
+ // speaker really switched.
736
+ // Only English: the accent drift this exists for. Re-asking in any other
737
+ // stream language would turn one wrong guess into a whole session's.
738
+ if (clearlyUnsure &&
739
+ stream === "en" &&
740
+ s.heard !== stream &&
741
+ s.fallbackText) {
742
+ return {
743
+ engine: "fallback",
744
+ language: stream,
745
+ agreed: false,
746
+ repin: stream,
747
+ why: `fallback heard ${s.heard} in a ${stream} stream, primary unsure: asked again in ${stream}`,
748
+ };
749
+ }
750
+ // Qwen3-ASR knows Hindi (and Urdu) among the Indian languages, nothing
751
+ // else: on Tamil it says English or even Malay. Its verdict is no
752
+ // evidence against a primary strongly sure of a language it cannot
753
+ // hear; the second opinion still reconciles any English in it.
754
+ const fallbackBlind = primarySure &&
755
+ primaryLang !== undefined &&
756
+ INDIC_LANGUAGES.has(primaryLang) &&
757
+ primaryLang !== "hi" &&
758
+ primaryLang !== "ur" &&
759
+ (primaryScore === undefined || primaryScore >= MIXED_PRIMARY_MIN_SCORE);
760
+ if (fallbackBlind) {
761
+ return {
762
+ engine: "primary",
763
+ language: primaryLang,
764
+ agreed: true,
765
+ why: `primary strongly sure of ${primaryLang}, a language the fallback cannot name (it said ${s.heard})`,
766
+ };
767
+ }
768
+ if (!heardIndic) {
769
+ if (!primarySure) {
770
+ return {
771
+ engine: s.fallbackText ? "fallback" : "primary",
772
+ language: s.heard,
773
+ agreed: clearlyUnsure && !!s.fallbackText,
774
+ ...(clearlyUnsure ? { pin: s.heard } : {}),
775
+ why: `fallback heard ${s.heard}, primary ${clearlyUnsure ? "clearly unsure" : "marginal"}`,
776
+ };
777
+ }
778
+ const strong = primaryScore === undefined || primaryScore >= MIXED_PRIMARY_MIN_SCORE;
779
+ return {
780
+ engine: strong ? "primary" : "fallback",
781
+ language: strong ? primaryLang : s.heard,
782
+ agreed: false,
783
+ why: `primary sure of ${primaryLang}, fallback heard ${s.heard}: mixed, ${strong ? "primary strong" : "primary marginal"} (utterance ${primaryScore?.toFixed(3)})`,
784
+ };
785
+ }
786
+ if (primarySure) {
787
+ return {
788
+ engine: "primary",
789
+ language: primaryLang,
790
+ agreed: true,
791
+ why: `both heard an Indian language (${primaryLang}/${s.heard})`,
792
+ };
793
+ }
794
+ return {
795
+ engine: s.primaryText ? "primary" : "fallback",
796
+ language: s.heard,
797
+ agreed: false,
798
+ why: `fallback heard ${s.heard}, primary unsure`,
799
+ };
800
+ }
801
+ /**
802
+ * The probe: both engines on the same audio, the utterance's engine chosen
803
+ * from what came back. Returns the result the caption should use.
804
+ */
805
+ async probePass(r, pcm, isFinal) {
806
+ const [p, f] = await Promise.all([
807
+ this.callEngine(r, pcm, isFinal, "primary"),
808
+ this.callEngine(r, pcm, isFinal, "fallback"),
809
+ ]);
810
+ r.probePasses++;
811
+ const lang = liftLanguage(p.res);
812
+ const primaryText = p.res?.text?.trim() ?? "";
813
+ const fallbackText = f.res?.text?.trim() ?? "";
814
+ const when = this.fallbackWhen;
815
+ const primaryFailed = p.err !== undefined || (when.includes("empty") && !primaryText);
816
+ const unsure = when.includes("unsure") && lang?.detected === false;
817
+ const confident = lang?.detected === true;
818
+ if (confident) {
819
+ r.confidentProbes++;
820
+ }
821
+ else if (unsure) {
822
+ r.unsureProbes++;
823
+ }
824
+ // Verdicts that disagreed buy one more probe, so a single call on two or
825
+ // three seconds of audio never settles an utterance by itself.
826
+ const disagreed = r.confidentProbes > 0 && r.unsureProbes > 0;
827
+ const lastProbe = r.probePasses >= this.lockAfterPasses + (disagreed ? 1 : 0);
828
+ // The fallback's words count when there are any; whether they are mostly
829
+ // Latin decides an "unsure" call (an English sentence with one Hindi
830
+ // word is still English), never an "empty" one: words always beat none.
831
+ const fallbackHasWords = fallbackText.length > 0;
832
+ const fallbackMostlyLatin = fallbackHasWords && latinShare(fallbackText) >= 0.7;
833
+ const lead = (engine, why) => {
834
+ if (r.engine !== engine) {
835
+ r.engine = engine;
836
+ r.switched = engine === "fallback";
837
+ resetLocalAgreement(r.la);
838
+ }
839
+ if (logger.shouldLog("debug")) {
840
+ logger.debug(`[transcribeStream] utterance ${r.u.id}: probe ${r.probePasses} → ${engine} leads (${why})`);
841
+ }
842
+ };
843
+ // Two verdicts. A fallback that names the language it heard (Qwen3-ASR
844
+ // through stt-aux does; a pinned pass does not count) settles what the
845
+ // primary's own score cannot: an Indian accent pulls an Indic engine's
846
+ // score toward "confident", never the other engine's language call.
847
+ const pinnedPass = r.fallbackPin !== undefined;
848
+ const heard = pinnedPass ? undefined : langCode(f.res?.language);
849
+ // Only an engine that states its own confidence can be weighed against the
850
+ // fallback's verdict; one that reports none (Deepgram, Whisper) keeps the
851
+ // rules below, where the fallback takes over only when the primary is
852
+ // empty or flags itself unsure.
853
+ if (heard && !p.err && f.res && lang?.flagged) {
854
+ const verdict = this.fuse(r, {
855
+ primary: lang,
856
+ primaryText,
857
+ fallbackText,
858
+ heard,
859
+ });
860
+ if (verdict.engine === "fallback" && verdict.repin && fallbackText) {
861
+ // The fallback named an Indic language in a stream that speaks
862
+ // English, on audio the primary cannot place: ask it again in the
863
+ // stream's language rather than show the wrong script. If it still
864
+ // answers in an Indic script, the speech really is Indic.
865
+ const again = await this.callEngine(r, pcm, isFinal, "fallback", verdict.repin);
866
+ const againText = again.res?.text?.trim() ?? "";
867
+ if (againText && indicShare(againText) <= 0.5) {
868
+ f.res = again.res;
869
+ }
870
+ else {
871
+ verdict.engine =
872
+ primaryText && INDIC_LANGUAGES.has(heard) ? "primary" : "fallback";
873
+ verdict.language = heard;
874
+ verdict.why += `; it insisted, so the speaker switched to ${heard}`;
875
+ }
876
+ }
877
+ const enough = r.u.length / this.inputRate >= AGREE_MIN_AUDIO_S;
878
+ r.agreedProbes = verdict.agreed && enough ? r.agreedProbes + 1 : 0;
879
+ // Two agreeing probes, not one: under load the first probe can land
880
+ // late on a single weak reading, and one call never settles an utterance.
881
+ const settled = r.agreedProbes >= 2;
882
+ const score = lang?.scores?.[0]?.score;
883
+ lead(verdict.engine, `${verdict.why}${score !== undefined ? ` [primary ${lang?.language} ${score}${lang?.flagged ? "" : ", no flag"}]` : ""}${enough ? "" : " (provisional)"}`);
884
+ const otherText = verdict.engine === "fallback"
885
+ ? primaryText
886
+ : (f.res?.text?.trim() ?? "");
887
+ r.alternative = otherText
888
+ ? {
889
+ engine: verdict.engine === "fallback" ? "primary" : "fallback",
890
+ text: otherText,
891
+ }
892
+ : undefined;
893
+ // Only agreement locks: a mixed or unsure utterance keeps both engines
894
+ // running, and its final decides on the whole utterance.
895
+ r.locked = settled;
896
+ r.lookAgain = !settled;
897
+ if (verdict.pin && settled) {
898
+ r.fallbackPin = verdict.pin;
899
+ }
900
+ // A weak guess (Qwen3-ASR called a Tamil utterance Malay) must not
901
+ // become the stream's language: only a settled verdict carries over.
902
+ this.emitLanguage(r, {
903
+ language: verdict.language ?? "und",
904
+ detected: verdict.language !== undefined,
905
+ flagged: true,
906
+ ...(lang?.scores ? { scores: lang.scores } : {}),
907
+ }, settled);
908
+ return verdict.engine === "fallback" ? f : p;
909
+ }
910
+ if (primaryFailed && fallbackHasWords) {
911
+ lead("fallback", p.err !== undefined ? "primary failed" : "primary empty");
912
+ // A failed call (a timeout on a busy gateway) says nothing about the
913
+ // language: try the primary again on the next pass. An empty answer does.
914
+ r.locked = p.err === undefined;
915
+ r.lookAgain = p.err !== undefined;
916
+ this.emitLanguage(r, lang);
917
+ this.emitLanguage(r, liftLanguage(f.res));
918
+ return f;
919
+ }
920
+ if (confident) {
921
+ // The primary knows this language. One verdict does not settle it,
922
+ // though: a language score on a few seconds of audio jitters (an
923
+ // English clip scored −0.46, −0.16 and −0.44 on three consecutive
924
+ // passes against a −0.18 threshold), so the lock waits for a second
925
+ // confident probe — unless the fallback has nothing to offer anyway,
926
+ // or this was the last probe.
927
+ const settled = r.confidentProbes >= 2 || !fallbackHasWords || lastProbe;
928
+ lead("primary", settled ? "primary confident" : "primary confident, one more probe");
929
+ r.locked = settled;
930
+ this.emitLanguage(r, lang);
931
+ return p;
932
+ }
933
+ if (unsure && fallbackMostlyLatin) {
934
+ // Lead with the fallback now; a later probe pass may still hand the
935
+ // utterance back if the primary turns confident with more audio.
936
+ lead("fallback", "primary unsure");
937
+ r.locked = lastProbe;
938
+ this.emitLanguage(r, lang);
939
+ this.emitLanguage(r, liftLanguage(f.res));
940
+ return f;
941
+ }
942
+ // No verdict either way: whoever has words leads, the primary on a tie.
943
+ if (!primaryText && fallbackHasWords) {
944
+ lead("fallback", "only the fallback had words");
945
+ r.locked = lastProbe;
946
+ this.emitLanguage(r, liftLanguage(f.res));
947
+ return f;
948
+ }
949
+ lead("primary", lang ? "primary not unsure" : "no language verdict");
950
+ r.locked = lastProbe;
951
+ this.emitLanguage(r, lang);
952
+ return p;
953
+ }
354
954
  /**
355
955
  * One pass over the whole utterance so far. Returns the text to show, the
356
956
  * result it came from (when the engine answered), and whether the pass failed.
@@ -360,19 +960,50 @@ class ChunkedStreamSession {
360
960
  r.lastReqAt = this.endpointer.audioSeconds;
361
961
  r.passes++;
362
962
  const t0 = Date.now();
363
- const pcm = pcmIn ?? this.endpointer.pcm16k(r.u);
963
+ const pcm = pcmIn ?? this.endpointer.pcm16k(r.u, this.engineCut());
364
964
  const seconds = r.u.length / this.inputRate;
365
- let { res, err } = await this.callEngine(r, pcm, isFinal);
965
+ // The final looks again with both engines unless they already agreed:
966
+ // the whole utterance is the best evidence there will be.
967
+ const probing = this.probe &&
968
+ (!r.locked || (isFinal && r.lookAgain)) &&
969
+ seconds >= PROBE_MIN_AUDIO_S &&
970
+ r.u.speechMs >= PROBE_MIN_SPEECH_MS;
971
+ // Outside the probe, a fallback leading an utterance already labelled
972
+ // with a confident language outside India transcribes in it: an unpinned
973
+ // pass of accented English can still drift into Devanagari.
974
+ const pin = r.fallbackPin ??
975
+ (r.engine === "fallback" &&
976
+ r.languageDetected === true &&
977
+ r.language &&
978
+ !INDIC_LANGUAGES.has(langCode(r.language) ?? "")
979
+ ? langCode(r.language)
980
+ : undefined);
981
+ let { res, err } = probing
982
+ ? await this.probePass(r, pcm, isFinal)
983
+ : await this.callEngine(r, pcm, isFinal, r.engine, pin);
984
+ // A file is not live: a final that failed (a timeout on a busy gateway)
985
+ // is tried again rather than reported as silence.
986
+ for (let retry = 0; err !== undefined && isFinal && retry < this.finalRetries; retry++) {
987
+ if (logger.shouldLog("debug")) {
988
+ logger.debug(`[transcribeStream] utterance ${r.u.id}: final pass failed (${errorMessage(err)}), retry ${retry + 1}`);
989
+ }
990
+ ({ res, err } = await this.callEngine(r, pcm, isFinal, r.engine, pin));
991
+ }
366
992
  let text = res?.text?.trim() ?? "";
367
993
  let lang = liftLanguage(res);
368
- this.emitLanguage(r, lang);
369
- if (this.options.fallback && r.engine === "primary" && !r.switched) {
994
+ if (!probing) {
995
+ this.emitLanguage(r, lang);
996
+ }
997
+ if (this.options.fallback &&
998
+ r.engine === "primary" &&
999
+ !r.switched &&
1000
+ !probing) {
370
1001
  const when = this.fallbackWhen;
371
1002
  const unsure = (when.includes("unsure") && lang?.detected === false) ||
372
1003
  (when.includes("empty") && !err && !text) ||
373
1004
  (when.includes("error") && err !== undefined);
374
1005
  r.unsureN = unsure ? r.unsureN + 1 : 0;
375
- if (!r.confidentOnce &&
1006
+ if (r.confidentPasses < 2 &&
376
1007
  seconds >= TAKEOVER_MIN_AUDIO_S &&
377
1008
  r.u.speechMs >= TAKEOVER_MIN_SPEECH_MS &&
378
1009
  r.unsureN >= TAKEOVER_UNSURE_PASSES) {
@@ -381,6 +1012,13 @@ class ChunkedStreamSession {
381
1012
  r.engine = "fallback";
382
1013
  r.switched = true;
383
1014
  resetLocalAgreement(r.la);
1015
+ // A primary that errors (a wrong model name, a refused key) would
1016
+ // otherwise be hidden by the fallback's captions. Logged as an error
1017
+ // because the logger drops warnings unless debugging.
1018
+ if (err !== undefined && !this.primaryFailureWarned) {
1019
+ this.primaryFailureWarned = true;
1020
+ logger.error(`[transcribeStream] primary engine ${this.label("primary")} failed (${errorMessage(err)}); fallback ${this.label("fallback")} is transcribing instead`);
1021
+ }
384
1022
  if (logger.shouldLog("debug")) {
385
1023
  logger.debug(`[transcribeStream] utterance ${r.u.id}: primary unsure on ${r.unsureN} passes, fallback takes over`);
386
1024
  }
@@ -433,7 +1071,7 @@ class ChunkedStreamSession {
433
1071
  }
434
1072
  finalize(r, reason) {
435
1073
  r.final = true;
436
- const pcm = this.endpointer.pcm16k(r.u);
1074
+ const pcm = this.endpointer.pcm16k(r.u, this.engineCut());
437
1075
  try {
438
1076
  this.options.onUtteranceEnd?.(pcm, r.u.id, r.engine);
439
1077
  }
@@ -447,8 +1085,11 @@ class ChunkedStreamSession {
447
1085
  return;
448
1086
  }
449
1087
  const seconds = r.u.length / this.inputRate;
1088
+ // Engines that disagreed on the language get the final look on the
1089
+ // whole utterance even when the last interim covered all of it.
450
1090
  const needFinal = r.u.length - r.sentLen >= MIN_NEW_AUDIO_FINAL_S * this.inputRate ||
451
- r.passes === 0;
1091
+ r.passes === 0 ||
1092
+ r.lookAgain;
452
1093
  let text = r.shown;
453
1094
  let res;
454
1095
  let keptInterim = false;
@@ -467,10 +1108,34 @@ class ChunkedStreamSession {
467
1108
  }
468
1109
  }
469
1110
  this.runs.delete(r.u.id);
1111
+ if (text && this.nearSilentNoise(r, text, seconds)) {
1112
+ // Little speech energy and a word or two in a script the session
1113
+ // does not speak: the engine filled a pause, not a sentence.
1114
+ if (logger.shouldLog("debug")) {
1115
+ logger.debug(`[transcribeStream] utterance ${r.u.id}: near-silent utterance, output discarded`);
1116
+ }
1117
+ text = "";
1118
+ }
1119
+ if (text && this.impossibleWordRate(r, text)) {
1120
+ if (logger.shouldLog("debug")) {
1121
+ logger.debug(`[transcribeStream] utterance ${r.u.id}: ${r.u.speechMs} ms of speech cannot hold the engine's words, output discarded`);
1122
+ }
1123
+ text = "";
1124
+ }
1125
+ const speechSeconds = Math.round(r.u.speechMs) / 1000;
470
1126
  if (!text) {
471
- this.queue.push({ type: "silence", utterance: r.u.id, seconds });
1127
+ this.queue.push({
1128
+ type: "silence",
1129
+ utterance: r.u.id,
1130
+ seconds,
1131
+ vad: this.vadName,
1132
+ speechSeconds,
1133
+ });
472
1134
  return;
473
1135
  }
1136
+ if (text.split(/\s+/u).filter(Boolean).length > NEAR_SILENT_MAX_WORDS) {
1137
+ this.sessionLatinShare = latinShare(text);
1138
+ }
474
1139
  const start = r.u.startSeconds;
475
1140
  const end = start + seconds;
476
1141
  const words = res && !keptInterim ? shiftWords(res.words, start) : undefined;
@@ -494,12 +1159,30 @@ class ChunkedStreamSession {
494
1159
  },
495
1160
  ...(words ? { words } : {}),
496
1161
  ...(r.language ? { language: r.language } : {}),
497
- ...(r.languageDetected !== undefined
498
- ? { languageDetected: r.languageDetected }
499
- : {}),
500
- engine: this.label(r.engine),
1162
+ // An utterance the two engines never agreed on (code-mixed speech,
1163
+ // most often) has no confident language, whatever its last label.
1164
+ ...(r.lookAgain && r.alternative
1165
+ ? { languageDetected: false }
1166
+ : r.languageDetected !== undefined
1167
+ ? { languageDetected: r.languageDetected }
1168
+ : {}),
1169
+ engine: res &&
1170
+ !keptInterim &&
1171
+ typeof res.metadata?.routedProvider === "string"
1172
+ ? res.metadata.routedProvider
1173
+ : this.label(r.engine),
501
1174
  ...(r.switched ? { fallbackUsed: true } : {}),
1175
+ ...(r.lookAgain && r.alternative && r.alternative.text !== text
1176
+ ? {
1177
+ alternative: {
1178
+ engine: this.label(r.alternative.engine),
1179
+ text: r.alternative.text,
1180
+ },
1181
+ }
1182
+ : {}),
502
1183
  seconds,
1184
+ vad: this.vadName,
1185
+ speechSeconds,
503
1186
  });
504
1187
  if (logger.shouldLog("debug")) {
505
1188
  logger.debug(`[transcribeStream] utterance ${r.u.id} final (${reason}${keptInterim ? ", kept live text" : ""}${needFinal ? "" : ", reused interim"})`);