@juspay/neurolink 12.48.1 → 12.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +616 -595
  3. package/dist/cli/commands/serve.d.ts +5 -3
  4. package/dist/cli/commands/serve.js +113 -20
  5. package/dist/cli/commands/server.js +13 -6
  6. package/dist/cli/commands/transcribe.d.ts +24 -0
  7. package/dist/cli/commands/transcribe.js +718 -0
  8. package/dist/cli/factories/commandFactory.d.ts +10 -0
  9. package/dist/cli/factories/commandFactory.js +13 -0
  10. package/dist/cli/parser.js +3 -0
  11. package/dist/cli/utils/serverUtils.d.ts +28 -0
  12. package/dist/cli/utils/serverUtils.js +71 -0
  13. package/dist/factories/mediaHandlerCatalog.js +11 -9
  14. package/dist/factories/sttDescriptors.d.ts +63 -0
  15. package/dist/factories/sttDescriptors.js +286 -0
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +7 -1
  18. package/dist/neurolink.d.ts +68 -1
  19. package/dist/neurolink.js +202 -4
  20. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  21. package/dist/processors/media/AudioProcessor.js +105 -276
  22. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  23. package/dist/processors/media/VideoProcessor.js +52 -51
  24. package/dist/server/abstract/baseServerAdapter.d.ts +28 -1
  25. package/dist/server/abstract/baseServerAdapter.js +84 -4
  26. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  27. package/dist/server/adapters/expressAdapter.js +50 -10
  28. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  29. package/dist/server/adapters/fastifyAdapter.js +51 -8
  30. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  31. package/dist/server/adapters/honoAdapter.js +95 -8
  32. package/dist/server/adapters/koaAdapter.d.ts +7 -0
  33. package/dist/server/adapters/koaAdapter.js +71 -10
  34. package/dist/server/index.d.ts +3 -2
  35. package/dist/server/index.js +3 -2
  36. package/dist/server/routes/index.d.ts +1 -0
  37. package/dist/server/routes/index.js +3 -0
  38. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  39. package/dist/server/routes/transcribeRoutes.js +506 -0
  40. package/dist/server/utils/rawBody.d.ts +32 -0
  41. package/dist/server/utils/rawBody.js +76 -0
  42. package/dist/server/utils/validation.d.ts +192 -0
  43. package/dist/server/utils/validation.js +100 -0
  44. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  45. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  46. package/dist/types/cli.d.ts +47 -0
  47. package/dist/types/index.d.ts +1 -0
  48. package/dist/types/index.js +1 -0
  49. package/dist/types/providers.d.ts +7 -1
  50. package/dist/types/server.d.ts +117 -1
  51. package/dist/types/stt.d.ts +160 -7
  52. package/dist/types/transcribe.d.ts +506 -0
  53. package/dist/types/transcribe.js +29 -0
  54. package/dist/utils/speakerTurns.d.ts +28 -0
  55. package/dist/utils/speakerTurns.js +93 -0
  56. package/dist/utils/sttProcessor.d.ts +20 -2
  57. package/dist/utils/sttProcessor.js +73 -5
  58. package/dist/voice/correction/dictionary.d.ts +49 -0
  59. package/dist/voice/correction/dictionary.js +134 -0
  60. package/dist/voice/correction/guard.d.ts +35 -0
  61. package/dist/voice/correction/guard.js +161 -0
  62. package/dist/voice/correction/index.d.ts +47 -0
  63. package/dist/voice/correction/index.js +211 -0
  64. package/dist/voice/correction/rewrite.d.ts +92 -0
  65. package/dist/voice/correction/rewrite.js +322 -0
  66. package/dist/voice/errors.d.ts +15 -0
  67. package/dist/voice/errors.js +45 -0
  68. package/dist/voice/index.d.ts +20 -2
  69. package/dist/voice/index.js +75 -9
  70. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  71. package/dist/voice/providers/AzureSTT.js +23 -75
  72. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  73. package/dist/voice/providers/DeepgramSTT.js +109 -13
  74. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  75. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  76. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  77. package/dist/voice/providers/GoogleSTT.js +21 -75
  78. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  79. package/dist/voice/providers/OpenAISTT.js +196 -21
  80. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  81. package/dist/voice/providers/WhistleSTT.js +313 -0
  82. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  83. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  84. package/dist/voice/streaming/endpointer.d.ts +81 -0
  85. package/dist/voice/streaming/endpointer.js +247 -0
  86. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  87. package/dist/voice/streaming/localAgreement.js +44 -0
  88. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  89. package/dist/voice/sttEnvCredentials.js +12 -0
  90. package/dist/voice/transcribe.d.ts +47 -0
  91. package/dist/voice/transcribe.js +519 -0
  92. package/dist/voice/transcribeStream.d.ts +4 -0
  93. package/dist/voice/transcribeStream.js +584 -0
  94. package/dist/voice/whistle/assets.d.ts +47 -0
  95. package/dist/voice/whistle/assets.js +270 -0
  96. package/dist/voice/whistle/audio.d.ts +32 -0
  97. package/dist/voice/whistle/audio.js +267 -0
  98. package/dist/voice/whistle/engine.d.ts +32 -0
  99. package/dist/voice/whistle/engine.js +193 -0
  100. package/dist/voice/whistle/worker.d.ts +18 -0
  101. package/dist/voice/whistle/worker.js +294 -0
  102. package/docs-site/static/search-index.json +69 -10
  103. package/models/whistle/README.md +18 -0
  104. package/models/whistle/needle.js +2 -0
  105. package/models/whistle/needle.wasm +0 -0
  106. package/models/whistle/whistle.cact +0 -0
  107. package/package.json +7 -3
