@juspay/neurolink 12.31.1 → 12.32.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 -3
- package/dist/browser/neurolink.min.js +393 -390
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/core/modules/MessageBuilder.js +8 -0
- package/dist/neurolink.js +4 -0
- package/dist/processors/media/AudioProcessor.d.ts +87 -10
- package/dist/processors/media/AudioProcessor.js +341 -47
- package/dist/types/file.d.ts +69 -1
- package/dist/types/generate.d.ts +51 -0
- package/dist/types/processor.d.ts +19 -0
- package/dist/types/stream.d.ts +17 -0
- package/dist/utils/fileDetector.js +40 -18
- package/dist/utils/messageBuilder.js +66 -5
- package/docs-site/static/search-index.json +4323 -4311
- package/package.json +3 -1
|
@@ -107,6 +107,96 @@ const SUPPORTED_AUDIO_MIME_TYPES = [
|
|
|
107
107
|
* Derived from the canonical registry — see the note above.
|
|
108
108
|
*/
|
|
109
109
|
const SUPPORTED_AUDIO_EXTENSIONS = extensionsForModality("audio");
|
|
110
|
+
/**
|
|
111
|
+
* Transcription backends this processor can drive, in auto-selection order
|
|
112
|
+
* (#413). OpenAI first because Whisper is the only one with a native path
|
|
113
|
+
* here; Google and Azure are delegated to the STT handlers under
|
|
114
|
+
* `src/lib/voice/providers/`, which already speak those wire formats.
|
|
115
|
+
*
|
|
116
|
+
* The order is the preference order and nothing else — a backend is only
|
|
117
|
+
* chosen if `isProviderAvailable` says its credentials are present.
|
|
118
|
+
*/
|
|
119
|
+
const TRANSCRIPTION_PROVIDER_ORDER = [
|
|
120
|
+
"openai",
|
|
121
|
+
"google",
|
|
122
|
+
"azure",
|
|
123
|
+
];
|
|
124
|
+
/**
|
|
125
|
+
* Environment variables that make each backend usable, and the label used in
|
|
126
|
+
* log lines and in `ProcessedAudio.transcriptionProvider`.
|
|
127
|
+
*
|
|
128
|
+
* Google accepts four credential vars: `.env.example` documents
|
|
129
|
+
* `GOOGLE_AI_API_KEY`/`GEMINI_API_KEY` as aliases of `GOOGLE_API_KEY`, and
|
|
130
|
+
* `GOOGLE_APPLICATION_CREDENTIALS` (a service-account key file) is a fourth,
|
|
131
|
+
* independent credential `GoogleSTT` accepts on its own. All four are kept in
|
|
132
|
+
* sync here so availability cannot disagree with what the handler will accept.
|
|
133
|
+
*/
|
|
134
|
+
const TRANSCRIPTION_PROVIDER_CREDENTIALS = {
|
|
135
|
+
openai: { label: "openai-whisper", envVars: ["OPENAI_API_KEY"] },
|
|
136
|
+
google: {
|
|
137
|
+
label: "google-stt",
|
|
138
|
+
// GoogleSTT.isConfigured() also accepts a service-account file via
|
|
139
|
+
// GOOGLE_APPLICATION_CREDENTIALS (constructor in voice/providers/GoogleSTT.ts)
|
|
140
|
+
// — listed here too so availability cannot disagree with what the handler
|
|
141
|
+
// will actually accept.
|
|
142
|
+
envVars: [
|
|
143
|
+
"GOOGLE_API_KEY",
|
|
144
|
+
"GOOGLE_AI_API_KEY",
|
|
145
|
+
"GEMINI_API_KEY",
|
|
146
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
azure: { label: "azure-stt", envVars: ["AZURE_SPEECH_KEY"] },
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Caller-facing aliases for a backend name. `AudioProcessorOptions.provider`
|
|
153
|
+
* is a free-form `string` on the public surface, so "whisper" and
|
|
154
|
+
* "openai-whisper" have to land on the same backend as "openai" rather than
|
|
155
|
+
* being rejected as unknown.
|
|
156
|
+
*/
|
|
157
|
+
const TRANSCRIPTION_PROVIDER_ALIASES = {
|
|
158
|
+
openai: "openai",
|
|
159
|
+
whisper: "openai",
|
|
160
|
+
"openai-whisper": "openai",
|
|
161
|
+
google: "google",
|
|
162
|
+
"google-stt": "google",
|
|
163
|
+
"google-speech": "google",
|
|
164
|
+
azure: "azure",
|
|
165
|
+
"azure-stt": "azure",
|
|
166
|
+
"azure-speech": "azure",
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Map an audio mimetype/extension onto the format vocabulary the STT handlers
|
|
170
|
+
* expect. Returns undefined when nothing matches, which leaves the handler on
|
|
171
|
+
* its own "wav" default rather than asserting a format that is wrong.
|
|
172
|
+
*/
|
|
173
|
+
function toSTTAudioFormat(extension, mimetype) {
|
|
174
|
+
const candidates = [
|
|
175
|
+
"mp3",
|
|
176
|
+
"wav",
|
|
177
|
+
"ogg",
|
|
178
|
+
"opus",
|
|
179
|
+
"m4a",
|
|
180
|
+
"flac",
|
|
181
|
+
"webm",
|
|
182
|
+
"mp4",
|
|
183
|
+
"mpeg",
|
|
184
|
+
"mpga",
|
|
185
|
+
];
|
|
186
|
+
if (extension) {
|
|
187
|
+
const match = candidates.find((format) => format === extension);
|
|
188
|
+
if (match) {
|
|
189
|
+
return match;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const subtype = mimetype?.split(";")[0].trim().toLowerCase().split("/")[1];
|
|
193
|
+
if (!subtype) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
// "audio/x-m4a" and "audio/x-wav" are both in the wild.
|
|
197
|
+
const normalized = subtype.startsWith("x-") ? subtype.slice(2) : subtype;
|
|
198
|
+
return candidates.find((format) => format === normalized);
|
|
199
|
+
}
|
|
110
200
|
// =============================================================================
|
|
111
201
|
// AUDIO PROCESSOR CLASS
|
|
112
202
|
// =============================================================================
|
|
@@ -167,6 +257,9 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
167
257
|
*
|
|
168
258
|
* @param fileInfo - File information (can include URL or buffer)
|
|
169
259
|
* @param options - Optional processing options (auth headers, timeout, etc.)
|
|
260
|
+
* widened with the transcription knobs (#413/#440) so a caller-chosen
|
|
261
|
+
* backend, language or prompt reaches the transcriber instead of being
|
|
262
|
+
* discarded at the detector boundary.
|
|
170
263
|
* @returns Processing result with audio metadata or error
|
|
171
264
|
*/
|
|
172
265
|
async processFile(fileInfo, options) {
|
|
@@ -229,11 +322,12 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
229
322
|
const tags = this.extractTags(audioMetadata);
|
|
230
323
|
// Step 6: Extract embedded cover art if present
|
|
231
324
|
const coverArt = await this.extractCoverArt(audioMetadata);
|
|
232
|
-
// Step 7: Attempt transcription if
|
|
325
|
+
// Step 7: Attempt transcription if a backend is configured
|
|
233
326
|
const filename = this.getFilename(fileInfo);
|
|
234
|
-
const transcriptionResult = await this.attemptTranscription(buffer, filename, fileInfo.mimetype);
|
|
235
|
-
// Step 8: Build LLM-friendly text content (includes transcript if
|
|
236
|
-
|
|
327
|
+
const transcriptionResult = await this.attemptTranscription(buffer, filename, fileInfo.mimetype, options);
|
|
328
|
+
// Step 8: Build LLM-friendly text content (includes transcript if
|
|
329
|
+
// available, or the skip reason when it is not — #471)
|
|
330
|
+
const textContent = this.buildTextContent(filename, metadata, tags, transcriptionResult.transcript, transcriptionResult.transcriptionSkippedReason);
|
|
237
331
|
return {
|
|
238
332
|
success: true,
|
|
239
333
|
data: {
|
|
@@ -243,6 +337,8 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
243
337
|
transcript: transcriptionResult.transcript,
|
|
244
338
|
hasTranscript: transcriptionResult.hasTranscript,
|
|
245
339
|
transcriptionProvider: transcriptionResult.transcriptionProvider,
|
|
340
|
+
transcriptionLanguage: transcriptionResult.transcriptionLanguage,
|
|
341
|
+
transcriptionDuration: transcriptionResult.transcriptionDuration,
|
|
246
342
|
...(transcriptionResult.transcriptionSkippedReason
|
|
247
343
|
? {
|
|
248
344
|
transcriptionSkippedReason: transcriptionResult.transcriptionSkippedReason,
|
|
@@ -283,13 +379,74 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
283
379
|
// PRIVATE: AUDIO TRANSCRIPTION
|
|
284
380
|
// ===========================================================================
|
|
285
381
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
382
|
+
* Whether a transcription backend has the credentials it needs (#413).
|
|
383
|
+
*
|
|
384
|
+
* Availability is decided from the environment only — no network call — so
|
|
385
|
+
* selection stays cheap and cannot itself fail. A key that is present but
|
|
386
|
+
* rejected upstream surfaces later, as a transcription failure with the
|
|
387
|
+
* provider's own message, not as "unavailable".
|
|
388
|
+
*
|
|
389
|
+
* @param provider - Backend to check
|
|
390
|
+
* @returns True when at least one of the backend's credential vars is set
|
|
391
|
+
*/
|
|
392
|
+
isProviderAvailable(provider) {
|
|
393
|
+
const { envVars } = TRANSCRIPTION_PROVIDER_CREDENTIALS[provider];
|
|
394
|
+
return envVars.some((name) => (process.env[name] ?? "").trim().length > 0);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Choose the transcription backend for this file (#413).
|
|
398
|
+
*
|
|
399
|
+
* With no `requested` backend, the first configured entry of
|
|
400
|
+
* {@link TRANSCRIPTION_PROVIDER_ORDER} wins (OpenAI, then Google, then
|
|
401
|
+
* Azure). With one, it is normalised through the alias table and validated
|
|
402
|
+
* for availability — a backend the caller explicitly asked for is never
|
|
403
|
+
* silently swapped for a different one, because a caller who pinned Azure
|
|
404
|
+
* for a data-residency reason would not want OpenAI chosen behind their
|
|
405
|
+
* back. The mismatch is reported instead.
|
|
406
|
+
*
|
|
407
|
+
* @param requested - Caller's `AudioProcessorOptions.provider`, if any
|
|
408
|
+
* @returns The chosen backend, or the reason no backend could be chosen
|
|
409
|
+
*/
|
|
410
|
+
selectProvider(requested) {
|
|
411
|
+
const normalized = requested?.trim().toLowerCase();
|
|
412
|
+
if (normalized) {
|
|
413
|
+
const resolved = TRANSCRIPTION_PROVIDER_ALIASES[normalized];
|
|
414
|
+
if (!resolved) {
|
|
415
|
+
return {
|
|
416
|
+
reason: `transcription provider "${requested}" is not one this processor can drive — ` +
|
|
417
|
+
`supported: ${TRANSCRIPTION_PROVIDER_ORDER.join(", ")}`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (!this.isProviderAvailable(resolved)) {
|
|
421
|
+
const { envVars } = TRANSCRIPTION_PROVIDER_CREDENTIALS[resolved];
|
|
422
|
+
return {
|
|
423
|
+
reason: `transcription provider "${resolved}" was requested but is not configured — ` +
|
|
424
|
+
`set one of: ${envVars.join(", ")}`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const { label } = TRANSCRIPTION_PROVIDER_CREDENTIALS[resolved];
|
|
428
|
+
logger.debug(`[AudioProcessor] Using caller-selected transcription provider: ${label}`);
|
|
429
|
+
return { provider: resolved, label };
|
|
430
|
+
}
|
|
431
|
+
for (const candidate of TRANSCRIPTION_PROVIDER_ORDER) {
|
|
432
|
+
if (this.isProviderAvailable(candidate)) {
|
|
433
|
+
const { label } = TRANSCRIPTION_PROVIDER_CREDENTIALS[candidate];
|
|
434
|
+
logger.debug(`[AudioProcessor] Auto-selected transcription provider: ${label}`);
|
|
435
|
+
return { provider: candidate, label };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const allVars = TRANSCRIPTION_PROVIDER_ORDER.flatMap((candidate) => TRANSCRIPTION_PROVIDER_CREDENTIALS[candidate].envVars);
|
|
439
|
+
return {
|
|
440
|
+
reason: `no transcription backend is configured — set one of: ${allVars.join(", ")}`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Attempt to transcribe audio with whichever backend is configured (#413).
|
|
288
445
|
*
|
|
289
446
|
* Transcription is attempted when:
|
|
290
|
-
* 1.
|
|
291
|
-
* 2. File size is within
|
|
292
|
-
* 3.
|
|
447
|
+
* 1. A backend's credentials are present (see {@link selectProvider})
|
|
448
|
+
* 2. File size is within the 25MB ceiling
|
|
449
|
+
* 3. The file format is one the backend accepts
|
|
293
450
|
*
|
|
294
451
|
* Gracefully degrades: if transcription fails for any reason, metadata-only
|
|
295
452
|
* output is returned (transcription is additive, never blocks processing).
|
|
@@ -297,57 +454,112 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
297
454
|
* @param buffer - Audio file content
|
|
298
455
|
* @param filename - Original filename (used for format detection)
|
|
299
456
|
* @param mimetype - MIME type of the audio file
|
|
300
|
-
* @
|
|
457
|
+
* @param options - Caller's transcription knobs (provider, language, prompt)
|
|
458
|
+
* @returns Transcription result with transcript text, or the reason there is none
|
|
301
459
|
*/
|
|
302
|
-
async attemptTranscription(buffer, filename, mimetype) {
|
|
303
|
-
|
|
460
|
+
async attemptTranscription(buffer, filename, mimetype, options) {
|
|
461
|
+
/**
|
|
462
|
+
* Every exit below used to return an indistinguishable empty result, so
|
|
463
|
+
* "no API key", "file too large", "format the backend can't read" and "the
|
|
464
|
+
* call failed" were impossible to tell apart — from the outside it just
|
|
465
|
+
* looked like the audio had no speech (#416).
|
|
466
|
+
*
|
|
467
|
+
* `transcriptionProvider` is left undefined by default (no backend ran),
|
|
468
|
+
* but a caller that reached a backend and got a genuine empty transcript
|
|
469
|
+
* back passes its label through. `FileDetector` only writes
|
|
470
|
+
* `transcriptionLength` when `transcriptionProvider` is set, precisely so
|
|
471
|
+
* "a provider ran and reported no speech" (`transcriptionLength: 0`)
|
|
472
|
+
* stays distinguishable from "no provider ever ran" (field omitted) —
|
|
473
|
+
* clearing the label here for an empty-but-successful call collapsed that
|
|
474
|
+
* distinction back into the same "never attempted" shape it exists to
|
|
475
|
+
* avoid.
|
|
476
|
+
*/
|
|
477
|
+
const skipped = (reason, transcriptionProvider) => {
|
|
304
478
|
logger.warn(`[AudioProcessor] No transcript for ${filename}: ${reason}. ` +
|
|
305
479
|
`The model will receive metadata only.`);
|
|
306
480
|
return {
|
|
307
481
|
transcript: undefined,
|
|
308
482
|
hasTranscript: false,
|
|
309
|
-
transcriptionProvider
|
|
483
|
+
transcriptionProvider,
|
|
484
|
+
transcriptionLanguage: undefined,
|
|
485
|
+
transcriptionDuration: undefined,
|
|
310
486
|
transcriptionSkippedReason: reason,
|
|
311
487
|
};
|
|
312
488
|
};
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return skipped("OPENAI_API_KEY is not set, and Whisper is the only transcription backend wired up");
|
|
489
|
+
const selection = this.selectProvider(options?.provider);
|
|
490
|
+
if (selection.provider === undefined) {
|
|
491
|
+
return skipped(selection.reason);
|
|
317
492
|
}
|
|
318
|
-
//
|
|
493
|
+
// Size ceiling. Whisper documents 25MB; the other backends' synchronous
|
|
494
|
+
// endpoints are lower still, so this is a floor on what is worth sending
|
|
495
|
+
// rather than a per-backend limit.
|
|
319
496
|
const fileSizeMB = buffer.length / (1024 * 1024);
|
|
320
497
|
if (fileSizeMB > AUDIO_CONFIG.WHISPER_MAX_SIZE_MB) {
|
|
321
|
-
return skipped(`file is ${fileSizeMB.toFixed(1)}MB, over
|
|
498
|
+
return skipped(`file is ${fileSizeMB.toFixed(1)}MB, over the ${AUDIO_CONFIG.WHISPER_MAX_SIZE_MB}MB transcription limit — split or compress it`);
|
|
322
499
|
}
|
|
323
|
-
// Check if file format is supported by Whisper
|
|
324
500
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
mimetype.startsWith("audio/
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
501
|
+
if (selection.provider === "openai") {
|
|
502
|
+
// Format gate is Whisper's own accepted-extension list. The other
|
|
503
|
+
// backends have their own, enforced by their handlers.
|
|
504
|
+
const isFormatSupported = ext && AUDIO_CONFIG.WHISPER_SUPPORTED_FORMATS.includes(ext);
|
|
505
|
+
const isMimeSupported = mimetype &&
|
|
506
|
+
(mimetype.startsWith("audio/mpeg") ||
|
|
507
|
+
mimetype.startsWith("audio/mp4") ||
|
|
508
|
+
mimetype.startsWith("audio/wav") ||
|
|
509
|
+
mimetype.startsWith("audio/x-wav") ||
|
|
510
|
+
mimetype.startsWith("audio/webm") ||
|
|
511
|
+
mimetype.startsWith("audio/flac") ||
|
|
512
|
+
mimetype.startsWith("audio/ogg") ||
|
|
513
|
+
mimetype.startsWith("audio/x-m4a"));
|
|
514
|
+
if (!isFormatSupported && !isMimeSupported) {
|
|
515
|
+
return skipped(`format is not one Whisper accepts (extension "${ext ?? "none"}", mimetype "${mimetype ?? "none"}"); supported: ${AUDIO_CONFIG.WHISPER_SUPPORTED_FORMATS.join(", ")}`);
|
|
516
|
+
}
|
|
517
|
+
return await this.transcribeWithOpenAI(buffer, filename, mimetype, options, skipped);
|
|
518
|
+
}
|
|
519
|
+
return await this.transcribeWithHandler({ provider: selection.provider, label: selection.label }, buffer, filename, mimetype, options, skipped);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Transcribe via OpenAI Whisper (#416).
|
|
523
|
+
*
|
|
524
|
+
* A native multipart POST to OpenAI's transcription endpoint. This used to
|
|
525
|
+
* go through @ai-sdk/openai's createOpenAI().transcription() plus the ai
|
|
526
|
+
* package's experimental_transcribe; both were dropped, and this is the
|
|
527
|
+
* only wire behaviour of theirs the processor ever depended on. The same
|
|
528
|
+
* request is already made natively by voice/providers/OpenAISTT.ts.
|
|
529
|
+
*
|
|
530
|
+
* `verbose_json` is requested for `language` and `duration` alongside the
|
|
531
|
+
* text — the response was previously parsed for `text` alone, so both were
|
|
532
|
+
* received and discarded, and `FileProcessingResult.metadata.language` had
|
|
533
|
+
* nothing to report (#409).
|
|
534
|
+
*
|
|
535
|
+
* @param buffer - Audio file content
|
|
536
|
+
* @param filename - Original filename, sent as the multipart part name
|
|
537
|
+
* @param mimetype - MIME type used for the upload blob
|
|
538
|
+
* @param options - Caller's language / model / prompt overrides
|
|
539
|
+
* @param skipped - Builds the "no transcript, and here is why" outcome
|
|
540
|
+
* @returns Transcription result, or the reason there is none
|
|
541
|
+
*/
|
|
542
|
+
async transcribeWithOpenAI(buffer, filename, mimetype, options, skipped) {
|
|
543
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
544
|
+
if (!apiKey) {
|
|
545
|
+
// Unreachable via selectProvider, which already proved the key is set.
|
|
546
|
+
// Kept so this method is safe to call directly.
|
|
547
|
+
return skipped("OPENAI_API_KEY is not set");
|
|
336
548
|
}
|
|
337
549
|
try {
|
|
338
|
-
// Native multipart POST to OpenAI's transcription endpoint. This used to
|
|
339
|
-
// go through @ai-sdk/openai's createOpenAI().transcription() plus the ai
|
|
340
|
-
// package's experimental_transcribe; both were dropped, and this is the
|
|
341
|
-
// only wire behaviour of theirs the processor ever depended on. The same
|
|
342
|
-
// request is already made natively by voice/providers/OpenAISTT.ts.
|
|
343
|
-
// Only `text` is read off the response, as before.
|
|
344
550
|
const baseUrl = (process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
|
|
345
551
|
const form = new FormData();
|
|
346
552
|
form.append("file", new Blob([new Uint8Array(buffer)], {
|
|
347
553
|
type: mimetype || "audio/mpeg",
|
|
348
554
|
}), filename);
|
|
349
|
-
form.append("model", "whisper-1");
|
|
555
|
+
form.append("model", options?.transcriptionModel ?? "whisper-1");
|
|
350
556
|
form.append("response_format", "verbose_json");
|
|
557
|
+
if (options?.language) {
|
|
558
|
+
form.append("language", options.language);
|
|
559
|
+
}
|
|
560
|
+
if (options?.prompt) {
|
|
561
|
+
form.append("prompt", options.prompt);
|
|
562
|
+
}
|
|
351
563
|
// Wrap in withTimeout — large audio files can take a while, but a
|
|
352
564
|
// stalled request shouldn't block the processor forever. A TimeoutError
|
|
353
565
|
// lands in the same handler as other failures below, which reports it as
|
|
@@ -377,23 +589,34 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
377
589
|
return skipped(`transcription request failed — HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
|
|
378
590
|
}
|
|
379
591
|
const payload = await response.json();
|
|
380
|
-
const
|
|
381
|
-
payload
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
592
|
+
const record = typeof payload === "object" && payload !== null
|
|
593
|
+
? payload
|
|
594
|
+
: {};
|
|
595
|
+
const rawText = typeof record.text === "string" ? record.text : "";
|
|
596
|
+
// `duration` comes back as a number, but some gateways stringify it.
|
|
597
|
+
const durationValue = typeof record.duration === "number"
|
|
598
|
+
? record.duration
|
|
599
|
+
: typeof record.duration === "string" &&
|
|
600
|
+
record.duration.trim().length > 0 &&
|
|
601
|
+
Number.isFinite(Number(record.duration))
|
|
602
|
+
? Number(record.duration)
|
|
603
|
+
: undefined;
|
|
385
604
|
if (rawText.trim().length > 0) {
|
|
386
605
|
logger.debug(`[AudioProcessor] Transcribed ${filename} via openai-whisper (${rawText.trim().length} chars)`);
|
|
387
606
|
return {
|
|
388
607
|
transcript: rawText.trim(),
|
|
389
608
|
hasTranscript: true,
|
|
390
609
|
transcriptionProvider: "openai-whisper",
|
|
610
|
+
transcriptionLanguage: typeof record.language === "string" && record.language.length > 0
|
|
611
|
+
? record.language
|
|
612
|
+
: options?.language,
|
|
613
|
+
transcriptionDuration: durationValue,
|
|
391
614
|
transcriptionSkippedReason: undefined,
|
|
392
615
|
};
|
|
393
616
|
}
|
|
394
617
|
// A successful call that returned nothing is a legitimate outcome
|
|
395
618
|
// (silence, music, no speech) — distinct from a failure.
|
|
396
|
-
return skipped("Whisper returned an empty transcript for this audio");
|
|
619
|
+
return skipped("Whisper returned an empty transcript for this audio", "openai-whisper");
|
|
397
620
|
}
|
|
398
621
|
catch (error) {
|
|
399
622
|
// Transcription stays best-effort — a failure must never kill the whole
|
|
@@ -404,6 +627,62 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
404
627
|
return skipped(`transcription request failed — ${message}`);
|
|
405
628
|
}
|
|
406
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Transcribe via one of the non-OpenAI STT handlers (#413).
|
|
632
|
+
*
|
|
633
|
+
* Google and Azure already have working, tested implementations under
|
|
634
|
+
* `src/lib/voice/providers/`, reached here through a dynamic import so a
|
|
635
|
+
* file-processing run that never transcribes does not pay to load them —
|
|
636
|
+
* the same reason `music-metadata` is loaded lazily above.
|
|
637
|
+
*
|
|
638
|
+
* @param chosen - Backend and display label from {@link selectProvider}
|
|
639
|
+
* @param buffer - Audio file content
|
|
640
|
+
* @param filename - Original filename; its extension picks the wire format
|
|
641
|
+
* @param mimetype - MIME type, used as the fallback format signal
|
|
642
|
+
* @param options - Caller's language / model overrides
|
|
643
|
+
* @param skipped - Builds the "no transcript, and here is why" outcome
|
|
644
|
+
* @returns Transcription result, or the reason there is none
|
|
645
|
+
*/
|
|
646
|
+
async transcribeWithHandler(chosen, buffer, filename, mimetype, options, skipped) {
|
|
647
|
+
const { provider, label } = chosen;
|
|
648
|
+
try {
|
|
649
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
650
|
+
const format = toSTTAudioFormat(extension, mimetype);
|
|
651
|
+
const sttOptions = {
|
|
652
|
+
...(options?.language ? { language: options.language } : {}),
|
|
653
|
+
...(options?.transcriptionModel
|
|
654
|
+
? { model: options.transcriptionModel }
|
|
655
|
+
: {}),
|
|
656
|
+
...(format ? { format } : {}),
|
|
657
|
+
};
|
|
658
|
+
let result;
|
|
659
|
+
if (provider === "google") {
|
|
660
|
+
const { GoogleSTT } = await import("../../voice/providers/GoogleSTT.js");
|
|
661
|
+
result = await new GoogleSTT().transcribe(buffer, sttOptions);
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
const { AzureSTT } = await import("../../voice/providers/AzureSTT.js");
|
|
665
|
+
result = await new AzureSTT().transcribe(buffer, sttOptions);
|
|
666
|
+
}
|
|
667
|
+
const text = result.text.trim();
|
|
668
|
+
if (text.length === 0) {
|
|
669
|
+
return skipped(`${label} returned an empty transcript for this audio`, label);
|
|
670
|
+
}
|
|
671
|
+
logger.debug(`[AudioProcessor] Transcribed ${filename} via ${label} (${text.length} chars)`);
|
|
672
|
+
return {
|
|
673
|
+
transcript: text,
|
|
674
|
+
hasTranscript: true,
|
|
675
|
+
transcriptionProvider: label,
|
|
676
|
+
transcriptionLanguage: result.language ?? options?.language,
|
|
677
|
+
transcriptionDuration: result.duration,
|
|
678
|
+
transcriptionSkippedReason: undefined,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
683
|
+
return skipped(`transcription request failed — ${message}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
407
686
|
// ===========================================================================
|
|
408
687
|
// STUB: buildProcessedResult (required by base class, unused due to override)
|
|
409
688
|
// ===========================================================================
|
|
@@ -539,6 +818,11 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
539
818
|
* @param metadata - Extracted audio metadata
|
|
540
819
|
* @param tags - Extracted audio tags
|
|
541
820
|
* @param transcript - Optional transcribed text from Whisper
|
|
821
|
+
* @param skippedReason - When `transcript` is absent, why transcription was
|
|
822
|
+
* skipped (no backend configured, unavailable pinned backend, format/size
|
|
823
|
+
* limit, or a backend call that failed) — inlined so the model is told the
|
|
824
|
+
* reason instead of silently receiving metadata with no transcript, which
|
|
825
|
+
* `AUDIO_TRANSCRIPTION_INSTRUCTIONS` (messageBuilder.ts) tells it to expect.
|
|
542
826
|
* @returns Formatted text content string
|
|
543
827
|
*
|
|
544
828
|
* @example Output:
|
|
@@ -553,7 +837,7 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
553
837
|
* [full transcribed text here]
|
|
554
838
|
* ```
|
|
555
839
|
*/
|
|
556
|
-
buildTextContent(filename, metadata, tags, transcript) {
|
|
840
|
+
buildTextContent(filename, metadata, tags, transcript, skippedReason) {
|
|
557
841
|
const lines = [];
|
|
558
842
|
// Header line
|
|
559
843
|
lines.push(`[Audio File: ${filename}]`);
|
|
@@ -617,12 +901,19 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
617
901
|
if (secondaryParts.length > 0) {
|
|
618
902
|
lines.push(secondaryParts.join(" | "));
|
|
619
903
|
}
|
|
620
|
-
// Transcript section (if transcription was performed)
|
|
904
|
+
// Transcript section (if transcription was performed), or — when it was
|
|
905
|
+
// not — the reason, so the model is told rather than left to guess or
|
|
906
|
+
// invent content for a recording it was never given a transcript of.
|
|
621
907
|
if (transcript) {
|
|
622
908
|
lines.push("");
|
|
623
909
|
lines.push("--- Transcript ---");
|
|
624
910
|
lines.push(transcript);
|
|
625
911
|
}
|
|
912
|
+
else if (skippedReason) {
|
|
913
|
+
lines.push("");
|
|
914
|
+
lines.push("--- Transcription Skipped ---");
|
|
915
|
+
lines.push(skippedReason);
|
|
916
|
+
}
|
|
626
917
|
return lines.join("\n");
|
|
627
918
|
}
|
|
628
919
|
// ===========================================================================
|
|
@@ -717,7 +1008,10 @@ export function isAudioFile(mimetype, filename) {
|
|
|
717
1008
|
* Convenience function that uses the singleton processor.
|
|
718
1009
|
*
|
|
719
1010
|
* @param fileInfo - File information (can include URL or buffer)
|
|
720
|
-
* @param options - Optional processing options (auth headers, timeout, etc.)
|
|
1011
|
+
* @param options - Optional processing options (auth headers, timeout, etc.),
|
|
1012
|
+
* widened with the transcription knobs (#413/#440, matching
|
|
1013
|
+
* {@link AudioProcessor.processFile}) so a caller can pin a provider,
|
|
1014
|
+
* language, model or prompt through this convenience export too.
|
|
721
1015
|
* @returns Processing result with audio metadata or error
|
|
722
1016
|
*
|
|
723
1017
|
* @example
|
package/dist/types/file.d.ts
CHANGED
|
@@ -344,6 +344,33 @@ export type FileProcessingResult = {
|
|
|
344
344
|
* — an unknown duration, not a zero-length clip.
|
|
345
345
|
*/
|
|
346
346
|
durationSec?: number;
|
|
347
|
+
/**
|
|
348
|
+
* Media duration in seconds, read from the container header.
|
|
349
|
+
*
|
|
350
|
+
* Sourced from the demuxed stream rather than from the transcription
|
|
351
|
+
* backend, so it is present for every audio file — including one that was
|
|
352
|
+
* never transcribed because no provider was configured.
|
|
353
|
+
*/
|
|
354
|
+
duration?: number;
|
|
355
|
+
/**
|
|
356
|
+
* Language of the transcript, as reported by the transcription provider
|
|
357
|
+
* (BCP-47 or ISO-639-1 depending on the backend), or the language the
|
|
358
|
+
* caller pinned via `AudioProcessorOptions.language`.
|
|
359
|
+
*
|
|
360
|
+
* Absent when the file was not transcribed — its absence therefore means
|
|
361
|
+
* "unknown", never "not speech".
|
|
362
|
+
*/
|
|
363
|
+
language?: string;
|
|
364
|
+
/**
|
|
365
|
+
* Character count of the transcript.
|
|
366
|
+
*
|
|
367
|
+
* `0` is a meaningful value distinct from `undefined`: it means a provider
|
|
368
|
+
* ran and returned an empty transcript (silence, music, no speech), where
|
|
369
|
+
* `undefined` means no transcription was attempted at all.
|
|
370
|
+
*/
|
|
371
|
+
transcriptionLength?: number;
|
|
372
|
+
/** Which backend produced the transcript, e.g. "openai-whisper". */
|
|
373
|
+
transcriptionProvider?: string;
|
|
347
374
|
};
|
|
348
375
|
};
|
|
349
376
|
/**
|
|
@@ -526,6 +553,47 @@ export type AudioProviderConfig = {
|
|
|
526
553
|
/** Optional: Cost per 15 seconds of audio in USD */
|
|
527
554
|
costPer15s?: number;
|
|
528
555
|
};
|
|
556
|
+
/**
|
|
557
|
+
* A transcription backend `AudioProcessor` can drive (#413).
|
|
558
|
+
*
|
|
559
|
+
* Deliberately narrower than `AudioProcessorOptions.provider`, which stays a
|
|
560
|
+
* free-form `string` on the public surface for backward compatibility: a
|
|
561
|
+
* caller's spelling is normalised onto this union (accepting aliases such as
|
|
562
|
+
* "whisper" for "openai") and an unrecognised one is reported rather than
|
|
563
|
+
* silently ignored.
|
|
564
|
+
*/
|
|
565
|
+
export type AudioTranscriptionProvider = "openai" | "google" | "azure";
|
|
566
|
+
/**
|
|
567
|
+
* Outcome of choosing a transcription backend (#413).
|
|
568
|
+
*
|
|
569
|
+
* A union rather than a nullable provider so the "nothing usable" case is
|
|
570
|
+
* forced to carry the reason — the failure mode this replaces was every
|
|
571
|
+
* unavailable backend collapsing into an indistinguishable empty result.
|
|
572
|
+
*/
|
|
573
|
+
export type AudioTranscriptionSelection = {
|
|
574
|
+
provider: AudioTranscriptionProvider;
|
|
575
|
+
label: string;
|
|
576
|
+
reason?: undefined;
|
|
577
|
+
} | {
|
|
578
|
+
provider?: undefined;
|
|
579
|
+
label?: undefined;
|
|
580
|
+
reason: string;
|
|
581
|
+
};
|
|
582
|
+
/**
|
|
583
|
+
* What a transcription attempt produced (#409/#416).
|
|
584
|
+
*
|
|
585
|
+
* `transcriptionSkippedReason` is present on exactly the attempts that yielded
|
|
586
|
+
* no transcript, so a caller can tell "no speech in this audio" from "no
|
|
587
|
+
* backend was reachable".
|
|
588
|
+
*/
|
|
589
|
+
export type AudioTranscriptionOutcome = {
|
|
590
|
+
transcript: string | undefined;
|
|
591
|
+
hasTranscript: boolean;
|
|
592
|
+
transcriptionProvider: string | undefined;
|
|
593
|
+
transcriptionLanguage: string | undefined;
|
|
594
|
+
transcriptionDuration: number | undefined;
|
|
595
|
+
transcriptionSkippedReason: string | undefined;
|
|
596
|
+
};
|
|
529
597
|
/**
|
|
530
598
|
* Audio processor options
|
|
531
599
|
*/
|
|
@@ -536,7 +604,7 @@ export type AudioProcessorOptions = {
|
|
|
536
604
|
transcriptionModel?: string;
|
|
537
605
|
/** Language code for transcription (e.g., 'en', 'es', 'fr') */
|
|
538
606
|
language?: string;
|
|
539
|
-
/**
|
|
607
|
+
/** OpenAI/Whisper-only context prompt to guide transcription accuracy; ignored by Google and Azure */
|
|
540
608
|
prompt?: string;
|
|
541
609
|
/** Maximum audio duration in seconds (default: 600) */
|
|
542
610
|
maxDurationSeconds?: number;
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -194,6 +194,40 @@ export type GenerateOptions = {
|
|
|
194
194
|
* and the inline copies had already drifted apart in what they documented.
|
|
195
195
|
*/
|
|
196
196
|
videoOptions?: VideoProcessorOptions;
|
|
197
|
+
/**
|
|
198
|
+
* Audio transcription options for attached audio files (#413/#440).
|
|
199
|
+
*
|
|
200
|
+
* An attached audio file is transcribed automatically when a backend is
|
|
201
|
+
* configured; this is only needed to override which one, or to help it.
|
|
202
|
+
* Without it the first configured backend wins, in the order OpenAI
|
|
203
|
+
* (Whisper), Google, Azure.
|
|
204
|
+
*
|
|
205
|
+
* @example Pin a backend and a language
|
|
206
|
+
* ```typescript
|
|
207
|
+
* await neurolink.generate({
|
|
208
|
+
* input: { text: "Summarise this call", files: ["./call.mp3"] },
|
|
209
|
+
* audioOptions: { provider: "openai", language: "en" },
|
|
210
|
+
* });
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
audioOptions?: {
|
|
214
|
+
/**
|
|
215
|
+
* Transcription backend: "openai" (aliases "whisper", "openai-whisper"),
|
|
216
|
+
* "google" or "azure". An unavailable or unrecognised choice is never
|
|
217
|
+
* swapped for another backend: no transcript is produced, and the
|
|
218
|
+
* selection reason is logged as a warning.
|
|
219
|
+
*/
|
|
220
|
+
provider?: string;
|
|
221
|
+
/** Transcription model, e.g. "whisper-1". Backend-specific. */
|
|
222
|
+
transcriptionModel?: string;
|
|
223
|
+
/** Language hint, e.g. "en". Improves accuracy on non-English speech. */
|
|
224
|
+
language?: string;
|
|
225
|
+
/**
|
|
226
|
+
* OpenAI/Whisper-only context prompt to bias transcription (proper nouns,
|
|
227
|
+
* jargon). Ignored by Google and Azure.
|
|
228
|
+
*/
|
|
229
|
+
prompt?: string;
|
|
230
|
+
};
|
|
197
231
|
/**
|
|
198
232
|
* Text-to-Speech (TTS) configuration
|
|
199
233
|
*
|
|
@@ -1472,6 +1506,23 @@ export type TextGenerationOptions = {
|
|
|
1472
1506
|
*/
|
|
1473
1507
|
outputFormat?: VisionImageOutputFormat;
|
|
1474
1508
|
};
|
|
1509
|
+
/**
|
|
1510
|
+
* Audio transcription options for attached audio files (#413/#440).
|
|
1511
|
+
*
|
|
1512
|
+
* Mirrors `GenerateOptions.audioOptions`; declared here because
|
|
1513
|
+
* `buildGenerateTextOptions` rebuilds options field by field, so anything
|
|
1514
|
+
* missing from this type cannot reach the message builder at all.
|
|
1515
|
+
*/
|
|
1516
|
+
audioOptions?: {
|
|
1517
|
+
/** Backend: "openai" (aliases "whisper"), "google" or "azure". */
|
|
1518
|
+
provider?: string;
|
|
1519
|
+
/** Transcription model, e.g. "whisper-1". Backend-specific. */
|
|
1520
|
+
transcriptionModel?: string;
|
|
1521
|
+
/** Language hint, e.g. "en". */
|
|
1522
|
+
language?: string;
|
|
1523
|
+
/** OpenAI/Whisper-only context prompt to bias transcription; ignored by Google and Azure. */
|
|
1524
|
+
prompt?: string;
|
|
1525
|
+
};
|
|
1475
1526
|
enableSummarization?: boolean;
|
|
1476
1527
|
/**
|
|
1477
1528
|
* Skip injecting tool schemas into the system prompt.
|