@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.
@@ -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 API key is available
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 available)
236
- const textContent = this.buildTextContent(filename, metadata, tags, transcriptionResult.transcript);
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
- * Attempt to transcribe audio using the Vercel AI SDK's `transcribe()` function
287
- * with the OpenAI Whisper model.
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. `OPENAI_API_KEY` environment variable is set
291
- * 2. File size is within Whisper's 25MB limit
292
- * 3. File format is supported by Whisper
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
- * @returns Transcription result with transcript text, or empty result
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
- const skipped = (reason) => {
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: undefined,
483
+ transcriptionProvider,
484
+ transcriptionLanguage: undefined,
485
+ transcriptionDuration: undefined,
310
486
  transcriptionSkippedReason: reason,
311
487
  };
312
488
  };
313
- // Check if OPENAI_API_KEY is available
314
- const apiKey = process.env.OPENAI_API_KEY;
315
- if (!apiKey) {
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
- // Check file size (Whisper limit is 25MB)
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 Whisper's ${AUDIO_CONFIG.WHISPER_MAX_SIZE_MB}MB limit — split or compress it`);
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
- const isFormatSupported = ext && AUDIO_CONFIG.WHISPER_SUPPORTED_FORMATS.includes(ext);
326
- const isMimeSupported = mimetype &&
327
- (mimetype.startsWith("audio/mpeg") ||
328
- mimetype.startsWith("audio/mp4") ||
329
- mimetype.startsWith("audio/wav") ||
330
- mimetype.startsWith("audio/webm") ||
331
- mimetype.startsWith("audio/flac") ||
332
- mimetype.startsWith("audio/ogg") ||
333
- mimetype.startsWith("audio/x-m4a"));
334
- if (!isFormatSupported && !isMimeSupported) {
335
- return skipped(`format is not one Whisper accepts (extension "${ext ?? "none"}", mimetype "${mimetype ?? "none"}"); supported: ${AUDIO_CONFIG.WHISPER_SUPPORTED_FORMATS.join(", ")}`);
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 rawText = typeof payload === "object" &&
381
- payload !== null &&
382
- typeof payload.text === "string"
383
- ? payload.text
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
@@ -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
- /** Context or prompt to guide transcription accuracy */
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;
@@ -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.