@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.
- package/CHANGELOG.md +6 -2
- package/dist/auth/sessionManager.js +8 -3
- package/dist/browser/neurolink.min.js +480 -460
- package/dist/cli/commands/serve.js +11 -1
- package/dist/cli/commands/transcribe.js +39 -3
- package/dist/constants/langfuse.d.ts +2 -0
- package/dist/constants/langfuse.js +2 -0
- package/dist/core/redisConversationMemoryManager.js +6 -16
- package/dist/factories/sttDescriptors.js +7 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/neurolink.js +33 -1
- package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
- package/dist/observability/exporters/langfuseExporter.js +29 -8
- package/dist/observability/otlpEncoding.js +2 -2
- package/dist/server/abstract/baseServerAdapter.js +2 -0
- package/dist/server/adapters/expressAdapter.js +1 -1
- package/dist/server/adapters/fastifyAdapter.js +4 -1
- package/dist/server/adapters/honoAdapter.js +1 -1
- package/dist/server/adapters/koaAdapter.js +1 -1
- package/dist/server/routes/transcribeRoutes.d.ts +0 -8
- package/dist/server/routes/transcribeRoutes.js +130 -3
- package/dist/server/utils/validation.d.ts +409 -15
- package/dist/server/utils/validation.js +106 -4
- package/dist/server/websocket/frameQueue.d.ts +16 -0
- package/dist/server/websocket/frameQueue.js +55 -0
- package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
- package/dist/server/websocket/realtimeTranscription.js +603 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
- package/dist/server/websocket/transcribeWebSocket.js +263 -64
- package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
- package/dist/telemetry/attributes.d.ts +1 -1
- package/dist/telemetry/attributes.js +84 -8
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/exporter.d.ts +3 -1
- package/dist/types/server.d.ts +304 -2
- package/dist/types/stt.d.ts +38 -3
- package/dist/types/transcribe.d.ts +401 -5
- package/dist/utils/redis.d.ts +17 -0
- package/dist/utils/redis.js +31 -0
- package/dist/voice/audioLevel.d.ts +14 -0
- package/dist/voice/audioLevel.js +60 -0
- package/dist/voice/codeMix.d.ts +40 -0
- package/dist/voice/codeMix.js +282 -0
- package/dist/voice/correction/align.d.ts +12 -0
- package/dist/voice/correction/align.js +48 -0
- package/dist/voice/correction/guards.d.ts +39 -0
- package/dist/voice/correction/guards.js +122 -0
- package/dist/voice/correction/index.d.ts +25 -3
- package/dist/voice/correction/index.js +132 -4
- package/dist/voice/correction/rewrite.d.ts +64 -3
- package/dist/voice/correction/rewrite.js +295 -25
- package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
- package/dist/voice/eval/sttEvalLangfuse.js +182 -0
- package/dist/voice/eval/sttEvalStore.d.ts +27 -0
- package/dist/voice/eval/sttEvalStore.js +397 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/languages.d.ts +34 -0
- package/dist/voice/languages.js +290 -0
- package/dist/voice/modelAssets.d.ts +28 -0
- package/dist/voice/modelAssets.js +113 -0
- package/dist/voice/output/render.d.ts +32 -0
- package/dist/voice/output/render.js +368 -0
- package/dist/voice/providers/OpenAISTT.d.ts +11 -1
- package/dist/voice/providers/OpenAISTT.js +57 -6
- package/dist/voice/session/transcriptionSession.d.ts +55 -0
- package/dist/voice/session/transcriptionSession.js +225 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
- package/dist/voice/streaming/endpointer.d.ts +50 -17
- package/dist/voice/streaming/endpointer.js +115 -19
- package/dist/voice/transcribe.d.ts +18 -1
- package/dist/voice/transcribe.js +459 -18
- package/dist/voice/transcribeStream.d.ts +18 -1
- package/dist/voice/transcribeStream.js +413 -16
- package/dist/voice/vad/assets.d.ts +36 -0
- package/dist/voice/vad/assets.js +142 -0
- package/dist/voice/vad/frameScorer.d.ts +28 -0
- package/dist/voice/vad/frameScorer.js +90 -0
- package/dist/voice/vad/sileroVad.d.ts +43 -0
- package/dist/voice/vad/sileroVad.js +178 -0
- package/dist/voice/whistle/assets.d.ts +3 -4
- package/dist/voice/whistle/assets.js +11 -95
- package/docs-site/static/search-index.json +21 -6
- package/models/silero-vad/README.md +19 -0
- package/models/silero-vad/silero_vad.onnx +0 -0
- 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 →
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
await
|
|
252
|
-
}
|
|
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
|
-
/**
|
|
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
|
|
289
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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 (
|
|
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({
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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"})`);
|