@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +3 -6
  2. package/dist/browser/neurolink.min.js +480 -460
  3. package/dist/cli/commands/serve.js +11 -1
  4. package/dist/cli/commands/transcribe.js +39 -3
  5. package/dist/constants/langfuse.d.ts +2 -0
  6. package/dist/constants/langfuse.js +2 -0
  7. package/dist/factories/sttDescriptors.js +7 -0
  8. package/dist/index.d.ts +3 -2
  9. package/dist/index.js +4 -2
  10. package/dist/mcp/externalServerManager.js +4 -1
  11. package/dist/mcp/mcpClientFactory.d.ts +6 -0
  12. package/dist/mcp/mcpClientFactory.js +44 -0
  13. package/dist/neurolink.js +42 -3
  14. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  15. package/dist/observability/exporters/langfuseExporter.js +29 -8
  16. package/dist/observability/otlpEncoding.js +2 -2
  17. package/dist/server/abstract/baseServerAdapter.js +2 -0
  18. package/dist/server/adapters/expressAdapter.js +1 -1
  19. package/dist/server/adapters/fastifyAdapter.js +4 -1
  20. package/dist/server/adapters/honoAdapter.js +1 -1
  21. package/dist/server/adapters/koaAdapter.js +1 -1
  22. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  23. package/dist/server/routes/transcribeRoutes.js +130 -3
  24. package/dist/server/utils/validation.d.ts +409 -15
  25. package/dist/server/utils/validation.js +106 -4
  26. package/dist/server/websocket/frameQueue.d.ts +16 -0
  27. package/dist/server/websocket/frameQueue.js +55 -0
  28. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  29. package/dist/server/websocket/realtimeTranscription.js +603 -0
  30. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  31. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  32. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  33. package/dist/types/cli.d.ts +6 -0
  34. package/dist/types/exporter.d.ts +3 -1
  35. package/dist/types/server.d.ts +304 -2
  36. package/dist/types/stt.d.ts +38 -3
  37. package/dist/types/transcribe.d.ts +401 -5
  38. package/dist/voice/audioLevel.d.ts +14 -0
  39. package/dist/voice/audioLevel.js +60 -0
  40. package/dist/voice/codeMix.d.ts +40 -0
  41. package/dist/voice/codeMix.js +282 -0
  42. package/dist/voice/correction/align.d.ts +12 -0
  43. package/dist/voice/correction/align.js +48 -0
  44. package/dist/voice/correction/guards.d.ts +39 -0
  45. package/dist/voice/correction/guards.js +122 -0
  46. package/dist/voice/correction/index.d.ts +25 -3
  47. package/dist/voice/correction/index.js +132 -4
  48. package/dist/voice/correction/rewrite.d.ts +64 -3
  49. package/dist/voice/correction/rewrite.js +295 -25
  50. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  51. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  52. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  53. package/dist/voice/eval/sttEvalStore.js +397 -0
  54. package/dist/voice/index.js +3 -0
  55. package/dist/voice/languages.d.ts +34 -0
  56. package/dist/voice/languages.js +290 -0
  57. package/dist/voice/modelAssets.d.ts +28 -0
  58. package/dist/voice/modelAssets.js +113 -0
  59. package/dist/voice/output/render.d.ts +32 -0
  60. package/dist/voice/output/render.js +368 -0
  61. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  62. package/dist/voice/providers/OpenAISTT.js +57 -6
  63. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  64. package/dist/voice/session/transcriptionSession.js +225 -0
  65. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  66. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  67. package/dist/voice/streaming/endpointer.d.ts +50 -17
  68. package/dist/voice/streaming/endpointer.js +115 -19
  69. package/dist/voice/transcribe.d.ts +18 -1
  70. package/dist/voice/transcribe.js +459 -18
  71. package/dist/voice/transcribeStream.d.ts +18 -1
  72. package/dist/voice/transcribeStream.js +413 -16
  73. package/dist/voice/vad/assets.d.ts +36 -0
  74. package/dist/voice/vad/assets.js +142 -0
  75. package/dist/voice/vad/frameScorer.d.ts +28 -0
  76. package/dist/voice/vad/frameScorer.js +90 -0
  77. package/dist/voice/vad/sileroVad.d.ts +43 -0
  78. package/dist/voice/vad/sileroVad.js +178 -0
  79. package/dist/voice/whistle/assets.d.ts +3 -4
  80. package/dist/voice/whistle/assets.js +11 -95
  81. package/docs-site/static/search-index.json +21 -6
  82. package/models/silero-vad/README.md +19 -0
  83. package/models/silero-vad/silero_vad.onnx +0 -0
  84. package/package.json +3 -1
@@ -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 secondOpinionSpec = options.correction?.secondOpinion;
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
- try {
330
- primary = await deps.transcribe(buffer, provider, optionsFor(primaryModel));
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
- catch (error) {
333
- primaryError = error;
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 (fallback && fallbackProvider && fallbackReason) {
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
- ...seg,
436
- text: corrected.lines[i]?.trim() || seg.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 corrected = await correctTranscript({
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. Result.
487
- const { text: _engineText, ...chosenRest } = chosen;
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>;