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