@juspay/neurolink 12.48.1 → 12.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +616 -595
  3. package/dist/cli/commands/serve.d.ts +5 -3
  4. package/dist/cli/commands/serve.js +113 -20
  5. package/dist/cli/commands/server.js +13 -6
  6. package/dist/cli/commands/transcribe.d.ts +24 -0
  7. package/dist/cli/commands/transcribe.js +718 -0
  8. package/dist/cli/factories/commandFactory.d.ts +10 -0
  9. package/dist/cli/factories/commandFactory.js +13 -0
  10. package/dist/cli/parser.js +3 -0
  11. package/dist/cli/utils/serverUtils.d.ts +28 -0
  12. package/dist/cli/utils/serverUtils.js +71 -0
  13. package/dist/factories/mediaHandlerCatalog.js +11 -9
  14. package/dist/factories/sttDescriptors.d.ts +63 -0
  15. package/dist/factories/sttDescriptors.js +286 -0
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +7 -1
  18. package/dist/neurolink.d.ts +68 -1
  19. package/dist/neurolink.js +202 -4
  20. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  21. package/dist/processors/media/AudioProcessor.js +105 -276
  22. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  23. package/dist/processors/media/VideoProcessor.js +52 -51
  24. package/dist/server/abstract/baseServerAdapter.d.ts +28 -1
  25. package/dist/server/abstract/baseServerAdapter.js +84 -4
  26. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  27. package/dist/server/adapters/expressAdapter.js +50 -10
  28. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  29. package/dist/server/adapters/fastifyAdapter.js +51 -8
  30. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  31. package/dist/server/adapters/honoAdapter.js +95 -8
  32. package/dist/server/adapters/koaAdapter.d.ts +7 -0
  33. package/dist/server/adapters/koaAdapter.js +71 -10
  34. package/dist/server/index.d.ts +3 -2
  35. package/dist/server/index.js +3 -2
  36. package/dist/server/routes/index.d.ts +1 -0
  37. package/dist/server/routes/index.js +3 -0
  38. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  39. package/dist/server/routes/transcribeRoutes.js +506 -0
  40. package/dist/server/utils/rawBody.d.ts +32 -0
  41. package/dist/server/utils/rawBody.js +76 -0
  42. package/dist/server/utils/validation.d.ts +192 -0
  43. package/dist/server/utils/validation.js +100 -0
  44. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  45. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  46. package/dist/types/cli.d.ts +47 -0
  47. package/dist/types/index.d.ts +1 -0
  48. package/dist/types/index.js +1 -0
  49. package/dist/types/providers.d.ts +7 -1
  50. package/dist/types/server.d.ts +117 -1
  51. package/dist/types/stt.d.ts +160 -7
  52. package/dist/types/transcribe.d.ts +506 -0
  53. package/dist/types/transcribe.js +29 -0
  54. package/dist/utils/speakerTurns.d.ts +28 -0
  55. package/dist/utils/speakerTurns.js +93 -0
  56. package/dist/utils/sttProcessor.d.ts +20 -2
  57. package/dist/utils/sttProcessor.js +73 -5
  58. package/dist/voice/correction/dictionary.d.ts +49 -0
  59. package/dist/voice/correction/dictionary.js +134 -0
  60. package/dist/voice/correction/guard.d.ts +35 -0
  61. package/dist/voice/correction/guard.js +161 -0
  62. package/dist/voice/correction/index.d.ts +47 -0
  63. package/dist/voice/correction/index.js +211 -0
  64. package/dist/voice/correction/rewrite.d.ts +92 -0
  65. package/dist/voice/correction/rewrite.js +322 -0
  66. package/dist/voice/errors.d.ts +15 -0
  67. package/dist/voice/errors.js +45 -0
  68. package/dist/voice/index.d.ts +20 -2
  69. package/dist/voice/index.js +75 -9
  70. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  71. package/dist/voice/providers/AzureSTT.js +23 -75
  72. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  73. package/dist/voice/providers/DeepgramSTT.js +109 -13
  74. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  75. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  76. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  77. package/dist/voice/providers/GoogleSTT.js +21 -75
  78. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  79. package/dist/voice/providers/OpenAISTT.js +196 -21
  80. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  81. package/dist/voice/providers/WhistleSTT.js +313 -0
  82. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  83. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  84. package/dist/voice/streaming/endpointer.d.ts +81 -0
  85. package/dist/voice/streaming/endpointer.js +247 -0
  86. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  87. package/dist/voice/streaming/localAgreement.js +44 -0
  88. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  89. package/dist/voice/sttEnvCredentials.js +12 -0
  90. package/dist/voice/transcribe.d.ts +47 -0
  91. package/dist/voice/transcribe.js +519 -0
  92. package/dist/voice/transcribeStream.d.ts +4 -0
  93. package/dist/voice/transcribeStream.js +584 -0
  94. package/dist/voice/whistle/assets.d.ts +47 -0
  95. package/dist/voice/whistle/assets.js +270 -0
  96. package/dist/voice/whistle/audio.d.ts +32 -0
  97. package/dist/voice/whistle/audio.js +267 -0
  98. package/dist/voice/whistle/engine.d.ts +32 -0
  99. package/dist/voice/whistle/engine.js +193 -0
  100. package/dist/voice/whistle/worker.d.ts +18 -0
  101. package/dist/voice/whistle/worker.js +294 -0
  102. package/docs-site/static/search-index.json +69 -10
  103. package/models/whistle/README.md +18 -0
  104. package/models/whistle/needle.js +2 -0
  105. package/models/whistle/needle.wasm +0 -0
  106. package/models/whistle/whistle.cact +0 -0
  107. package/package.json +7 -3
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Batch transcription orchestration behind `neurolink.transcribe()`:
3
+ *
4
+ * load audio → resolve provider → transcribe (+ second opinion in parallel)
5
+ * → fallback → correction → TranscribeResult
6
+ *
7
+ * Everything that touches the SDK (handlers, credentials, `tryDecide`, the
8
+ * rewrite model) arrives through `TranscribeDeps`, so this module stays free
9
+ * of provider plumbing. Nothing here may lose words: a failed second opinion
10
+ * or fallback keeps the primary text, and a correction that comes back empty
11
+ * keeps the engine text.
12
+ *
13
+ * @module voice/transcribe
14
+ */
15
+ import { promises as fs } from "fs";
16
+ import { extname } from "path";
17
+ import { fileURLToPath } from "url";
18
+ import { logger } from "../utils/logger.js";
19
+ import { SpanSerializer, SpanStatus, SpanType, getMetricsAggregator, } from "../observability/index.js";
20
+ import { detectAudioFormat } from "./audio-utils.js";
21
+ import { correctTranscript, correctTranscriptLines, isCorrectionEnabled, } from "./correction/index.js";
22
+ /** `NEUROLINK_STT_CORRECT_WHOLE=1` forces one whole-transcript rewrite even for segmented results. */
23
+ function correctionLinesDisabled() {
24
+ const v = process.env.NEUROLINK_STT_CORRECT_WHOLE?.trim().toLowerCase();
25
+ return v === "1" || v === "true";
26
+ }
27
+ import { STTError } from "./errors.js";
28
+ /** Default timeout for fetching an `http(s)://` audio input. */
29
+ const URL_FETCH_TIMEOUT_MS = 60_000;
30
+ /** Container names an extension or magic number can map to. */
31
+ const KNOWN_FORMATS = [
32
+ "mp3",
33
+ "wav",
34
+ "ogg",
35
+ "opus",
36
+ "m4a",
37
+ "flac",
38
+ "webm",
39
+ "mp4",
40
+ "mpeg",
41
+ "mpga",
42
+ ];
43
+ // ============================================================================
44
+ // AUDIO INPUT
45
+ // ============================================================================
46
+ /** True for an async stream of frames, which only `transcribeStream()` takes. */
47
+ function isAsyncIterableInput(input) {
48
+ return (typeof input === "object" &&
49
+ input !== null &&
50
+ !Buffer.isBuffer(input) &&
51
+ !(input instanceof ArrayBuffer) &&
52
+ !(input instanceof Uint8Array) &&
53
+ Symbol.asyncIterator in input);
54
+ }
55
+ /** The container a path or URL names by its extension, if it is a known one. */
56
+ export function audioFormatFromName(name) {
57
+ const ext = extname(name.split(/[?#]/, 1)[0] ?? name)
58
+ .slice(1)
59
+ .toLowerCase();
60
+ const normalized = ext === "oga" ? "ogg" : ext;
61
+ return KNOWN_FORMATS.find((format) => format === normalized);
62
+ }
63
+ /**
64
+ * The container a buffer's leading bytes identify: WAV, MP3 and Ogg via the
65
+ * shared detector, plus FLAC, WebM/Matroska and ISO-BMFF (m4a/mp4).
66
+ */
67
+ export function audioFormatFromBytes(buffer) {
68
+ const detected = detectAudioFormat(buffer);
69
+ if (detected) {
70
+ return detected;
71
+ }
72
+ if (buffer.length >= 4 &&
73
+ buffer.subarray(0, 4).toString("ascii") === "fLaC") {
74
+ return "flac";
75
+ }
76
+ if (buffer.length >= 4 &&
77
+ buffer[0] === 0x1a &&
78
+ buffer[1] === 0x45 &&
79
+ buffer[2] === 0xdf &&
80
+ buffer[3] === 0xa3) {
81
+ return "webm";
82
+ }
83
+ if (buffer.length >= 8 &&
84
+ buffer.subarray(4, 8).toString("ascii") === "ftyp") {
85
+ return "m4a";
86
+ }
87
+ return undefined;
88
+ }
89
+ /**
90
+ * Read a batch audio input into a Buffer: bytes as they are, a local path or
91
+ * `file://` URL from disk, an `http(s)://` URL over the network. A format
92
+ * implied by the path or URL is returned alongside.
93
+ *
94
+ * @throws STTError when the input is a frame stream, or cannot be read
95
+ */
96
+ export async function loadTranscribeAudio(input, timeoutMs) {
97
+ if (Buffer.isBuffer(input)) {
98
+ return { buffer: input };
99
+ }
100
+ if (input instanceof ArrayBuffer) {
101
+ return { buffer: Buffer.from(input) };
102
+ }
103
+ if (input instanceof Uint8Array) {
104
+ return {
105
+ buffer: Buffer.from(input.buffer, input.byteOffset, input.byteLength),
106
+ };
107
+ }
108
+ if (typeof input !== "string") {
109
+ if (isAsyncIterableInput(input)) {
110
+ throw STTError.streamInputNotSupported();
111
+ }
112
+ throw STTError.audioLoadFailed("expected a Buffer, ArrayBuffer, Uint8Array, path or URL");
113
+ }
114
+ const source = input.trim();
115
+ if (/^https?:\/\//i.test(source)) {
116
+ const controller = new AbortController();
117
+ const limit = timeoutMs ?? URL_FETCH_TIMEOUT_MS;
118
+ const timer = setTimeout(() => controller.abort(), limit);
119
+ try {
120
+ const response = await fetch(source, { signal: controller.signal });
121
+ if (!response.ok) {
122
+ throw STTError.audioLoadFailed(`fetching the audio URL returned HTTP ${response.status}`);
123
+ }
124
+ const buffer = Buffer.from(await response.arrayBuffer());
125
+ return {
126
+ buffer,
127
+ formatHint: audioFormatFromName(new URL(source).pathname),
128
+ };
129
+ }
130
+ catch (error) {
131
+ if (error instanceof STTError) {
132
+ throw error;
133
+ }
134
+ const aborted = error instanceof Error && error.name === "AbortError";
135
+ throw STTError.audioLoadFailed(aborted
136
+ ? `fetching the audio URL timed out after ${limit}ms`
137
+ : `fetching the audio URL failed: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
138
+ }
139
+ finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
143
+ let path;
144
+ try {
145
+ path = /^file:/i.test(source) ? fileURLToPath(source) : source;
146
+ }
147
+ catch (error) {
148
+ throw STTError.audioLoadFailed("the file: URL is not a valid local path", error instanceof Error ? error : undefined);
149
+ }
150
+ try {
151
+ return {
152
+ buffer: await fs.readFile(path),
153
+ formatHint: audioFormatFromName(path),
154
+ };
155
+ }
156
+ catch (error) {
157
+ const code = error?.code;
158
+ throw STTError.audioLoadFailed(code === "ENOENT"
159
+ ? "no file at the given path"
160
+ : `reading the file failed${code ? ` (${code})` : ""}`, error instanceof Error ? error : undefined);
161
+ }
162
+ }
163
+ // ============================================================================
164
+ // OPTIONS
165
+ // ============================================================================
166
+ /** `undefined` for "let the engine detect" (omitted, empty or "auto"). */
167
+ function normalizeLanguage(language) {
168
+ const trimmed = language?.trim();
169
+ return trimmed && trimmed.toLowerCase() !== "auto" ? trimmed : undefined;
170
+ }
171
+ /** Dictionary terms merged into the caller's vocabulary, without duplicates. */
172
+ function mergeVocabulary(vocabulary, dictionary) {
173
+ const seen = new Set();
174
+ const out = [];
175
+ for (const term of [
176
+ ...(vocabulary ?? []),
177
+ ...(dictionary ?? []).map((entry) => entry.term),
178
+ ]) {
179
+ const trimmed = typeof term === "string" ? term.trim() : "";
180
+ if (trimmed && !seen.has(trimmed.toLowerCase())) {
181
+ seen.add(trimmed.toLowerCase());
182
+ out.push(trimmed);
183
+ }
184
+ }
185
+ return out.length > 0 ? out : undefined;
186
+ }
187
+ /**
188
+ * The handler options for one engine. Fields only the orchestration reads
189
+ * (dictionary, correction, fallback, streaming, credentials) are dropped;
190
+ * the correction context rides along in the prompt for engines that take one.
191
+ */
192
+ 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;
194
+ const contextPrompt = [prompt?.trim(), correction?.context?.trim()]
195
+ .filter((part) => !!part)
196
+ .join(" ");
197
+ const engineOptions = { ...rest };
198
+ const resolvedLanguage = normalizeLanguage(language);
199
+ if (resolvedLanguage) {
200
+ engineOptions.language = resolvedLanguage;
201
+ }
202
+ if (format) {
203
+ engineOptions.format = format;
204
+ }
205
+ if (model) {
206
+ engineOptions.model = model;
207
+ }
208
+ if (contextPrompt) {
209
+ engineOptions.prompt = contextPrompt;
210
+ }
211
+ const mergedVocabulary = mergeVocabulary(vocabulary, dictionary);
212
+ if (mergedVocabulary) {
213
+ engineOptions.vocabulary = mergedVocabulary;
214
+ }
215
+ return engineOptions;
216
+ }
217
+ /** Whether the correction layer runs — the one rule shared with the correction layer and the stream. */
218
+ function correctionWanted(options) {
219
+ return isCorrectionEnabled(options.dictionary, options.correction);
220
+ }
221
+ // ============================================================================
222
+ // RESULT HELPERS
223
+ // ============================================================================
224
+ /** `metadata.languageDetected`, when the engine reported one. */
225
+ function readLanguageDetected(result) {
226
+ const value = result.metadata?.languageDetected;
227
+ return typeof value === "boolean" ? value : undefined;
228
+ }
229
+ /** `metadata.languageScores`, keeping only well-formed entries. */
230
+ function readLanguageScores(result) {
231
+ const value = result.metadata?.languageScores;
232
+ if (!Array.isArray(value)) {
233
+ return undefined;
234
+ }
235
+ const scores = [];
236
+ for (const entry of value) {
237
+ if (typeof entry === "object" &&
238
+ entry !== null &&
239
+ "language" in entry &&
240
+ "score" in entry &&
241
+ typeof entry.language === "string" &&
242
+ typeof entry.score === "number" &&
243
+ Number.isFinite(entry.score)) {
244
+ scores.push({ language: entry.language, score: entry.score });
245
+ }
246
+ }
247
+ return scores.length > 0 ? scores : undefined;
248
+ }
249
+ function errorMessage(error) {
250
+ return error instanceof Error ? error.message : String(error);
251
+ }
252
+ /** The model an engine reported, else the one that was asked for. */
253
+ function reportedModel(result, requested) {
254
+ const model = result.metadata?.model;
255
+ return typeof model === "string" ? model : requested;
256
+ }
257
+ // ============================================================================
258
+ // ORCHESTRATION
259
+ // ============================================================================
260
+ /**
261
+ * Transcribe one recording with every configured layer. See the module
262
+ * comment for the order of operations.
263
+ *
264
+ * @throws STTError when the audio cannot be loaded, the provider is unknown,
265
+ * or the primary engine fails with no fallback that covers errors
266
+ */
267
+ export async function runTranscribe(options, deps) {
268
+ const startedAt = Date.now();
269
+ const span = SpanSerializer.createSpan(SpanType.STT, "stt.transcribe", {
270
+ "stt.operation": "transcribe",
271
+ "stt.provider": options.provider,
272
+ "stt.language": options.language,
273
+ "stt.format": options.format,
274
+ });
275
+ try {
276
+ const result = await transcribeLayers(options, deps, startedAt);
277
+ const endedSpan = SpanSerializer.endSpan(SpanSerializer.updateAttributes(span, {
278
+ "stt.provider": result.engine.provider,
279
+ "stt.fallback_used": result.engine.fallbackUsed === true,
280
+ "stt.corrected": result.corrected !== undefined,
281
+ }), SpanStatus.OK);
282
+ getMetricsAggregator().recordSpan(endedSpan);
283
+ return result;
284
+ }
285
+ catch (error) {
286
+ const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR, errorMessage(error));
287
+ getMetricsAggregator().recordSpan(endedSpan);
288
+ throw error;
289
+ }
290
+ }
291
+ async function transcribeLayers(options, deps, startedAt) {
292
+ const steps = [];
293
+ // 1. Audio, and its container when the caller did not name one.
294
+ const { buffer, formatHint } = await loadTranscribeAudio(options.audio, options.timeoutMs);
295
+ if (buffer.length === 0) {
296
+ throw STTError.audioEmpty(options.provider);
297
+ }
298
+ const format = options.format ?? formatHint ?? audioFormatFromBytes(buffer);
299
+ // 2. Engines. A model from the environment only applies to the provider
300
+ // the environment picked; an explicit provider gets only an explicit model.
301
+ const explicitProvider = !!options.provider?.trim();
302
+ const provider = deps.resolveProvider(options.provider);
303
+ const envModel = process.env.NEUROLINK_STT_MODEL?.trim() || undefined;
304
+ const primaryModel = options.model ?? (explicitProvider ? undefined : envModel);
305
+ const engineBase = {
306
+ ...options,
307
+ language: options.language ?? process.env.NEUROLINK_STT_LANGUAGE,
308
+ };
309
+ const optionsFor = (model) => buildEngineOptions(engineBase, format, model);
310
+ // 3. Primary and second opinion, in parallel.
311
+ const secondOpinionSpec = options.correction?.secondOpinion;
312
+ const secondProvider = secondOpinionSpec
313
+ ? deps.resolveProvider(secondOpinionSpec.provider)
314
+ : undefined;
315
+ const secondStarted = Date.now();
316
+ const secondPromise = secondProvider && secondOpinionSpec
317
+ ? deps
318
+ .transcribe(buffer, secondProvider, optionsFor(secondOpinionSpec.model))
319
+ .then((result) => ({ result, ms: Date.now() - secondStarted }), (error) => ({ error, ms: Date.now() - secondStarted }))
320
+ : null;
321
+ const primaryStarted = Date.now();
322
+ const fallback = options.fallback;
323
+ const fallbackWhen = new Set(fallback?.when ?? ["unsure", "empty"]);
324
+ const fallbackProvider = fallback
325
+ ? deps.resolveProvider(fallback.provider)
326
+ : undefined;
327
+ let primary;
328
+ let primaryError;
329
+ try {
330
+ primary = await deps.transcribe(buffer, provider, optionsFor(primaryModel));
331
+ }
332
+ catch (error) {
333
+ primaryError = error;
334
+ }
335
+ // 4. Fallback: on error, on an empty transcript, or when the engine said
336
+ // it could not identify the language — whichever `when` asks for.
337
+ let chosen = primary;
338
+ let chosenProvider = provider;
339
+ let chosenModel = primaryModel;
340
+ let fallbackUsed = false;
341
+ const fallbackReason = primaryError !== undefined
342
+ ? fallbackWhen.has("error")
343
+ ? "error"
344
+ : undefined
345
+ : primary && !primary.text.trim() && fallbackWhen.has("empty")
346
+ ? "empty"
347
+ : primary &&
348
+ readLanguageDetected(primary) === false &&
349
+ fallbackWhen.has("unsure")
350
+ ? "unsure"
351
+ : undefined;
352
+ if (fallback && fallbackProvider && fallbackReason) {
353
+ if (fallbackProvider === provider && fallback.model === primaryModel) {
354
+ steps.push(`fallback skipped · same engine as primary`);
355
+ }
356
+ else {
357
+ try {
358
+ const fallbackResult = await deps.transcribe(buffer, fallbackProvider, optionsFor(fallback.model));
359
+ if (fallbackResult.text.trim() || !primary?.text.trim()) {
360
+ chosen = fallbackResult;
361
+ chosenProvider = fallbackProvider;
362
+ chosenModel = fallback.model;
363
+ fallbackUsed = true;
364
+ steps.push(`fallback ${fallbackProvider} (${fallbackReason})`);
365
+ }
366
+ else {
367
+ steps.push(`fallback ${fallbackProvider} empty · kept primary`);
368
+ }
369
+ }
370
+ catch (error) {
371
+ steps.push(`fallback ${fallbackProvider} failed · kept primary`);
372
+ if (logger.shouldLog("debug")) {
373
+ logger.debug(`[transcribe] fallback ${fallbackProvider} failed: ${errorMessage(error)}`);
374
+ }
375
+ }
376
+ }
377
+ }
378
+ if (!chosen) {
379
+ // Primary failed and nothing replaced it. Let the second opinion settle
380
+ // first so its request is not left running unobserved.
381
+ await secondPromise;
382
+ throw primaryError instanceof Error
383
+ ? primaryError
384
+ : STTError.transcriptionFailed(errorMessage(primaryError), provider);
385
+ }
386
+ const transcribeMs = Date.now() - primaryStarted;
387
+ steps.unshift(`transcribed ${chosenProvider}`);
388
+ // 5. Second opinion.
389
+ let secondText;
390
+ let secondOpinionMs;
391
+ if (secondPromise && secondProvider) {
392
+ const settled = await secondPromise;
393
+ secondOpinionMs = settled.ms;
394
+ if (fallbackUsed && secondProvider === chosenProvider) {
395
+ // The fallback already produced the text with this very engine; a
396
+ // "second opinion" from it would be the same transcript twice.
397
+ steps.push(`second opinion ${secondProvider} skipped · it is the fallback`);
398
+ }
399
+ else if ("result" in settled) {
400
+ secondText = settled.result.text.trim() || undefined;
401
+ steps.push(secondText
402
+ ? `second opinion ${secondProvider}`
403
+ : `second opinion ${secondProvider} empty`);
404
+ }
405
+ else {
406
+ steps.push(`second opinion ${secondProvider} failed`);
407
+ if (logger.shouldLog("debug")) {
408
+ logger.debug(`[transcribe] second opinion ${secondProvider} failed: ${errorMessage(settled.error)}`);
409
+ }
410
+ }
411
+ }
412
+ // 6. Correction. The primary text leads unless the primary engine said it
413
+ // could not identify the language and a second opinion exists.
414
+ const languageDetected = readLanguageDetected(chosen);
415
+ const languageScores = readLanguageScores(chosen);
416
+ const engineText = chosen.text;
417
+ const secondLeads = languageDetected === false && !!secondText;
418
+ const raw = secondLeads && secondText ? secondText : engineText;
419
+ let finalText = raw;
420
+ let decisions;
421
+ const correctionTimings = {};
422
+ // A multi-segment transcript (diarized turns, long files) is corrected
423
+ // segment by segment so speakers and timings stay aligned with the text;
424
+ // a second opinion is whole-transcript and keeps the single-text path.
425
+ const segmentTexts = chosen.segments?.map((seg) => seg.text) ?? [];
426
+ const perSegment = segmentTexts.length > 1 && !secondText && !correctionLinesDisabled();
427
+ let correctedSegments;
428
+ if (correctionWanted(options) && perSegment && chosen.segments) {
429
+ try {
430
+ const corrected = await correctTranscriptLines(segmentTexts, options.dictionary, options.correction, deps);
431
+ decisions = corrected.decisions;
432
+ steps.push(...corrected.steps);
433
+ Object.assign(correctionTimings, corrected.timings);
434
+ correctedSegments = chosen.segments.map((seg, i) => ({
435
+ ...seg,
436
+ text: corrected.lines[i]?.trim() || seg.text,
437
+ }));
438
+ const joined = correctedSegments
439
+ .map((seg) => seg.text)
440
+ .filter(Boolean)
441
+ .join(" ");
442
+ if (joined.trim()) {
443
+ finalText = joined;
444
+ }
445
+ else {
446
+ steps.push("correction returned nothing · kept text");
447
+ }
448
+ }
449
+ catch (error) {
450
+ steps.push("correction failed · kept text");
451
+ correctedSegments = undefined;
452
+ if (logger.shouldLog("debug")) {
453
+ logger.debug(`[transcribe] correction failed: ${errorMessage(error)}`);
454
+ }
455
+ }
456
+ }
457
+ else if (correctionWanted(options)) {
458
+ try {
459
+ const corrected = await correctTranscript({
460
+ text: engineText,
461
+ ...(secondText ? { secondOpinion: secondText } : {}),
462
+ ...(options.dictionary ? { dictionary: options.dictionary } : {}),
463
+ ...(options.correction ? { options: options.correction } : {}),
464
+ ...(languageDetected !== undefined ? { languageDetected } : {}),
465
+ }, deps);
466
+ decisions = corrected.decisions;
467
+ steps.push(...corrected.steps);
468
+ Object.assign(correctionTimings, corrected.timings);
469
+ if (corrected.text.trim()) {
470
+ finalText = corrected.text;
471
+ }
472
+ else {
473
+ steps.push("correction returned nothing · kept text");
474
+ }
475
+ }
476
+ catch (error) {
477
+ steps.push("correction failed · kept text");
478
+ if (logger.shouldLog("debug")) {
479
+ logger.debug(`[transcribe] correction failed: ${errorMessage(error)}`);
480
+ }
481
+ }
482
+ }
483
+ else if (secondLeads) {
484
+ steps.push("second opinion leads (primary language unsure)");
485
+ }
486
+ // 7. Result.
487
+ const { text: _engineText, ...chosenRest } = chosen;
488
+ const result = {
489
+ ...chosenRest,
490
+ ...(correctedSegments ? { segments: correctedSegments } : {}),
491
+ text: finalText,
492
+ raw,
493
+ ...(finalText !== raw ? { corrected: finalText } : {}),
494
+ ...(languageDetected !== undefined ? { languageDetected } : {}),
495
+ ...(languageScores ? { languageScores } : {}),
496
+ ...(decisions && decisions.length > 0 ? { decisions } : {}),
497
+ engine: {
498
+ provider: chosenProvider,
499
+ ...(reportedModel(chosen, chosenModel)
500
+ ? { model: reportedModel(chosen, chosenModel) }
501
+ : {}),
502
+ ...(fallbackUsed ? { fallbackUsed: true } : {}),
503
+ ...(secondText && secondProvider
504
+ ? { secondOpinion: secondProvider }
505
+ : {}),
506
+ },
507
+ timings: {
508
+ transcribeMs,
509
+ ...(secondOpinionMs !== undefined ? { secondOpinionMs } : {}),
510
+ ...correctionTimings,
511
+ totalMs: Date.now() - startedAt,
512
+ },
513
+ steps,
514
+ };
515
+ if (logger.shouldLog("debug")) {
516
+ logger.debug(`[transcribe] ${chosenProvider}: ${raw.length} chars raw, ${finalText.length} final in ${result.timings.totalMs}ms`, { steps });
517
+ }
518
+ return result;
519
+ }
@@ -0,0 +1,4 @@
1
+ import type { TranscribeDeps, TranscribeStreamEvent, TranscribeStreamOptions } from "../types/index.js";
2
+ /** Float samples in [-1, 1] → 16 kHz mono PCM16 WAV. */
3
+ export declare function encodeWav16k(pcm: Float32Array): Buffer;
4
+ export declare function runTranscribeStream(options: TranscribeStreamOptions, deps: TranscribeDeps): AsyncIterable<TranscribeStreamEvent>;