@juspay/neurolink 12.48.2 → 12.49.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 +2 -2
- package/dist/browser/neurolink.min.js +616 -595
- package/dist/cli/commands/serve.d.ts +0 -3
- package/dist/cli/commands/serve.js +85 -3
- package/dist/cli/commands/transcribe.d.ts +24 -0
- package/dist/cli/commands/transcribe.js +718 -0
- package/dist/cli/factories/commandFactory.d.ts +10 -0
- package/dist/cli/factories/commandFactory.js +13 -0
- package/dist/cli/parser.js +3 -0
- package/dist/factories/mediaHandlerCatalog.js +11 -9
- package/dist/factories/providerDescriptors.js +5 -4
- package/dist/factories/sttDescriptors.d.ts +63 -0
- package/dist/factories/sttDescriptors.js +286 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +7 -1
- package/dist/neurolink.d.ts +68 -1
- package/dist/neurolink.js +202 -4
- package/dist/processors/media/AudioProcessor.d.ts +15 -56
- package/dist/processors/media/AudioProcessor.js +105 -276
- package/dist/processors/media/VideoProcessor.d.ts +3 -5
- package/dist/processors/media/VideoProcessor.js +52 -51
- package/dist/providers/cloudflareClef.js +9 -1
- package/dist/server/abstract/baseServerAdapter.d.ts +7 -0
- package/dist/server/abstract/baseServerAdapter.js +13 -1
- package/dist/server/adapters/expressAdapter.d.ts +1 -0
- package/dist/server/adapters/expressAdapter.js +48 -4
- package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
- package/dist/server/adapters/fastifyAdapter.js +38 -1
- package/dist/server/adapters/honoAdapter.d.ts +1 -4
- package/dist/server/adapters/honoAdapter.js +83 -1
- package/dist/server/adapters/koaAdapter.d.ts +1 -0
- package/dist/server/adapters/koaAdapter.js +43 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.js +3 -0
- package/dist/server/routes/transcribeRoutes.d.ts +23 -0
- package/dist/server/routes/transcribeRoutes.js +506 -0
- package/dist/server/utils/rawBody.d.ts +32 -0
- package/dist/server/utils/rawBody.js +76 -0
- package/dist/server/utils/validation.d.ts +192 -0
- package/dist/server/utils/validation.js +100 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
- package/dist/server/websocket/transcribeWebSocket.js +264 -0
- package/dist/types/cli.d.ts +46 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/providers.d.ts +7 -1
- package/dist/types/server.d.ts +117 -1
- package/dist/types/stt.d.ts +160 -7
- package/dist/types/transcribe.d.ts +506 -0
- package/dist/types/transcribe.js +29 -0
- package/dist/utils/speakerTurns.d.ts +28 -0
- package/dist/utils/speakerTurns.js +93 -0
- package/dist/utils/sttProcessor.d.ts +20 -2
- package/dist/utils/sttProcessor.js +73 -5
- package/dist/voice/correction/dictionary.d.ts +49 -0
- package/dist/voice/correction/dictionary.js +134 -0
- package/dist/voice/correction/guard.d.ts +35 -0
- package/dist/voice/correction/guard.js +161 -0
- package/dist/voice/correction/index.d.ts +47 -0
- package/dist/voice/correction/index.js +211 -0
- package/dist/voice/correction/rewrite.d.ts +92 -0
- package/dist/voice/correction/rewrite.js +322 -0
- package/dist/voice/errors.d.ts +15 -0
- package/dist/voice/errors.js +45 -0
- package/dist/voice/index.d.ts +20 -2
- package/dist/voice/index.js +75 -9
- package/dist/voice/providers/AzureSTT.d.ts +10 -7
- package/dist/voice/providers/AzureSTT.js +23 -75
- package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
- package/dist/voice/providers/DeepgramSTT.js +109 -13
- package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
- package/dist/voice/providers/ElevenLabsSTT.js +34 -8
- package/dist/voice/providers/GoogleSTT.d.ts +10 -8
- package/dist/voice/providers/GoogleSTT.js +21 -75
- package/dist/voice/providers/OpenAISTT.d.ts +30 -9
- package/dist/voice/providers/OpenAISTT.js +196 -21
- package/dist/voice/providers/WhistleSTT.d.ts +87 -0
- package/dist/voice/providers/WhistleSTT.js +313 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
- package/dist/voice/streaming/endpointer.d.ts +81 -0
- package/dist/voice/streaming/endpointer.js +247 -0
- package/dist/voice/streaming/localAgreement.d.ts +30 -0
- package/dist/voice/streaming/localAgreement.js +44 -0
- package/dist/voice/sttEnvCredentials.d.ts +12 -0
- package/dist/voice/sttEnvCredentials.js +12 -0
- package/dist/voice/transcribe.d.ts +47 -0
- package/dist/voice/transcribe.js +519 -0
- package/dist/voice/transcribeStream.d.ts +4 -0
- package/dist/voice/transcribeStream.js +584 -0
- package/dist/voice/whistle/assets.d.ts +47 -0
- package/dist/voice/whistle/assets.js +270 -0
- package/dist/voice/whistle/audio.d.ts +32 -0
- package/dist/voice/whistle/audio.js +267 -0
- package/dist/voice/whistle/engine.d.ts +32 -0
- package/dist/voice/whistle/engine.js +193 -0
- package/dist/voice/whistle/worker.d.ts +18 -0
- package/dist/voice/whistle/worker.js +294 -0
- package/docs-site/static/search-index.json +71 -12
- package/models/whistle/README.md +18 -0
- package/models/whistle/needle.js +2 -0
- package/models/whistle/needle.wasm +0 -0
- package/models/whistle/whistle.cact +0 -0
- 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>;
|