@juspay/neurolink 13.7.0 → 13.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -6
- 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/factories/sttDescriptors.js +7 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/mcp/externalServerManager.js +4 -1
- package/dist/mcp/mcpClientFactory.d.ts +6 -0
- package/dist/mcp/mcpClientFactory.js +44 -0
- package/dist/neurolink.js +42 -3
- 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/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/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 +3 -1
package/dist/voice/transcribe.js
CHANGED
|
@@ -15,16 +15,63 @@
|
|
|
15
15
|
import { promises as fs } from "fs";
|
|
16
16
|
import { extname } from "path";
|
|
17
17
|
import { fileURLToPath } from "url";
|
|
18
|
+
import { guardEngineResult, promptTermsOf, runTranscribeStream, } from "./transcribeStream.js";
|
|
19
|
+
import { ENGINE_SAMPLE_RATE } from "./streaming/endpointer.js";
|
|
20
|
+
import { alignWordsToText } from "./correction/align.js";
|
|
21
|
+
import { normalizeLanguageCode } from "./languages.js";
|
|
22
|
+
import { outputLanguagesOf, renderOutputs } from "./output/render.js";
|
|
18
23
|
import { logger } from "../utils/logger.js";
|
|
19
24
|
import { SpanSerializer, SpanStatus, SpanType, getMetricsAggregator, } from "../observability/index.js";
|
|
20
25
|
import { detectAudioFormat } from "./audio-utils.js";
|
|
21
|
-
import { correctTranscript, correctTranscriptLines, isCorrectionEnabled, } from "./correction/index.js";
|
|
26
|
+
import { correctTranscript, correctTranscriptLines, correctTranscriptSentences, isCorrectionEnabled, } from "./correction/index.js";
|
|
27
|
+
import { STTError } from "./errors.js";
|
|
28
|
+
/** Audio the language probe gets to hear; a verdict needs a few seconds, not the file. */
|
|
29
|
+
const PROBE_SECONDS = 8;
|
|
30
|
+
/**
|
|
31
|
+
* The first `seconds` of a PCM WAV as a smaller WAV, so a language probe
|
|
32
|
+
* uploads kilobytes instead of the whole recording. Anything that is not a
|
|
33
|
+
* plain RIFF/WAVE with a readable `fmt ` and `data` chunk is returned as is.
|
|
34
|
+
*/
|
|
35
|
+
export function wavHead(buffer, seconds) {
|
|
36
|
+
if (buffer.length < 44 ||
|
|
37
|
+
buffer.toString("ascii", 0, 4) !== "RIFF" ||
|
|
38
|
+
buffer.toString("ascii", 8, 12) !== "WAVE") {
|
|
39
|
+
return buffer;
|
|
40
|
+
}
|
|
41
|
+
let offset = 12;
|
|
42
|
+
let byteRate = 0;
|
|
43
|
+
let blockAlign = 0;
|
|
44
|
+
while (offset + 8 <= buffer.length) {
|
|
45
|
+
const id = buffer.toString("ascii", offset, offset + 4);
|
|
46
|
+
const size = buffer.readUInt32LE(offset + 4);
|
|
47
|
+
const body = offset + 8;
|
|
48
|
+
if (id === "fmt " && body + 16 <= buffer.length) {
|
|
49
|
+
byteRate = buffer.readUInt32LE(body + 8);
|
|
50
|
+
blockAlign = buffer.readUInt16LE(body + 12);
|
|
51
|
+
}
|
|
52
|
+
else if (id === "data") {
|
|
53
|
+
if (!byteRate || !blockAlign) {
|
|
54
|
+
return buffer;
|
|
55
|
+
}
|
|
56
|
+
const available = Math.min(size, buffer.length - body);
|
|
57
|
+
const wanted = Math.floor((byteRate * seconds) / blockAlign) * blockAlign;
|
|
58
|
+
if (available <= wanted) {
|
|
59
|
+
return buffer;
|
|
60
|
+
}
|
|
61
|
+
const head = Buffer.from(buffer.subarray(0, body + wanted));
|
|
62
|
+
head.writeUInt32LE(head.length - 8, 4);
|
|
63
|
+
head.writeUInt32LE(wanted, offset + 4);
|
|
64
|
+
return head;
|
|
65
|
+
}
|
|
66
|
+
offset = body + size + (size % 2);
|
|
67
|
+
}
|
|
68
|
+
return buffer;
|
|
69
|
+
}
|
|
22
70
|
/** `NEUROLINK_STT_CORRECT_WHOLE=1` forces one whole-transcript rewrite even for segmented results. */
|
|
23
71
|
function correctionLinesDisabled() {
|
|
24
72
|
const v = process.env.NEUROLINK_STT_CORRECT_WHOLE?.trim().toLowerCase();
|
|
25
73
|
return v === "1" || v === "true";
|
|
26
74
|
}
|
|
27
|
-
import { STTError } from "./errors.js";
|
|
28
75
|
/** Default timeout for fetching an `http(s)://` audio input. */
|
|
29
76
|
const URL_FETCH_TIMEOUT_MS = 60_000;
|
|
30
77
|
/** Container names an extension or magic number can map to. */
|
|
@@ -190,7 +237,7 @@ function mergeVocabulary(vocabulary, dictionary) {
|
|
|
190
237
|
* the correction context rides along in the prompt for engines that take one.
|
|
191
238
|
*/
|
|
192
239
|
export function buildEngineOptions(options, format, model) {
|
|
193
|
-
const { provider: _provider, model: _model, language, format: _format, prompt, dictionary, correction, fallback: _fallback, streaming: _streaming, credentials: _credentials, vocabulary, ...rest } = options;
|
|
240
|
+
const { provider: _provider, model: _model, language, format: _format, prompt, dictionary, correction, fallback: _fallback, routing: _routing, chunking: _chunking, output: _output, streaming: _streaming, credentials: _credentials, vocabulary, ...rest } = options;
|
|
194
241
|
const contextPrompt = [prompt?.trim(), correction?.context?.trim()]
|
|
195
242
|
.filter((part) => !!part)
|
|
196
243
|
.join(" ");
|
|
@@ -288,6 +335,243 @@ export async function runTranscribe(options, deps) {
|
|
|
288
335
|
throw error;
|
|
289
336
|
}
|
|
290
337
|
}
|
|
338
|
+
/** 100 ms of 16 kHz PCM16 per frame when a recording is replayed through the live pipeline. */
|
|
339
|
+
const UTTERANCE_FRAME_BYTES = 3200;
|
|
340
|
+
/** Finals in flight at once on the file path when the caller sets no `streaming.concurrency`. */
|
|
341
|
+
const DEFAULT_FILE_CONCURRENCY = 3;
|
|
342
|
+
/** Per-engine-call timeout on the file path when the caller sets none. */
|
|
343
|
+
const FILE_ENGINE_TIMEOUT_MS = 180_000;
|
|
344
|
+
/** File-mode utterance cuts: shorter than live, so a long-form engine stays in its comfortable range and more utterances run in parallel. */
|
|
345
|
+
const FILE_MAX_UTTERANCE_SECONDS = 20;
|
|
346
|
+
const FILE_SOFT_CUT_SECONDS = 12;
|
|
347
|
+
/**
|
|
348
|
+
* The stream options a recording gets when it goes through the live
|
|
349
|
+
* pipeline, whether by `chunking: "utterances"` or over the WebSocket with
|
|
350
|
+
* `mode: "file"`: no interim passes, several utterances at a time, one retry
|
|
351
|
+
* of a failed final, file-sized engine timeouts and shorter cuts. Anything
|
|
352
|
+
* the caller set explicitly is kept.
|
|
353
|
+
*/
|
|
354
|
+
export function fileModeStreamOptions(options) {
|
|
355
|
+
return {
|
|
356
|
+
...options,
|
|
357
|
+
streaming: {
|
|
358
|
+
...options.streaming,
|
|
359
|
+
// No interim passes on a file: an infinite cadence disables them.
|
|
360
|
+
intervalMs: Number.POSITIVE_INFINITY,
|
|
361
|
+
concurrency: options.streaming?.concurrency ?? DEFAULT_FILE_CONCURRENCY,
|
|
362
|
+
finalRetries: options.streaming?.finalRetries ?? 1,
|
|
363
|
+
maxUtteranceSeconds: options.streaming?.maxUtteranceSeconds ?? FILE_MAX_UTTERANCE_SECONDS,
|
|
364
|
+
softCutSeconds: options.streaming?.softCutSeconds ?? FILE_SOFT_CUT_SECONDS,
|
|
365
|
+
},
|
|
366
|
+
// Engines get a file-sized timeout: an utterance may be 20 s of audio on
|
|
367
|
+
// a busy gateway, and the live default (30 s) is cut for captions.
|
|
368
|
+
timeoutMs: options.timeoutMs ?? FILE_ENGINE_TIMEOUT_MS,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The file path that behaves like live captions: the recording is cut into
|
|
373
|
+
* utterances by the endpointer, every utterance gets the language probe and
|
|
374
|
+
* its own engine, the dictionary and the per-sentence correction, several
|
|
375
|
+
* utterances at a time, and the result has one segment per utterance. No
|
|
376
|
+
* interim passes are run; the stream is fed as fast as the engines take it.
|
|
377
|
+
*/
|
|
378
|
+
async function transcribeUtterances(buffer, format, options, deps, startedAt) {
|
|
379
|
+
const { decodeToWhistlePcm } = await import("./whistle/audio.js");
|
|
380
|
+
const samples = await decodeToWhistlePcm(buffer, format, ENGINE_SAMPLE_RATE);
|
|
381
|
+
const pcm = Buffer.alloc(samples.length * 2);
|
|
382
|
+
for (let i = 0; i < samples.length; i++) {
|
|
383
|
+
pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), i * 2);
|
|
384
|
+
}
|
|
385
|
+
const duration = samples.length / ENGINE_SAMPLE_RATE;
|
|
386
|
+
async function* frames() {
|
|
387
|
+
for (let at = 0; at < pcm.length; at += UTTERANCE_FRAME_BYTES) {
|
|
388
|
+
yield pcm.subarray(at, Math.min(pcm.length, at + UTTERANCE_FRAME_BYTES));
|
|
389
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const { audio: _audio, chunking: _chunking, format: _format, ...rest } = options;
|
|
393
|
+
const provider = deps.resolveProvider(options.provider);
|
|
394
|
+
const fileOptions = fileModeStreamOptions(rest);
|
|
395
|
+
const events = runTranscribeStream({
|
|
396
|
+
...fileOptions,
|
|
397
|
+
audio: frames(),
|
|
398
|
+
streaming: {
|
|
399
|
+
...fileOptions.streaming,
|
|
400
|
+
mode: "chunked",
|
|
401
|
+
sampleRate: ENGINE_SAMPLE_RATE,
|
|
402
|
+
},
|
|
403
|
+
}, deps);
|
|
404
|
+
const finals = new Map();
|
|
405
|
+
const corrections = new Map();
|
|
406
|
+
const outputEvents = new Map();
|
|
407
|
+
let lastOutputAt = startedAt;
|
|
408
|
+
const silences = [];
|
|
409
|
+
const errors = [];
|
|
410
|
+
let lastFinalAt = startedAt;
|
|
411
|
+
let lastCorrectedAt = startedAt;
|
|
412
|
+
let fatal;
|
|
413
|
+
for await (const ev of events) {
|
|
414
|
+
if (ev.type === "final") {
|
|
415
|
+
finals.set(ev.utterance, ev);
|
|
416
|
+
lastFinalAt = Date.now();
|
|
417
|
+
}
|
|
418
|
+
else if (ev.type === "corrected") {
|
|
419
|
+
corrections.set(ev.utterance, ev);
|
|
420
|
+
lastCorrectedAt = Date.now();
|
|
421
|
+
}
|
|
422
|
+
else if (ev.type === "output") {
|
|
423
|
+
const byUtterance = outputEvents.get(ev.language) ?? new Map();
|
|
424
|
+
byUtterance.set(ev.utterance, ev);
|
|
425
|
+
outputEvents.set(ev.language, byUtterance);
|
|
426
|
+
lastOutputAt = Date.now();
|
|
427
|
+
}
|
|
428
|
+
else if (ev.type === "silence") {
|
|
429
|
+
silences.push({ utterance: ev.utterance, seconds: ev.seconds });
|
|
430
|
+
}
|
|
431
|
+
else if (ev.type === "error") {
|
|
432
|
+
errors.push(ev.message);
|
|
433
|
+
if (!ev.recoverable) {
|
|
434
|
+
fatal = ev.message;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// The same contract as a whole-file call: an engine that could not run is
|
|
439
|
+
// an error, not an empty transcript.
|
|
440
|
+
const nothingRead = finals.size === 0 && errors.length > 0;
|
|
441
|
+
if (fatal !== undefined || nothingRead) {
|
|
442
|
+
throw STTError.transcriptionFailed(fatal ?? errors[errors.length - 1] ?? "every utterance failed", provider);
|
|
443
|
+
}
|
|
444
|
+
const ids = [...finals.keys()].sort((a, b) => a - b);
|
|
445
|
+
const segments = ids.map((id, index) => {
|
|
446
|
+
const f = finals.get(id);
|
|
447
|
+
const c = corrections.get(id);
|
|
448
|
+
return {
|
|
449
|
+
...f.segment,
|
|
450
|
+
index,
|
|
451
|
+
text: c?.text.trim() ? c.text : f.text,
|
|
452
|
+
isFinal: true,
|
|
453
|
+
...(f.language ? { language: f.language } : {}),
|
|
454
|
+
};
|
|
455
|
+
});
|
|
456
|
+
const raw = ids
|
|
457
|
+
.map((id) => finals.get(id)?.text ?? "")
|
|
458
|
+
.filter(Boolean)
|
|
459
|
+
.join(" ");
|
|
460
|
+
const text = segments
|
|
461
|
+
.map((seg) => seg.text)
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.join(" ");
|
|
464
|
+
const decisions = ids.flatMap((id) => corrections.get(id)?.decisions ?? []);
|
|
465
|
+
const handedOver = ids.filter((id) => finals.get(id)?.fallbackUsed).length;
|
|
466
|
+
const fallbackProvider = handedOver
|
|
467
|
+
? (ids.map((id) => finals.get(id)).find((f) => f?.fallbackUsed)?.engine ??
|
|
468
|
+
options.fallback?.provider)
|
|
469
|
+
: undefined;
|
|
470
|
+
const languages = ids
|
|
471
|
+
.map((id) => finals.get(id)?.language)
|
|
472
|
+
.filter((l) => !!l && l !== "und");
|
|
473
|
+
const language = languages.length
|
|
474
|
+
? [...new Set(languages)].sort((a, b) => languages.filter((l) => l === b).length -
|
|
475
|
+
languages.filter((l) => l === a).length)[0]
|
|
476
|
+
: undefined;
|
|
477
|
+
const unsure = ids.some((id) => finals.get(id)?.languageDetected === false &&
|
|
478
|
+
!finals.get(id)?.fallbackUsed);
|
|
479
|
+
const anyDetected = ids.some((id) => finals.get(id)?.languageDetected !== undefined);
|
|
480
|
+
const steps = [
|
|
481
|
+
`transcribed ${provider} per utterance (${ids.length} utterance${ids.length === 1 ? "" : "s"}${handedOver ? `, ${handedOver} by ${fallbackProvider}` : ""})`,
|
|
482
|
+
...(silences.length
|
|
483
|
+
? [
|
|
484
|
+
`${silences.length} utterance(s) no engine could read (${silences.map((g) => `${g.seconds.toFixed(1)} s`).join(", ")}), reported as silence`,
|
|
485
|
+
]
|
|
486
|
+
: []),
|
|
487
|
+
...(corrections.size
|
|
488
|
+
? [`corrected ${corrections.size}/${ids.length} utterances`]
|
|
489
|
+
: []),
|
|
490
|
+
...(errors.length ? [`${errors.length} recoverable error(s)`] : []),
|
|
491
|
+
];
|
|
492
|
+
const decideMs = sumTimings(corrections, "decideMs");
|
|
493
|
+
const rewriteMs = sumTimings(corrections, "rewriteMs");
|
|
494
|
+
const outputs = collectUtteranceOutputs(outputLanguagesOf(options.output), ids, outputEvents);
|
|
495
|
+
if (outputs) {
|
|
496
|
+
steps.push(describeOutputs(outputs));
|
|
497
|
+
}
|
|
498
|
+
const result = {
|
|
499
|
+
text,
|
|
500
|
+
confidence: 0,
|
|
501
|
+
...(language ? { language } : {}),
|
|
502
|
+
duration,
|
|
503
|
+
segments,
|
|
504
|
+
raw,
|
|
505
|
+
...(text !== raw ? { corrected: text } : {}),
|
|
506
|
+
...(anyDetected ? { languageDetected: !unsure } : {}),
|
|
507
|
+
...(decisions.length ? { decisions } : {}),
|
|
508
|
+
engine: {
|
|
509
|
+
provider,
|
|
510
|
+
...(handedOver ? { fallbackUsed: true } : {}),
|
|
511
|
+
},
|
|
512
|
+
timings: {
|
|
513
|
+
transcribeMs: lastFinalAt - startedAt,
|
|
514
|
+
...(decideMs !== undefined ? { decideMs } : {}),
|
|
515
|
+
...(rewriteMs !== undefined ? { rewriteMs } : {}),
|
|
516
|
+
...(corrections.size
|
|
517
|
+
? { correctionMs: Math.max(0, lastCorrectedAt - lastFinalAt) }
|
|
518
|
+
: {}),
|
|
519
|
+
...(outputs
|
|
520
|
+
? { outputMs: Math.max(0, lastOutputAt - lastCorrectedAt) }
|
|
521
|
+
: {}),
|
|
522
|
+
totalMs: Date.now() - startedAt,
|
|
523
|
+
},
|
|
524
|
+
steps,
|
|
525
|
+
...(outputs ? { outputs } : {}),
|
|
526
|
+
};
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
/** One rendering per language from the per-utterance `output` events, in utterance order. */
|
|
530
|
+
function collectUtteranceOutputs(languages, ids, events) {
|
|
531
|
+
if (languages.length === 0) {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
const out = {};
|
|
535
|
+
for (const tag of languages) {
|
|
536
|
+
const byUtterance = events.get(tag) ?? new Map();
|
|
537
|
+
const parts = ids.map((id) => byUtterance.get(id));
|
|
538
|
+
const good = parts.filter((p) => p && (p.status === "ok" || p.status === "same-language"));
|
|
539
|
+
const failure = parts.find((p) => p && p.status !== "ok" && p.status !== "same-language");
|
|
540
|
+
const status = good.length === ids.length
|
|
541
|
+
? good.every((p) => p?.status === "same-language")
|
|
542
|
+
? "same-language"
|
|
543
|
+
: "ok"
|
|
544
|
+
: good.length > 0
|
|
545
|
+
? "partial"
|
|
546
|
+
: (failure?.status ?? "error");
|
|
547
|
+
out[tag] = {
|
|
548
|
+
language: tag,
|
|
549
|
+
text: parts
|
|
550
|
+
.map((p) => p?.text ?? "")
|
|
551
|
+
.filter(Boolean)
|
|
552
|
+
.join(" "),
|
|
553
|
+
status,
|
|
554
|
+
ms: Math.max(0, ...parts.map((p) => p?.ms ?? 0)),
|
|
555
|
+
segments: parts.map((p, index) => ({ index, text: p?.text ?? "" })),
|
|
556
|
+
...(status !== "ok" && status !== "same-language" && failure?.reason
|
|
557
|
+
? { reason: failure.reason }
|
|
558
|
+
: {}),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
/** "outputs es ok · fr timeout" */
|
|
564
|
+
function describeOutputs(outputs) {
|
|
565
|
+
return `outputs ${Object.values(outputs)
|
|
566
|
+
.map((o) => `${o.language} ${o.status}`)
|
|
567
|
+
.join(" · ")}`;
|
|
568
|
+
}
|
|
569
|
+
function sumTimings(corrections, key) {
|
|
570
|
+
const values = [...corrections.values()]
|
|
571
|
+
.map((c) => c.timings[key])
|
|
572
|
+
.filter((v) => typeof v === "number");
|
|
573
|
+
return values.length ? values.reduce((a, b) => a + b, 0) : undefined;
|
|
574
|
+
}
|
|
291
575
|
async function transcribeLayers(options, deps, startedAt) {
|
|
292
576
|
const steps = [];
|
|
293
577
|
// 1. Audio, and its container when the caller did not name one.
|
|
@@ -296,6 +580,9 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
296
580
|
throw STTError.audioEmpty(options.provider);
|
|
297
581
|
}
|
|
298
582
|
const format = options.format ?? formatHint ?? audioFormatFromBytes(buffer);
|
|
583
|
+
if (options.chunking === "utterances") {
|
|
584
|
+
return transcribeUtterances(buffer, format, options, deps, startedAt);
|
|
585
|
+
}
|
|
299
586
|
// 2. Engines. A model from the environment only applies to the provider
|
|
300
587
|
// the environment picked; an explicit provider gets only an explicit model.
|
|
301
588
|
const explicitProvider = !!options.provider?.trim();
|
|
@@ -307,8 +594,13 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
307
594
|
language: options.language ?? process.env.NEUROLINK_STT_LANGUAGE,
|
|
308
595
|
};
|
|
309
596
|
const optionsFor = (model) => buildEngineOptions(engineBase, format, model);
|
|
310
|
-
// 3. Primary and second opinion, in parallel.
|
|
311
|
-
const
|
|
597
|
+
// 3. Primary and second opinion, in parallel. "auto" names the fallback.
|
|
598
|
+
const secondSetting = options.correction?.secondOpinion;
|
|
599
|
+
const secondOpinionSpec = secondSetting === "auto"
|
|
600
|
+
? options.fallback
|
|
601
|
+
? { provider: options.fallback.provider, model: options.fallback.model }
|
|
602
|
+
: undefined
|
|
603
|
+
: secondSetting;
|
|
312
604
|
const secondProvider = secondOpinionSpec
|
|
313
605
|
? deps.resolveProvider(secondOpinionSpec.provider)
|
|
314
606
|
: undefined;
|
|
@@ -324,13 +616,59 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
324
616
|
const fallbackProvider = fallback
|
|
325
617
|
? deps.resolveProvider(fallback.provider)
|
|
326
618
|
: undefined;
|
|
619
|
+
// 3b. Language probe. With a fallback and no pinned language, an engine
|
|
620
|
+
// that can answer "which language is this" without transcribing (its
|
|
621
|
+
// `languageProbeModel`) is asked first, in a fraction of a full pass. When
|
|
622
|
+
// it is unsure, the fallback transcribes instead of the primary, so an
|
|
623
|
+
// English recording never pays for a full Indic pass that would come back
|
|
624
|
+
// as noise; the primary still runs if the fallback has nothing.
|
|
625
|
+
let probed;
|
|
626
|
+
const pinnedLanguage = typeof engineBase.language === "string" &&
|
|
627
|
+
engineBase.language.trim() !== "" &&
|
|
628
|
+
engineBase.language.trim().toLowerCase() !== "auto";
|
|
629
|
+
// The verdict only matters when an unsure primary hands over.
|
|
630
|
+
const probeModel = fallbackProvider &&
|
|
631
|
+
fallbackWhen.has("unsure") &&
|
|
632
|
+
options.routing?.probe !== false &&
|
|
633
|
+
!pinnedLanguage
|
|
634
|
+
? deps.getHandler(provider)?.languageProbeModel?.()
|
|
635
|
+
: undefined;
|
|
636
|
+
if (probeModel && fallbackProvider !== provider) {
|
|
637
|
+
const probeStarted = Date.now();
|
|
638
|
+
try {
|
|
639
|
+
const verdict = await deps.transcribe(wavHead(buffer, PROBE_SECONDS), provider, optionsFor(probeModel));
|
|
640
|
+
const detected = readLanguageDetected(verdict);
|
|
641
|
+
probed = detected === false ? "unsure" : "confident";
|
|
642
|
+
steps.push(`language probe ${probed}${verdict.language ? ` (${verdict.language})` : ""} · ${Date.now() - probeStarted} ms`);
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
steps.push("language probe failed · transcribing with the primary");
|
|
646
|
+
if (logger.shouldLog("debug")) {
|
|
647
|
+
logger.debug(`[transcribe] language probe failed: ${errorMessage(error)}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
327
651
|
let primary;
|
|
328
652
|
let primaryError;
|
|
329
|
-
|
|
330
|
-
|
|
653
|
+
let probedFallback;
|
|
654
|
+
if (probed === "unsure" && fallbackProvider && fallbackWhen.has("unsure")) {
|
|
655
|
+
try {
|
|
656
|
+
probedFallback = await deps.transcribe(buffer, fallbackProvider, optionsFor(fallback?.model));
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
steps.push(`fallback ${fallbackProvider} failed · trying the primary`);
|
|
660
|
+
if (logger.shouldLog("debug")) {
|
|
661
|
+
logger.debug(`[transcribe] probed fallback ${fallbackProvider} failed: ${errorMessage(error)}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
331
664
|
}
|
|
332
|
-
|
|
333
|
-
|
|
665
|
+
if (!probedFallback?.text.trim()) {
|
|
666
|
+
try {
|
|
667
|
+
primary = await deps.transcribe(buffer, provider, optionsFor(primaryModel));
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
primaryError = error;
|
|
671
|
+
}
|
|
334
672
|
}
|
|
335
673
|
// 4. Fallback: on error, on an empty transcript, or when the engine said
|
|
336
674
|
// it could not identify the language — whichever `when` asks for.
|
|
@@ -349,7 +687,14 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
349
687
|
fallbackWhen.has("unsure")
|
|
350
688
|
? "unsure"
|
|
351
689
|
: undefined;
|
|
352
|
-
if (
|
|
690
|
+
if (probedFallback?.text.trim() && fallbackProvider) {
|
|
691
|
+
chosen = probedFallback;
|
|
692
|
+
chosenProvider = fallbackProvider;
|
|
693
|
+
chosenModel = fallback?.model;
|
|
694
|
+
fallbackUsed = true;
|
|
695
|
+
steps.push(`fallback ${fallbackProvider} (probe said unsure)`);
|
|
696
|
+
}
|
|
697
|
+
else if (fallback && fallbackProvider && fallbackReason) {
|
|
353
698
|
if (fallbackProvider === provider && fallback.model === primaryModel) {
|
|
354
699
|
steps.push(`fallback skipped · same engine as primary`);
|
|
355
700
|
}
|
|
@@ -375,6 +720,46 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
375
720
|
}
|
|
376
721
|
}
|
|
377
722
|
}
|
|
723
|
+
// 4b. Language routing: a transcript whose language belongs to another
|
|
724
|
+
// engine is read again by that engine, and kept when it reads the language.
|
|
725
|
+
const routes = options.routing?.engines;
|
|
726
|
+
const chosenLanguage = chosen
|
|
727
|
+
? normalizeLanguageCode(chosen.language)
|
|
728
|
+
: undefined;
|
|
729
|
+
if (chosen && routes && chosenLanguage && !pinnedLanguage) {
|
|
730
|
+
const routeName = Object.entries(routes).find(([lang]) => normalizeLanguageCode(lang) === chosenLanguage)?.[1] ?? routes["*"];
|
|
731
|
+
let target;
|
|
732
|
+
try {
|
|
733
|
+
target = routeName ? deps.resolveProvider(routeName) : undefined;
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
steps.push(`routing ${chosenLanguage} → ${routeName} unavailable`);
|
|
737
|
+
}
|
|
738
|
+
if (target && target !== chosenProvider) {
|
|
739
|
+
try {
|
|
740
|
+
const routed = await deps.transcribe(buffer, target, {
|
|
741
|
+
...optionsFor(target === fallbackProvider ? fallback?.model : undefined),
|
|
742
|
+
languagePrior: chosenLanguage,
|
|
743
|
+
});
|
|
744
|
+
if (routed.text.trim() && readLanguageDetected(routed) !== false) {
|
|
745
|
+
chosen = {
|
|
746
|
+
...routed,
|
|
747
|
+
language: routed.language ?? chosen.language,
|
|
748
|
+
};
|
|
749
|
+
chosenProvider = target;
|
|
750
|
+
chosenModel =
|
|
751
|
+
target === fallbackProvider ? fallback?.model : undefined;
|
|
752
|
+
steps.push(`routed ${chosenLanguage} → ${target}`);
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
steps.push(`routing ${chosenLanguage} → ${target} heard nothing · kept ${chosenProvider}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
steps.push(`routing ${chosenLanguage} → ${target} failed · kept ${chosenProvider}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
378
763
|
if (!chosen) {
|
|
379
764
|
// Primary failed and nothing replaced it. Let the second opinion settle
|
|
380
765
|
// first so its request is not left running unobserved.
|
|
@@ -409,6 +794,9 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
409
794
|
}
|
|
410
795
|
}
|
|
411
796
|
}
|
|
797
|
+
// 5b. Guards on the engine output: a decoding loop is collapsed and a
|
|
798
|
+
// prompt echo is emptied before the correction layer sees the text.
|
|
799
|
+
chosen = guardEngineResult(chosen, promptTermsOf(options), (note) => steps.push(note));
|
|
412
800
|
// 6. Correction. The primary text leads unless the primary engine said it
|
|
413
801
|
// could not identify the language and a second opinion exists.
|
|
414
802
|
const languageDetected = readLanguageDetected(chosen);
|
|
@@ -427,14 +815,20 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
427
815
|
let correctedSegments;
|
|
428
816
|
if (correctionWanted(options) && perSegment && chosen.segments) {
|
|
429
817
|
try {
|
|
430
|
-
const corrected = await correctTranscriptLines(segmentTexts, options.dictionary, options.correction, deps);
|
|
818
|
+
const corrected = await correctTranscriptLines(segmentTexts, options.dictionary, options.correction, deps, chosen.language);
|
|
431
819
|
decisions = corrected.decisions;
|
|
432
820
|
steps.push(...corrected.steps);
|
|
433
821
|
Object.assign(correctionTimings, corrected.timings);
|
|
434
|
-
correctedSegments = chosen.segments.map((seg, i) =>
|
|
435
|
-
|
|
436
|
-
text
|
|
437
|
-
|
|
822
|
+
correctedSegments = chosen.segments.map((seg, i) => {
|
|
823
|
+
const text = corrected.lines[i]?.trim() || seg.text;
|
|
824
|
+
if (text === seg.text || !seg.words?.length) {
|
|
825
|
+
return { ...seg, text };
|
|
826
|
+
}
|
|
827
|
+
const words = alignWordsToText(seg.words, text);
|
|
828
|
+
return words.length
|
|
829
|
+
? { ...seg, text, words }
|
|
830
|
+
: { ...seg, text, words: undefined };
|
|
831
|
+
});
|
|
438
832
|
const joined = correctedSegments
|
|
439
833
|
.map((seg) => seg.text)
|
|
440
834
|
.filter(Boolean)
|
|
@@ -456,12 +850,18 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
456
850
|
}
|
|
457
851
|
else if (correctionWanted(options)) {
|
|
458
852
|
try {
|
|
459
|
-
const
|
|
853
|
+
const correctFn = options.correction?.perSentence === false
|
|
854
|
+
? correctTranscript
|
|
855
|
+
: correctTranscriptSentences;
|
|
856
|
+
const corrected = await correctFn({
|
|
460
857
|
text: engineText,
|
|
461
858
|
...(secondText ? { secondOpinion: secondText } : {}),
|
|
462
859
|
...(options.dictionary ? { dictionary: options.dictionary } : {}),
|
|
463
860
|
...(options.correction ? { options: options.correction } : {}),
|
|
464
861
|
...(languageDetected !== undefined ? { languageDetected } : {}),
|
|
862
|
+
...(chosen.language && languageDetected !== false
|
|
863
|
+
? { language: chosen.language }
|
|
864
|
+
: {}),
|
|
465
865
|
}, deps);
|
|
466
866
|
decisions = corrected.decisions;
|
|
467
867
|
steps.push(...corrected.steps);
|
|
@@ -483,11 +883,50 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
483
883
|
else if (secondLeads) {
|
|
484
884
|
steps.push("second opinion leads (primary language unsure)");
|
|
485
885
|
}
|
|
486
|
-
// 7.
|
|
487
|
-
|
|
886
|
+
// 7. Output languages, from the settled text: per segment when the
|
|
887
|
+
// transcript has several (so renderings keep the timings), else whole.
|
|
888
|
+
let outputs;
|
|
889
|
+
let outputMs;
|
|
890
|
+
const outputLanguages = outputLanguagesOf(options.output);
|
|
891
|
+
if (outputLanguages.length > 0) {
|
|
892
|
+
const outputStarted = Date.now();
|
|
893
|
+
const segs = chosen.segments ?? [];
|
|
894
|
+
let source;
|
|
895
|
+
let corrected;
|
|
896
|
+
if (correctedSegments && segs.length === correctedSegments.length) {
|
|
897
|
+
source = segs.map((seg) => seg.text);
|
|
898
|
+
corrected = correctedSegments.map((seg) => seg.text);
|
|
899
|
+
}
|
|
900
|
+
else if (segs.length > 1 && finalText === raw) {
|
|
901
|
+
source = segs.map((seg) => seg.text);
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
source = [raw];
|
|
905
|
+
corrected = finalText !== raw ? [finalText] : undefined;
|
|
906
|
+
}
|
|
907
|
+
outputs = await renderOutputs({
|
|
908
|
+
source,
|
|
909
|
+
...(corrected ? { corrected } : {}),
|
|
910
|
+
...(chosen.language && languageDetected !== false
|
|
911
|
+
? { language: chosen.language }
|
|
912
|
+
: {}),
|
|
913
|
+
languages: outputLanguages,
|
|
914
|
+
...(options.dictionary ? { dictionary: options.dictionary } : {}),
|
|
915
|
+
options: options.output,
|
|
916
|
+
}, deps);
|
|
917
|
+
outputMs = Date.now() - outputStarted;
|
|
918
|
+
steps.push(describeOutputs(outputs));
|
|
919
|
+
}
|
|
920
|
+
// Word timings follow the corrected text.
|
|
921
|
+
const words = finalText !== raw && chosen.words?.length && !correctedSegments
|
|
922
|
+
? alignWordsToText(chosen.words, finalText)
|
|
923
|
+
: chosen.words;
|
|
924
|
+
// 8. Result.
|
|
925
|
+
const { text: _engineText, words: _engineWords, ...chosenRest } = chosen;
|
|
488
926
|
const result = {
|
|
489
927
|
...chosenRest,
|
|
490
928
|
...(correctedSegments ? { segments: correctedSegments } : {}),
|
|
929
|
+
...(words?.length ? { words } : {}),
|
|
491
930
|
text: finalText,
|
|
492
931
|
raw,
|
|
493
932
|
...(finalText !== raw ? { corrected: finalText } : {}),
|
|
@@ -508,9 +947,11 @@ async function transcribeLayers(options, deps, startedAt) {
|
|
|
508
947
|
transcribeMs,
|
|
509
948
|
...(secondOpinionMs !== undefined ? { secondOpinionMs } : {}),
|
|
510
949
|
...correctionTimings,
|
|
950
|
+
...(outputMs !== undefined ? { outputMs } : {}),
|
|
511
951
|
totalMs: Date.now() - startedAt,
|
|
512
952
|
},
|
|
513
953
|
steps,
|
|
954
|
+
...(outputs ? { outputs } : {}),
|
|
514
955
|
};
|
|
515
956
|
if (logger.shouldLog("debug")) {
|
|
516
957
|
logger.debug(`[transcribe] ${chosenProvider}: ${raw.length} chars raw, ${finalText.length} final in ${result.timings.totalMs}ms`, { steps });
|
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
import type { TranscribeDeps, TranscribeStreamEvent, TranscribeStreamOptions } from "../types/index.js";
|
|
1
|
+
import type { STTDictionaryEntry, STTResult, TranscribeDeps, TranscribeStreamEvent, TranscribeStreamOptions } from "../types/index.js";
|
|
2
2
|
/** Float samples in [-1, 1] → 16 kHz mono PCM16 WAV. */
|
|
3
3
|
export declare function encodeWav16k(pcm: Float32Array): Buffer;
|
|
4
|
+
/**
|
|
5
|
+
* Engine options for one provider: the caller's pass-through `STTOptions`,
|
|
6
|
+
* dictionary terms as vocabulary and appended to the prompt, `"auto"`
|
|
7
|
+
* language left to the engine.
|
|
8
|
+
*/
|
|
9
|
+
/** The words an engine is prompted with (dictionary terms, the caller's vocabulary, the fixed phrasing). */
|
|
10
|
+
export declare function promptTermsOf(options: {
|
|
11
|
+
dictionary?: STTDictionaryEntry[];
|
|
12
|
+
vocabulary?: string[];
|
|
13
|
+
}): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Engine output through the guards: a looping phrase is collapsed and the
|
|
16
|
+
* share of words it took is recorded on `metadata.loopShare`; an output
|
|
17
|
+
* that is only the prompt echoed back becomes empty. `note` receives a
|
|
18
|
+
* human-readable line for each guard that fired.
|
|
19
|
+
*/
|
|
20
|
+
export declare function guardEngineResult(res: STTResult, promptTerms: readonly string[], note: (line: string) => void): STTResult;
|
|
4
21
|
export declare function runTranscribeStream(options: TranscribeStreamOptions, deps: TranscribeDeps): AsyncIterable<TranscribeStreamEvent>;
|