@@ -37,6 +37,9 @@
37
37
  * ```
38
38
  */
39
39
  import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
40
+ import { getSTTProviderDescriptor, isSTTProviderConfigured, resolveDefaultSTTProvider, resolveSTTProviderName, STT_PROVIDER_DESCRIPTORS, } from "../../factories/sttDescriptors.js";
41
+ import { STTProcessor } from "../../utils/sttProcessor.js";
42
+ import { freshEnvSTTCredentials } from "../../voice/sttEnvCredentials.js";
40
43
  import { SIZE_LIMITS_MB } from "../config/index.js";
41
44
  import { extensionsForModality, mimeTypesForModality, } from "../config/fileTypeRegistry.js";
42
45
  import { FileErrorCode } from "../errors/index.js";
@@ -70,18 +73,6 @@ const AUDIO_CONFIG = {
70
73
  WHISPER_MAX_SIZE_MB: 25,
71
74
  /** Transcription timeout in milliseconds (120 seconds for large files) */
72
75
  TRANSCRIPTION_TIMEOUT_MS: 120_000,
73
- /** Whisper-supported audio formats */
74
- WHISPER_SUPPORTED_FORMATS: [
75
- "mp3",
76
- "mp4",
77
- "mpeg",
78
- "mpga",
79
- "m4a",
80
- "wav",
81
- "webm",
82
- "flac",
83
- "ogg",
84
- ],
85
76
  };
86
77
  /**
87
78
  * Supported MIME types for audio files.
@@ -108,63 +99,33 @@ const SUPPORTED_AUDIO_MIME_TYPES = [
108
99
  */
109
100
  const SUPPORTED_AUDIO_EXTENSIONS = extensionsForModality("audio");
110
101
  /**
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.
102
+ * Backend names this processor has always accepted in
103
+ * `AudioProcessorOptions.provider`, mapped onto STT provider names. Anything
104
+ * else is resolved through the STT descriptors (`whisper`, `deepgram`,
105
+ * `scribe`, `local`, …), so every shipped STT provider can be pinned here.
133
106
  */
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"] },
107
+ const LEGACY_TRANSCRIPTION_ALIASES = {
108
+ openai: "whisper",
109
+ "openai-whisper": "whisper",
110
+ google: "google-stt",
111
+ "google-speech": "google-stt",
112
+ azure: "azure-stt",
113
+ "azure-speech": "azure-stt",
150
114
  };
151
115
  /**
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.
116
+ * The label reported in log lines and in `ProcessedAudio.transcriptionProvider`.
117
+ * Whisper keeps the label it has always had; every other provider reports its
118
+ * STT provider name.
156
119
  */
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
- };
120
+ function transcriptionLabel(provider) {
121
+ return provider === "whisper" ? "openai-whisper" : provider;
122
+ }
123
+ /** The STT provider a caller's backend name means, if it means one. */
124
+ function resolveTranscriptionProvider(requested) {
125
+ const normalized = requested.trim().toLowerCase();
126
+ return (LEGACY_TRANSCRIPTION_ALIASES[normalized] ??
127
+ resolveSTTProviderName(normalized));
128
+ }
168
129
  /**
169
130
  * Map an audio mimetype/extension onto the format vocabulary the STT handlers
170
131
  * expect. Returns undefined when nothing matches, which leaves the handler on
@@ -379,75 +340,75 @@ export class AudioProcessor extends BaseFileProcessor {
379
340
  // PRIVATE: AUDIO TRANSCRIPTION
380
341
  // ===========================================================================
381
342
  /**
382
- * Whether a transcription backend has the credentials it needs (#413).
343
+ * Choose the transcription backend for this file (#413).
344
+ *
345
+ * With no `requested` backend, the STT default applies: the provider named
346
+ * by `NEUROLINK_STT_PROVIDER`, else the first configured STT provider in
347
+ * descriptor order, else the built-in local engine when it can run here.
348
+ * With one, it is resolved through the legacy names and the STT aliases and
349
+ * validated for availability — a backend the caller explicitly asked for is
350
+ * never silently swapped for a different one, because a caller who pinned
351
+ * Azure for a data-residency reason would not want OpenAI chosen behind
352
+ * their back. The mismatch is reported instead.
383
353
  *
384
354
  * Availability is decided from the environment only — no network call — so
385
355
  * selection stays cheap and cannot itself fail. A key that is present but
386
356
  * rejected upstream surfaces later, as a transcription failure with the
387
357
  * provider's own message, not as "unavailable".
388
358
  *
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
359
  * @param requested - Caller's `AudioProcessorOptions.provider`, if any
408
360
  * @returns The chosen backend, or the reason no backend could be chosen
409
361
  */
410
- selectProvider(requested) {
411
- const normalized = requested?.trim().toLowerCase();
362
+ async selectProvider(requested) {
363
+ const normalized = requested?.trim();
412
364
  if (normalized) {
413
- const resolved = TRANSCRIPTION_PROVIDER_ALIASES[normalized];
365
+ const resolved = resolveTranscriptionProvider(normalized);
414
366
  if (!resolved) {
415
367
  return {
416
368
  reason: `transcription provider "${requested}" is not one this processor can drive — ` +
417
- `supported: ${TRANSCRIPTION_PROVIDER_ORDER.join(", ")}`,
369
+ `supported: ${STT_PROVIDER_DESCRIPTORS.map((d) => d.name).join(", ")}`,
418
370
  };
419
371
  }
420
- if (!this.isProviderAvailable(resolved)) {
421
- const { envVars } = TRANSCRIPTION_PROVIDER_CREDENTIALS[resolved];
372
+ if (!isSTTProviderConfigured(resolved)) {
373
+ const envVars = getSTTProviderDescriptor(resolved)?.envVars ?? [];
422
374
  return {
423
- reason: `transcription provider "${resolved}" was requested but is not configured — ` +
375
+ reason: `transcription provider "${normalized}" was requested but is not configured — ` +
424
376
  `set one of: ${envVars.join(", ")}`,
425
377
  };
426
378
  }
427
- const { label } = TRANSCRIPTION_PROVIDER_CREDENTIALS[resolved];
379
+ const label = transcriptionLabel(resolved);
428
380
  logger.debug(`[AudioProcessor] Using caller-selected transcription provider: ${label}`);
429
381
  return { provider: resolved, label };
430
382
  }
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 };
383
+ const provider = resolveDefaultSTTProvider();
384
+ const descriptor = getSTTProviderDescriptor(provider);
385
+ // The local engine is the default of last resort, but only when it can
386
+ // actually run on this machine; otherwise "nothing is configured" is the
387
+ // honest reason, and the hosted options are what fixes it.
388
+ if (descriptor?.capabilities.local) {
389
+ const handler = await STTProcessor.resolveHandler(provider, freshEnvSTTCredentials(descriptor.credentialsKey));
390
+ if (!handler?.isConfigured()) {
391
+ return {
392
+ reason: `no transcription backend is configured — set one of: ${STT_PROVIDER_DESCRIPTORS.flatMap((d) => d.envVars).join(", ")}`,
393
+ };
436
394
  }
437
395
  }
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
- };
396
+ const label = transcriptionLabel(provider);
397
+ logger.debug(`[AudioProcessor] Auto-selected transcription provider: ${label}`);
398
+ return { provider, label };
442
399
  }
443
400
  /**
444
401
  * Attempt to transcribe audio with whichever backend is configured (#413).
445
402
  *
446
403
  * Transcription is attempted when:
447
- * 1. A backend's credentials are present (see {@link selectProvider})
404
+ * 1. A backend is available (see {@link selectProvider})
448
405
  * 2. File size is within the 25MB ceiling
449
406
  * 3. The file format is one the backend accepts
450
407
  *
408
+ * The call itself goes through `STTProcessor`, the same path as
409
+ * `transcribe()` and `generate({ stt })`, so `OPENAI_STT_*`, the default
410
+ * provider rule and every STT handler apply here too.
411
+ *
451
412
  * Gracefully degrades: if transcription fails for any reason, metadata-only
452
413
  * output is returned (transcription is additive, never blocks processing).
453
414
  *
@@ -469,10 +430,7 @@ export class AudioProcessor extends BaseFileProcessor {
469
430
  * back passes its label through. `FileDetector` only writes
470
431
  * `transcriptionLength` when `transcriptionProvider` is set, precisely so
471
432
  * "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.
433
+ * stays distinguishable from "no provider ever ran" (field omitted).
476
434
  */
477
435
  const skipped = (reason, transcriptionProvider) => {
478
436
  logger.warn(`[AudioProcessor] No transcript for ${filename}: ${reason}. ` +
@@ -486,199 +444,70 @@ export class AudioProcessor extends BaseFileProcessor {
486
444
  transcriptionSkippedReason: reason,
487
445
  };
488
446
  };
489
- const selection = this.selectProvider(options?.provider);
490
- if (selection.provider === undefined) {
491
- return skipped(selection.reason);
492
- }
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.
496
- const fileSizeMB = buffer.length / (1024 * 1024);
497
- if (fileSizeMB > AUDIO_CONFIG.WHISPER_MAX_SIZE_MB) {
498
- return skipped(`file is ${fileSizeMB.toFixed(1)}MB, over the ${AUDIO_CONFIG.WHISPER_MAX_SIZE_MB}MB transcription limit — split or compress it`);
499
- }
500
- const ext = filename.split(".").pop()?.toLowerCase();
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");
548
- }
549
447
  try {
550
- const baseUrl = (process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
551
- const form = new FormData();
552
- form.append("file", new Blob([new Uint8Array(buffer)], {
553
- type: mimetype || "audio/mpeg",
554
- }), filename);
555
- form.append("model", options?.transcriptionModel ?? "whisper-1");
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
- }
563
- // Wrap in withTimeout — large audio files can take a while, but a
564
- // stalled request shouldn't block the processor forever. A TimeoutError
565
- // lands in the same handler as other failures below, which reports it as
566
- // the reason rather than discarding it.
567
- // `withTimeout` only races the promise against a timer — it cannot
568
- // cancel the operation. This code owns the raw fetch now, so without an
569
- // abort the socket and its in-flight upload (up to 25MB) stay alive
570
- // after the timeout has already resolved the caller.
571
- const transcriptionAbort = new AbortController();
572
- const transcriptionTimer = setTimeout(() => transcriptionAbort.abort(), AUDIO_CONFIG.TRANSCRIPTION_TIMEOUT_MS);
573
- let response;
574
- try {
575
- response = await withTimeout(fetch(`${baseUrl}/audio/transcriptions`, {
576
- method: "POST",
577
- headers: { Authorization: `Bearer ${apiKey}` },
578
- body: form,
579
- signal: transcriptionAbort.signal,
580
- }), AUDIO_CONFIG.TRANSCRIPTION_TIMEOUT_MS, "openai-whisper", "generate");
448
+ const selection = await this.selectProvider(options?.provider);
449
+ if (!("provider" in selection)) {
450
+ return skipped(selection.reason);
581
451
  }
582
- finally {
583
- clearTimeout(transcriptionTimer);
452
+ const { provider, label } = selection;
453
+ // Size ceiling. Whisper documents 25MB; the other backends' synchronous
454
+ // endpoints are lower still, so this is a floor on what is worth sending
455
+ // rather than a per-backend limit.
456
+ const fileSizeMB = buffer.length / (1024 * 1024);
457
+ if (fileSizeMB > AUDIO_CONFIG.WHISPER_MAX_SIZE_MB) {
458
+ return skipped(`file is ${fileSizeMB.toFixed(1)}MB, over the ${AUDIO_CONFIG.WHISPER_MAX_SIZE_MB}MB transcription limit — split or compress it`);
584
459
  }
585
- if (!response.ok) {
586
- const detail = await response.text().catch(() => "");
587
- // Mirrors the old behaviour: a non-2xx used to surface as a thrown
588
- // APICallError caught by the handler below and reported as the reason.
589
- return skipped(`transcription request failed — HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
460
+ const descriptor = getSTTProviderDescriptor(provider);
461
+ const credentials = descriptor
462
+ ? freshEnvSTTCredentials(descriptor.credentialsKey)
463
+ : undefined;
464
+ const handler = await STTProcessor.resolveHandler(provider, credentials);
465
+ // Format gate: the handler's own accepted list, checked before upload
466
+ // so an unreadable file is reported as such rather than as a failure.
467
+ const ext = filename.split(".").pop()?.toLowerCase();
468
+ const format = toSTTAudioFormat(ext, mimetype);
469
+ const supported = handler?.getSupportedFormats() ?? [];
470
+ if (!format || (supported.length > 0 && !supported.includes(format))) {
471
+ const displayName = provider === "whisper" ? "Whisper" : label;
472
+ return skipped(`format is not one ${displayName} accepts (extension "${ext ?? "none"}", mimetype "${mimetype ?? "none"}"); supported: ${supported.join(", ")}`);
590
473
  }
