@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +616 -595
- package/dist/cli/commands/serve.d.ts +5 -3
- package/dist/cli/commands/serve.js +113 -20
- package/dist/cli/commands/server.js +13 -6
- package/dist/cli/commands/transcribe.d.ts +24 -0
- package/dist/cli/commands/transcribe.js +718 -0
- package/dist/cli/factories/commandFactory.d.ts +10 -0
- package/dist/cli/factories/commandFactory.js +13 -0
- package/dist/cli/parser.js +3 -0
- package/dist/cli/utils/serverUtils.d.ts +28 -0
- package/dist/cli/utils/serverUtils.js +71 -0
- package/dist/factories/mediaHandlerCatalog.js +11 -9
- package/dist/factories/sttDescriptors.d.ts +63 -0
- package/dist/factories/sttDescriptors.js +286 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +7 -1
- package/dist/neurolink.d.ts +68 -1
- package/dist/neurolink.js +202 -4
- package/dist/processors/media/AudioProcessor.d.ts +15 -56
- package/dist/processors/media/AudioProcessor.js +105 -276
- package/dist/processors/media/VideoProcessor.d.ts +3 -5
- package/dist/processors/media/VideoProcessor.js +52 -51
- package/dist/server/abstract/baseServerAdapter.d.ts +28 -1
- package/dist/server/abstract/baseServerAdapter.js +84 -4
- package/dist/server/adapters/expressAdapter.d.ts +1 -0
- package/dist/server/adapters/expressAdapter.js +50 -10
- package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
- package/dist/server/adapters/fastifyAdapter.js +51 -8
- package/dist/server/adapters/honoAdapter.d.ts +1 -4
- package/dist/server/adapters/honoAdapter.js +95 -8
- package/dist/server/adapters/koaAdapter.d.ts +7 -0
- package/dist/server/adapters/koaAdapter.js +71 -10
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.js +3 -0
- package/dist/server/routes/transcribeRoutes.d.ts +23 -0
- package/dist/server/routes/transcribeRoutes.js +506 -0
- package/dist/server/utils/rawBody.d.ts +32 -0
- package/dist/server/utils/rawBody.js +76 -0
- package/dist/server/utils/validation.d.ts +192 -0
- package/dist/server/utils/validation.js +100 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
- package/dist/server/websocket/transcribeWebSocket.js +264 -0
- package/dist/types/cli.d.ts +47 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/providers.d.ts +7 -1
- package/dist/types/server.d.ts +117 -1
- package/dist/types/stt.d.ts +160 -7
- package/dist/types/transcribe.d.ts +506 -0
- package/dist/types/transcribe.js +29 -0
- package/dist/utils/speakerTurns.d.ts +28 -0
- package/dist/utils/speakerTurns.js +93 -0
- package/dist/utils/sttProcessor.d.ts +20 -2
- package/dist/utils/sttProcessor.js +73 -5
- package/dist/voice/correction/dictionary.d.ts +49 -0
- package/dist/voice/correction/dictionary.js +134 -0
- package/dist/voice/correction/guard.d.ts +35 -0
- package/dist/voice/correction/guard.js +161 -0
- package/dist/voice/correction/index.d.ts +47 -0
- package/dist/voice/correction/index.js +211 -0
- package/dist/voice/correction/rewrite.d.ts +92 -0
- package/dist/voice/correction/rewrite.js +322 -0
- package/dist/voice/errors.d.ts +15 -0
- package/dist/voice/errors.js +45 -0
- package/dist/voice/index.d.ts +20 -2
- package/dist/voice/index.js +75 -9
- package/dist/voice/providers/AzureSTT.d.ts +10 -7
- package/dist/voice/providers/AzureSTT.js +23 -75
- package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
- package/dist/voice/providers/DeepgramSTT.js +109 -13
- package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
- package/dist/voice/providers/ElevenLabsSTT.js +34 -8
- package/dist/voice/providers/GoogleSTT.d.ts +10 -8
- package/dist/voice/providers/GoogleSTT.js +21 -75
- package/dist/voice/providers/OpenAISTT.d.ts +30 -9
- package/dist/voice/providers/OpenAISTT.js +196 -21
- package/dist/voice/providers/WhistleSTT.d.ts +87 -0
- package/dist/voice/providers/WhistleSTT.js +313 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
- package/dist/voice/streaming/endpointer.d.ts +81 -0
- package/dist/voice/streaming/endpointer.js +247 -0
- package/dist/voice/streaming/localAgreement.d.ts +30 -0
- package/dist/voice/streaming/localAgreement.js +44 -0
- package/dist/voice/sttEnvCredentials.d.ts +12 -0
- package/dist/voice/sttEnvCredentials.js +12 -0
- package/dist/voice/transcribe.d.ts +47 -0
- package/dist/voice/transcribe.js +519 -0
- package/dist/voice/transcribeStream.d.ts +4 -0
- package/dist/voice/transcribeStream.js +584 -0
- package/dist/voice/whistle/assets.d.ts +47 -0
- package/dist/voice/whistle/assets.js +270 -0
- package/dist/voice/whistle/audio.d.ts +32 -0
- package/dist/voice/whistle/audio.js +267 -0
- package/dist/voice/whistle/engine.d.ts +32 -0
- package/dist/voice/whistle/engine.js +193 -0
- package/dist/voice/whistle/worker.d.ts +18 -0
- package/dist/voice/whistle/worker.js +294 -0
- package/docs-site/static/search-index.json +69 -10
- package/models/whistle/README.md +18 -0
- package/models/whistle/needle.js +2 -0
- package/models/whistle/needle.wasm +0 -0
- package/models/whistle/whistle.cact +0 -0
- package/package.json +7 -3
|
@@ -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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `
|
|
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
|
|
135
|
-
openai:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
*
|
|
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()
|
|
362
|
+
async selectProvider(requested) {
|
|
363
|
+
const normalized = requested?.trim();
|
|
412
364
|
if (normalized) {
|
|
413
|
-
const resolved =
|
|
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: ${
|
|
369
|
+
`supported: ${STT_PROVIDER_DESCRIPTORS.map((d) => d.name).join(", ")}`,
|
|
418
370
|
};
|
|
419
371
|
}
|
|
420
|
-
if (!
|
|
421
|
-
const
|
|
372
|
+
if (!isSTTProviderConfigured(resolved)) {
|
|
373
|
+
const envVars = getSTTProviderDescriptor(resolved)?.envVars ?? [];
|
|
422
374
|
return {
|
|
423
|
-
reason: `transcription provider "${
|
|
375
|
+
reason: `transcription provider "${normalized}" was requested but is not configured — ` +
|
|
424
376
|
`set one of: ${envVars.join(", ")}`,
|
|
425
377
|
};
|
|
426
378
|
}
|
|
427
|
-
const
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
...(
|
|
482
|
+
...(options?.prompt ? { prompt: options.prompt } : {}),
|
|
657
483
|
};
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
183
|
-
* `
|
|
184
|
-
*
|
|
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
|
*
|