591
- const payload = await response.json();
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;
604
- if (rawText.trim().length > 0) {
605
- logger.debug(`[AudioProcessor] Transcribed ${filename} via openai-whisper (${rawText.trim().length} chars)`);
606
- return {
607
- transcript: rawText.trim(),
608
- hasTranscript: true,
609
- transcriptionProvider: "openai-whisper",
610
- transcriptionLanguage: typeof record.language === "string" && record.language.length > 0
611
- ? record.language
612
- : options?.language,
613
- transcriptionDuration: durationValue,
614
- transcriptionSkippedReason: undefined,
615
- };
616
- }
617
- // A successful call that returned nothing is a legitimate outcome
618
- // (silence, music, no speech) — distinct from a failure.
619
- return skipped("Whisper returned an empty transcript for this audio", "openai-whisper");
620
- }
621
- catch (error) {
622
- // Transcription stays best-effort — a failure must never kill the whole
623
- // processing pipeline. But discarding the error outright, as this block
624
- // used to, made a bad API key, a rate limit and a network blip all look
625
- // identical to "this file has no speech in it".
626
- const message = error instanceof Error ? error.message : String(error);
627
- return skipped(`transcription request failed — ${message}`);
628
- }
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
474
  const sttOptions = {
475
+ format,
476
+ timeoutMs: AUDIO_CONFIG.TRANSCRIPTION_TIMEOUT_MS,
477
+ maxAudioBytes: AUDIO_CONFIG.WHISPER_MAX_SIZE_MB * 1024 * 1024,
652
478
  ...(options?.language ? { language: options.language } : {}),
653
479
  ...(options?.transcriptionModel
654
480
  ? { model: options.transcriptionModel }
655
481
  : {}),
656
- ...(format ? { format } : {}),
482
+ ...(options?.prompt ? { prompt: options.prompt } : {}),
657
483
  };
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
- }
484
+ // `withTimeout` bounds handlers that keep no timer of their own; the
485
+ // ones that do abort their request at the same deadline.
486
+ const result = await withTimeout(STTProcessor.transcribe(buffer, provider, sttOptions, credentials), AUDIO_CONFIG.TRANSCRIPTION_TIMEOUT_MS, label, "generate");
667
487
  const text = result.text.trim();
668
488
  if (text.length === 0) {
669
- return skipped(`${label} returned an empty transcript for this audio`, label);
489
+ // A successful call that returned nothing is a legitimate outcome
490
+ // (silence, music, no speech) — distinct from a failure.
491
+ const displayName = provider === "whisper" ? "Whisper" : label;
492
+ return skipped(`${displayName} returned an empty transcript for this audio`, label);
670
493
  }
671
494
  logger.debug(`[AudioProcessor] Transcribed ${filename} via ${label} (${text.length} chars)`);
672
495
  return {
673
496
  transcript: text,
674
497
  hasTranscript: true,
675
498
  transcriptionProvider: label,
676
- transcriptionLanguage: result.language ?? options?.language,
499
+ transcriptionLanguage: result.language && result.language.length > 0
500
+ ? result.language
501
+ : options?.language,
677
502
  transcriptionDuration: result.duration,
678
503
  transcriptionSkippedReason: undefined,
679
504
  };
680
505
  }
681
506
  catch (error) {
507
+ // Transcription stays best-effort — a failure must never kill the whole
508
+ // processing pipeline. But discarding the error outright made a bad API
509
+ // key, a rate limit and a network blip all look identical to "this
510
+ // file has no speech in it".
682
511
  const message = error instanceof Error ? error.message : String(error);
683
512
  return skipped(`transcription request failed — ${message}`);
684
513
  }
@@ -179,11 +179,9 @@ export declare class VideoProcessor extends BaseFileProcessor<ProcessedVideo> {
179
179
  * working implementation of the same thing, and `--transcribe-audio` would
180
180
  * still do nothing.
181
181
  *
182
- * The Whisper call is reproduced here rather than shared with
183
- * `AudioProcessor`: extracting it into a common module is the better
184
- * long-term shape, but that file is being edited concurrently, and a
185
- * merge conflict in the audio pipeline is a worse outcome than fifty
186
- * duplicated lines. The duplication is worth removing once both land.
182
+ * The call goes through `STTProcessor`, like `AudioProcessor` and
183
+ * `transcribe()`, so the default-provider rule, `OPENAI_STT_*` and every
184
+ * STT handler apply to video too.
187
185
  *
188
186
  * ## Failure behaviour
189
187
  *