@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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module voice/providers/ElevenLabsSTT
|
|
9
9
|
*/
|
|
10
|
-
import type { TTSAudioFormat, STTHandler, STTLanguage, STTOptions, STTResult } from "../../types/index.js";
|
|
10
|
+
import type { TTSAudioFormat, STTCredentials, STTHandler, STTLanguage, STTOptions, STTResult } from "../../types/index.js";
|
|
11
11
|
/**
|
|
12
12
|
* ElevenLabs Scribe Speech-to-Text Handler
|
|
13
13
|
*
|
|
@@ -20,15 +20,25 @@ import type { TTSAudioFormat, STTHandler, STTLanguage, STTOptions, STTResult } f
|
|
|
20
20
|
export declare class ElevenLabsSTT implements STTHandler {
|
|
21
21
|
private readonly apiKey;
|
|
22
22
|
private readonly baseUrl;
|
|
23
|
+
private readonly defaultTimeoutMs;
|
|
23
24
|
/** Scribe accepts files up to 1 GB / ~4.5 hours; 2 hours is a conservative cap. */
|
|
24
25
|
readonly maxAudioDuration = 7200;
|
|
25
26
|
readonly supportsStreaming = false;
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* @param apiKeyOrCredentials - The `credentials.stt.elevenlabs` slice
|
|
29
|
+
* (`apiKey`, `baseURL`, `timeoutMs`), or an API key (the original
|
|
30
|
+
* positional form). The environment fills whatever is left out:
|
|
31
|
+
* `ELEVENLABS_API_KEY`, and `ELEVENLABS_STT_BASE_URL` before the
|
|
32
|
+
* account-wide `ELEVENLABS_BASE_URL`.
|
|
33
|
+
*/
|
|
34
|
+
constructor(apiKeyOrCredentials?: string | STTCredentials["elevenlabs"]);
|
|
27
35
|
isConfigured(): boolean;
|
|
28
36
|
getSupportedFormats(): TTSAudioFormat[];
|
|
29
37
|
getSupportedLanguages(): Promise<STTLanguage[]>;
|
|
30
38
|
transcribe(audio: Buffer | ArrayBuffer, options?: STTOptions): Promise<STTResult>;
|
|
31
39
|
private toSTTResult;
|
|
40
|
+
/** A usable timeout: a finite, positive number of milliseconds. */
|
|
41
|
+
private static validTimeout;
|
|
32
42
|
/** A finite number clamped to [0, 1]; `undefined` for anything else. */
|
|
33
43
|
private static unitInterval;
|
|
34
44
|
/** Mean `exp(logprob)` over the words that carry a finite one, clamped to [0, 1]; `undefined` when none does. */
|
|
@@ -28,13 +28,36 @@ const DEFAULT_MODEL = "scribe_v2";
|
|
|
28
28
|
export class ElevenLabsSTT {
|
|
29
29
|
apiKey;
|
|
30
30
|
baseUrl;
|
|
31
|
+
defaultTimeoutMs;
|
|
31
32
|
/** Scribe accepts files up to 1 GB / ~4.5 hours; 2 hours is a conservative cap. */
|
|
32
33
|
maxAudioDuration = 7200;
|
|
33
34
|
supportsStreaming = false;
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* @param apiKeyOrCredentials - The `credentials.stt.elevenlabs` slice
|
|
37
|
+
* (`apiKey`, `baseURL`, `timeoutMs`), or an API key (the original
|
|
38
|
+
* positional form). The environment fills whatever is left out:
|
|
39
|
+
* `ELEVENLABS_API_KEY`, and `ELEVENLABS_STT_BASE_URL` before the
|
|
40
|
+
* account-wide `ELEVENLABS_BASE_URL`.
|
|
41
|
+
*/
|
|
42
|
+
constructor(apiKeyOrCredentials) {
|
|
43
|
+
// `typeof x === "string"` narrows the same way with and without
|
|
44
|
+
// strictNullChecks (the react-hooks build runs tsc without it); an
|
|
45
|
+
// `=== undefined` test does not.
|
|
46
|
+
const slice = typeof apiKeyOrCredentials === "string"
|
|
47
|
+
? { apiKey: apiKeyOrCredentials }
|
|
48
|
+
: (apiKeyOrCredentials ?? {});
|
|
49
|
+
const resolvedKey = (slice.apiKey ??
|
|
50
|
+
process.env.ELEVENLABS_API_KEY ??
|
|
51
|
+
"").trim();
|
|
36
52
|
this.apiKey = resolvedKey.length > 0 ? resolvedKey : null;
|
|
37
|
-
this.baseUrl = ElevenLabsSTT.normalizeBaseUrl(
|
|
53
|
+
this.baseUrl = ElevenLabsSTT.normalizeBaseUrl(("baseURL" in slice ? slice.baseURL : undefined) ??
|
|
54
|
+
process.env.ELEVENLABS_STT_BASE_URL ??
|
|
55
|
+
process.env.ELEVENLABS_BASE_URL ??
|
|
56
|
+
DEFAULT_BASE_URL);
|
|
57
|
+
const sliceTimeout = "timeoutMs" in slice ? slice.timeoutMs : undefined;
|
|
58
|
+
this.defaultTimeoutMs = ElevenLabsSTT.validTimeout(sliceTimeout)
|
|
59
|
+
? Math.min(sliceTimeout, MAX_TIMEOUT_MS)
|
|
60
|
+
: DEFAULT_TIMEOUT_MS;
|
|
38
61
|
}
|
|
39
62
|
isConfigured() {
|
|
40
63
|
return this.apiKey !== null;
|
|
@@ -146,7 +169,8 @@ export class ElevenLabsSTT {
|
|
|
146
169
|
// otherwise, and an injected default would pin every request to one
|
|
147
170
|
// language. (STTProcessor passes options through untouched, so a caller
|
|
148
171
|
// who leaves `language` unset reaches here with it unset.)
|
|
149
|
-
if (options.language
|
|
172
|
+
if (options.language &&
|
|
173
|
+
options.language.trim().toLowerCase() !== "auto") {
|
|
150
174
|
formData.append("language_code", this.toScribeLanguageCode(options.language));
|
|
151
175
|
}
|
|
152
176
|
// ElevenLabs defaults this to true; a plain transcript is the better
|
|
@@ -160,11 +184,9 @@ export class ElevenLabsSTT {
|
|
|
160
184
|
const baseUrl = elevenOptions.baseUrl
|
|
161
185
|
? ElevenLabsSTT.normalizeBaseUrl(elevenOptions.baseUrl)
|
|
162
186
|
: this.baseUrl;
|
|
163
|
-
const timeoutMs =
|
|
164
|
-
Number.isFinite(elevenOptions.timeoutMs) &&
|
|
165
|
-
elevenOptions.timeoutMs > 0
|
|
187
|
+
const timeoutMs = ElevenLabsSTT.validTimeout(elevenOptions.timeoutMs)
|
|
166
188
|
? Math.min(elevenOptions.timeoutMs, MAX_TIMEOUT_MS)
|
|
167
|
-
:
|
|
189
|
+
: this.defaultTimeoutMs;
|
|
168
190
|
// The timer covers the response headers AND the JSON body: a server
|
|
169
191
|
// that stalls mid-body used to hold the transcription — and the
|
|
170
192
|
// generate() it fed — until undici's own ~5-minute body timeout, long
|
|
@@ -298,6 +320,10 @@ export class ElevenLabsSTT {
|
|
|
298
320
|
}
|
|
299
321
|
return result;
|
|
300
322
|
}
|
|
323
|
+
/** A usable timeout: a finite, positive number of milliseconds. */
|
|
324
|
+
static validTimeout(value) {
|
|
325
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
326
|
+
}
|
|
301
327
|
/** A finite number clamped to [0, 1]; `undefined` for anything else. */
|
|
302
328
|
static unitInterval(value) {
|
|
303
329
|
return typeof value === "number" && Number.isFinite(value)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @module voice/providers/GoogleSTT
|
|
7
7
|
*/
|
|
8
|
-
import type { TTSAudioFormat, STTHandler, STTLanguage, STTOptions, STTResult
|
|
8
|
+
import type { TTSAudioFormat, STTCredentials, STTHandler, STTLanguage, STTOptions, STTResult } from "../../types/index.js";
|
|
9
9
|
/**
|
|
10
10
|
* Google Cloud Speech-to-Text Handler
|
|
11
11
|
*
|
|
@@ -23,19 +23,21 @@ export declare class GoogleSTT implements STTHandler {
|
|
|
23
23
|
*/
|
|
24
24
|
readonly maxAudioDuration = 60;
|
|
25
25
|
/**
|
|
26
|
-
* True streaming requires gRPC (not
|
|
27
|
-
*
|
|
26
|
+
* True streaming requires gRPC (not implemented). `transcribeStream()` on
|
|
27
|
+
* the SDK streams this handler through the generic chunked adapter.
|
|
28
28
|
*/
|
|
29
29
|
readonly supportsStreaming = false;
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* @param apiKeyOrCredentials - The `credentials.stt.google` slice
|
|
32
|
+
* (`apiKey`, `credentialsPath`), or an API key (the original positional
|
|
33
|
+
* form). The environment fills whatever is left out.
|
|
34
|
+
* @param credentialsPath - Service-account file, positional form only.
|
|
35
|
+
*/
|
|
36
|
+
constructor(apiKeyOrCredentials?: string | STTCredentials["google"], credentialsPath?: string);
|
|
31
37
|
isConfigured(): boolean;
|
|
32
38
|
getSupportedFormats(): TTSAudioFormat[];
|
|
33
39
|
getSupportedLanguages(): Promise<STTLanguage[]>;
|
|
34
40
|
transcribe(audio: Buffer | ArrayBuffer, options?: STTOptions): Promise<STTResult>;
|
|
35
|
-
/**
|
|
36
|
-
* Streaming transcription (placeholder - requires WebSocket/gRPC)
|
|
37
|
-
*/
|
|
38
|
-
transcribeStream(audioStream: AsyncIterable<Buffer>, options: STTOptions): AsyncIterable<TranscriptionSegment>;
|
|
39
41
|
/**
|
|
40
42
|
* Get encoding string for audio format
|
|
41
43
|
*/
|
|
@@ -24,21 +24,33 @@ export class GoogleSTT {
|
|
|
24
24
|
*/
|
|
25
25
|
maxAudioDuration = 60;
|
|
26
26
|
/**
|
|
27
|
-
* True streaming requires gRPC (not
|
|
28
|
-
*
|
|
27
|
+
* True streaming requires gRPC (not implemented). `transcribeStream()` on
|
|
28
|
+
* the SDK streams this handler through the generic chunked adapter.
|
|
29
29
|
*/
|
|
30
30
|
supportsStreaming = false;
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* @param apiKeyOrCredentials - The `credentials.stt.google` slice
|
|
33
|
+
* (`apiKey`, `credentialsPath`), or an API key (the original positional
|
|
34
|
+
* form). The environment fills whatever is left out.
|
|
35
|
+
* @param credentialsPath - Service-account file, positional form only.
|
|
36
|
+
*/
|
|
37
|
+
constructor(apiKeyOrCredentials, credentialsPath) {
|
|
38
|
+
// `typeof x === "string"` narrows the same way with and without
|
|
39
|
+
// strictNullChecks (the react-hooks build runs tsc without it); an
|
|
40
|
+
// `=== undefined` test does not.
|
|
41
|
+
const slice = typeof apiKeyOrCredentials === "string"
|
|
42
|
+
? { apiKey: apiKeyOrCredentials, credentialsPath }
|
|
43
|
+
: { credentialsPath, ...(apiKeyOrCredentials ?? {}) };
|
|
32
44
|
// Accept GOOGLE_AI_API_KEY / GEMINI_API_KEY as aliases since `.env.example`
|
|
33
45
|
// documents those as the canonical Google credentials and forcing users to
|
|
34
46
|
// also set GOOGLE_API_KEY just for STT was a footgun (Copilot review).
|
|
35
|
-
const resolvedKey = (apiKey ??
|
|
47
|
+
const resolvedKey = (slice.apiKey ??
|
|
36
48
|
process.env.GOOGLE_API_KEY ??
|
|
37
49
|
process.env.GOOGLE_AI_API_KEY ??
|
|
38
50
|
process.env.GEMINI_API_KEY ??
|
|
39
51
|
"").trim();
|
|
40
52
|
this.apiKey = resolvedKey.length > 0 ? resolvedKey : null;
|
|
41
|
-
const resolvedCreds = (credentialsPath ??
|
|
53
|
+
const resolvedCreds = (slice.credentialsPath ??
|
|
42
54
|
process.env.GOOGLE_APPLICATION_CREDENTIALS ??
|
|
43
55
|
"").trim();
|
|
44
56
|
this.credentialsPath = resolvedCreds.length > 0 ? resolvedCreds : null;
|
|
@@ -166,7 +178,10 @@ export class GoogleSTT {
|
|
|
166
178
|
: options.sampleRate
|
|
167
179
|
? { sampleRateHertz: options.sampleRate }
|
|
168
180
|
: {}),
|
|
169
|
-
|
|
181
|
+
// Google needs a language; "auto" (no pin) falls back to the default.
|
|
182
|
+
languageCode: options.language && options.language.toLowerCase() !== "auto"
|
|
183
|
+
? options.language
|
|
184
|
+
: "en-US",
|
|
170
185
|
enableAutomaticPunctuation: options.punctuation ?? true,
|
|
171
186
|
enableWordTimeOffsets: options.wordTimestamps ?? false,
|
|
172
187
|
enableWordConfidence: true,
|
|
@@ -313,75 +328,6 @@ export class GoogleSTT {
|
|
|
313
328
|
throw STTError.transcriptionFailed(errorMessage, "google-stt", err instanceof Error ? err : undefined);
|
|
314
329
|
}
|
|
315
330
|
}
|
|
316
|
-
/**
|
|
317
|
-
* Streaming transcription (placeholder - requires WebSocket/gRPC)
|
|
318
|
-
*/
|
|
319
|
-
async *transcribeStream(audioStream, options) {
|
|
320
|
-
// Google streaming STT requires gRPC or WebSocket connection
|
|
321
|
-
// For now, buffer and transcribe in chunks
|
|
322
|
-
const chunks = [];
|
|
323
|
-
let chunkIndex = 0;
|
|
324
|
-
for await (const chunk of audioStream) {
|
|
325
|
-
chunks.push(chunk);
|
|
326
|
-
// Process every ~5 seconds of audio (assuming 16kHz, 16-bit)
|
|
327
|
-
const bytesPerSecond = 16000 * 2; // 16kHz * 2 bytes
|
|
328
|
-
const totalBytes = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
329
|
-
if (totalBytes >= bytesPerSecond * 5) {
|
|
330
|
-
const audio = Buffer.concat(chunks);
|
|
331
|
-
chunks.length = 0;
|
|
332
|
-
try {
|
|
333
|
-
const result = await this.transcribe(audio, options);
|
|
334
|
-
yield {
|
|
335
|
-
index: chunkIndex++,
|
|
336
|
-
text: result.text,
|
|
337
|
-
isFinal: false,
|
|
338
|
-
confidence: result.confidence,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
catch (err) {
|
|
342
|
-
// M5: distinguish permanent (auth, schema, 4xx) from transient
|
|
343
|
-
// (5xx, 429, network) errors. Permanent errors retry indefinitely
|
|
344
|
-
// and racks up failed API calls; rethrow to terminate the stream.
|
|
345
|
-
// Transient errors get logged and skipped so a multi-minute audio
|
|
346
|
-
// stream can recover from a transient hiccup.
|
|
347
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
348
|
-
const isPermanent = /\b(401|403|404|UNAUTHENTICATED|PERMISSION_DENIED|INVALID_ARGUMENT|UNAUTHORIZED|FORBIDDEN|invalid.*credential|invalid.*key)\b/i.test(msg);
|
|
349
|
-
if (isPermanent) {
|
|
350
|
-
logger.error(`[GoogleSTTHandler] Permanent chunk error — terminating stream: ${msg}`);
|
|
351
|
-
throw err;
|
|
352
|
-
}
|
|
353
|
-
logger.warn(`[GoogleSTTHandler] Transient chunk failure (skipping): ${msg}`);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
// Process remaining audio
|
|
358
|
-
if (chunks.length > 0) {
|
|
359
|
-
const audio = Buffer.concat(chunks);
|
|
360
|
-
try {
|
|
361
|
-
const result = await this.transcribe(audio, options);
|
|
362
|
-
yield {
|
|
363
|
-
index: chunkIndex,
|
|
364
|
-
text: result.text,
|
|
365
|
-
isFinal: true,
|
|
366
|
-
confidence: result.confidence,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
catch (err) {
|
|
370
|
-
// Don't swallow the final chunk's terminal errors — auth/config/4xx
|
|
371
|
-
// failures here would otherwise look like a successful empty
|
|
372
|
-
// transcription, hiding the root cause from callers (CodeRabbit
|
|
373
|
-
// review). Mirror the permanent-vs-transient split used in the
|
|
374
|
-
// chunk loop above (Azure/Google share this taxonomy).
|
|
375
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
376
|
-
const isPermanent = /\b(401|403|404|Forbidden|Unauthorized|Invalid.*credential|Invalid.*key|Permission|PERMISSION_DENIED|UNAUTHENTICATED|INVALID_ARGUMENT)\b/i.test(msg);
|
|
377
|
-
if (isPermanent) {
|
|
378
|
-
logger.error(`[GoogleSTTHandler] Permanent final-chunk error — surfacing: ${msg}`);
|
|
379
|
-
throw err;
|
|
380
|
-
}
|
|
381
|
-
logger.warn(`[GoogleSTTHandler] Final chunk transcription failed (transient): ${msg}`);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
331
|
/**
|
|
386
332
|
* Get encoding string for audio format
|
|
387
333
|
*/
|
|
@@ -5,17 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @module voice/providers/OpenAISTT
|
|
7
7
|
*/
|
|
8
|
-
import type { TTSAudioFormat, STTHandler, STTLanguage, STTOptions, STTResult } from "../../types/index.js";
|
|
9
|
-
/**
|
|
10
|
-
* OpenAI Whisper Speech-to-Text Handler
|
|
11
|
-
*
|
|
12
|
-
* Supports transcription and translation using OpenAI's Whisper model.
|
|
13
|
-
*
|
|
14
|
-
* @see https://platform.openai.com/docs/api-reference/audio
|
|
15
|
-
*/
|
|
8
|
+
import type { TTSAudioFormat, STTCredentials, STTHandler, STTLanguage, STTOptions, STTResult } from "../../types/index.js";
|
|
16
9
|
export declare class OpenAISTT implements STTHandler {
|
|
17
10
|
private readonly apiKey;
|
|
18
11
|
private readonly baseUrl;
|
|
12
|
+
private readonly defaultTimeoutMs;
|
|
13
|
+
/** The `model` sent when the request names none. */
|
|
14
|
+
private readonly defaultModel;
|
|
19
15
|
/**
|
|
20
16
|
* Maximum audio duration in seconds (25 minutes)
|
|
21
17
|
*/
|
|
@@ -24,11 +20,36 @@ export declare class OpenAISTT implements STTHandler {
|
|
|
24
20
|
* Whisper does not support streaming
|
|
25
21
|
*/
|
|
26
22
|
readonly supportsStreaming = false;
|
|
27
|
-
|
|
23
|
+
/**
|
|
24
|
+
* @param apiKeyOrCredentials - The `credentials.stt.whisper` slice
|
|
25
|
+
* (`apiKey`, `baseURL`, `timeoutMs`), or — the original positional form —
|
|
26
|
+
* an API key. Anything left out falls back to the environment.
|
|
27
|
+
* @param baseUrl - Base URL, positional form only.
|
|
28
|
+
*/
|
|
29
|
+
constructor(apiKeyOrCredentials?: string | STTCredentials["whisper"], baseUrl?: string);
|
|
30
|
+
/** True when pointed at OpenAI itself rather than a compatible server. */
|
|
31
|
+
private get isOfficialEndpoint();
|
|
32
|
+
/**
|
|
33
|
+
* OpenAI itself needs a key. A compatible server at another base URL may
|
|
34
|
+
* not (a self-hosted engine on the local network), so a base URL alone
|
|
35
|
+
* configures the handler and the request goes without an Authorization header.
|
|
36
|
+
*/
|
|
28
37
|
isConfigured(): boolean;
|
|
29
38
|
getSupportedFormats(): TTSAudioFormat[];
|
|
30
39
|
getSupportedLanguages(): Promise<STTLanguage[]>;
|
|
31
40
|
transcribe(audio: Buffer | ArrayBuffer, options?: STTOptions): Promise<STTResult>;
|
|
41
|
+
/**
|
|
42
|
+
* The context prompt: the caller's own text, then the vocabulary as one
|
|
43
|
+
* sentence. Whisper-style decoders treat the prompt as preceding text, so a
|
|
44
|
+
* plain list of names biases spelling without being read as an instruction.
|
|
45
|
+
*/
|
|
46
|
+
private static buildPrompt;
|
|
47
|
+
/**
|
|
48
|
+
* Optional language-identification fields a self-hosted server may add to
|
|
49
|
+
* `verbose_json`. Each is read only when it has the expected shape; a
|
|
50
|
+
* server that sends none (OpenAI itself) contributes nothing.
|
|
51
|
+
*/
|
|
52
|
+
private static readLanguageExtensions;
|
|
32
53
|
/**
|
|
33
54
|
* Get MIME type for audio format. Whisper auto-detects from headers, but
|
|
34
55
|
* sending a correct MIME helps providers / proxies that sniff Content-Type.
|
|
@@ -7,16 +7,62 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
9
|
import { STTError } from "../errors.js";
|
|
10
|
+
const OPENAI_STT_DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
10
12
|
/**
|
|
11
13
|
* OpenAI Whisper Speech-to-Text Handler
|
|
12
14
|
*
|
|
13
|
-
* Supports transcription and translation using OpenAI's Whisper model
|
|
15
|
+
* Supports transcription and translation using OpenAI's Whisper model, and
|
|
16
|
+
* any server that speaks the same `/audio/transcriptions` wire format: vLLM
|
|
17
|
+
* serving an ASR model, LiteLLM, or NeuroLink's diarization sidecar. Point it
|
|
18
|
+
* elsewhere with the `baseUrl` constructor argument or `OPENAI_STT_BASE_URL`;
|
|
19
|
+
* the key then comes from `OPENAI_STT_API_KEY` (falling back to
|
|
20
|
+
* `OPENAI_API_KEY`), so a keyless local server can use a placeholder without
|
|
21
|
+
* touching the real OpenAI credential. Set `OPENAI_STT_TIMEOUT_MS` for long
|
|
22
|
+
* recordings — a local server transcribing a whole meeting needs minutes.
|
|
14
23
|
*
|
|
15
24
|
* @see https://platform.openai.com/docs/api-reference/audio
|
|
16
25
|
*/
|
|
26
|
+
/**
|
|
27
|
+
* Trailing slashes off a caller-supplied URL, in one linear pass. A `/\/+$/`
|
|
28
|
+
* regex retries from every slash of a long run and is quadratic on hostile
|
|
29
|
+
* input (CodeQL js/polynomial-redos).
|
|
30
|
+
*/
|
|
31
|
+
function stripTrailingSlashes(url) {
|
|
32
|
+
let end = url.length;
|
|
33
|
+
while (end > 0 && url.charCodeAt(end - 1) === 47) {
|
|
34
|
+
end--;
|
|
35
|
+
}
|
|
36
|
+
return url.slice(0, end);
|
|
37
|
+
}
|
|
38
|
+
const MIN_TIMEOUT_MS = 1_000;
|
|
39
|
+
const MAX_TIMEOUT_MS = 60 * 60 * 1_000;
|
|
40
|
+
/**
|
|
41
|
+
* A finite timeout within [1 s, 1 h], else the handler's own default. Written
|
|
42
|
+
* as explicit range checks: the value can come from a request body and ends
|
|
43
|
+
* up in a timer, and a comparison against a constant bound is what static
|
|
44
|
+
* analysis recognises as bounding it.
|
|
45
|
+
*/
|
|
46
|
+
function clampTimeoutMs(requested, fallback) {
|
|
47
|
+
if (typeof requested !== "number" ||
|
|
48
|
+
!Number.isFinite(requested) ||
|
|
49
|
+
requested <= 0) {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
if (requested > MAX_TIMEOUT_MS) {
|
|
53
|
+
return MAX_TIMEOUT_MS;
|
|
54
|
+
}
|
|
55
|
+
if (requested < MIN_TIMEOUT_MS) {
|
|
56
|
+
return MIN_TIMEOUT_MS;
|
|
57
|
+
}
|
|
58
|
+
return Math.floor(requested);
|
|
59
|
+
}
|
|
17
60
|
export class OpenAISTT {
|
|
18
61
|
apiKey;
|
|
19
|
-
baseUrl
|
|
62
|
+
baseUrl;
|
|
63
|
+
defaultTimeoutMs;
|
|
64
|
+
/** The `model` sent when the request names none. */
|
|
65
|
+
defaultModel;
|
|
20
66
|
/**
|
|
21
67
|
* Maximum audio duration in seconds (25 minutes)
|
|
22
68
|
*/
|
|
@@ -25,12 +71,54 @@ export class OpenAISTT {
|
|
|
25
71
|
* Whisper does not support streaming
|
|
26
72
|
*/
|
|
27
73
|
supportsStreaming = false;
|
|
28
|
-
|
|
29
|
-
|
|
74
|
+
/**
|
|
75
|
+
* @param apiKeyOrCredentials - The `credentials.stt.whisper` slice
|
|
76
|
+
* (`apiKey`, `baseURL`, `timeoutMs`), or — the original positional form —
|
|
77
|
+
* an API key. Anything left out falls back to the environment.
|
|
78
|
+
* @param baseUrl - Base URL, positional form only.
|
|
79
|
+
*/
|
|
80
|
+
constructor(apiKeyOrCredentials, baseUrl) {
|
|
81
|
+
// `typeof x === "string"` narrows the same way with and without
|
|
82
|
+
// strictNullChecks (the react-hooks build runs tsc without it); an
|
|
83
|
+
// `=== undefined` test does not.
|
|
84
|
+
const slice = typeof apiKeyOrCredentials === "string"
|
|
85
|
+
? { apiKey: apiKeyOrCredentials, baseURL: baseUrl }
|
|
86
|
+
: { baseURL: baseUrl, ...(apiKeyOrCredentials ?? {}) };
|
|
87
|
+
this.baseUrl = stripTrailingSlashes((slice.baseURL ??
|
|
88
|
+
process.env.OPENAI_STT_BASE_URL ??
|
|
89
|
+
OPENAI_STT_DEFAULT_BASE_URL).trim());
|
|
90
|
+
// A dedicated STT key wins so a local endpoint never borrows the real
|
|
91
|
+
// OpenAI credential; the plain key keeps working for api.openai.com.
|
|
92
|
+
// `OPENAI_API_KEY` is OpenAI's credential and goes only to api.openai.com;
|
|
93
|
+
// a custom base URL takes the dedicated STT key or the slice's own, so a
|
|
94
|
+
// self-hosted or proxied endpoint never receives the real OpenAI key.
|
|
95
|
+
const resolvedKey = (slice.apiKey ??
|
|
96
|
+
process.env.OPENAI_STT_API_KEY ??
|
|
97
|
+
(this.isOfficialEndpoint ? process.env.OPENAI_API_KEY : undefined) ??
|
|
98
|
+
"").trim();
|
|
30
99
|
this.apiKey = resolvedKey.length > 0 ? resolvedKey : null;
|
|
100
|
+
const envTimeout = Number(process.env.OPENAI_STT_TIMEOUT_MS);
|
|
101
|
+
const sliceTimeout = "timeoutMs" in slice ? Number(slice.timeoutMs) : Number.NaN;
|
|
102
|
+
this.defaultTimeoutMs =
|
|
103
|
+
Number.isFinite(sliceTimeout) && sliceTimeout > 0
|
|
104
|
+
? sliceTimeout
|
|
105
|
+
: Number.isFinite(envTimeout) && envTimeout > 0
|
|
106
|
+
? envTimeout
|
|
107
|
+
: DEFAULT_TIMEOUT_MS;
|
|
108
|
+
this.defaultModel =
|
|
109
|
+
slice.model ?? process.env.OPENAI_STT_MODEL ?? "whisper-1";
|
|
31
110
|
}
|
|
111
|
+
/** True when pointed at OpenAI itself rather than a compatible server. */
|
|
112
|
+
get isOfficialEndpoint() {
|
|
113
|
+
return this.baseUrl === OPENAI_STT_DEFAULT_BASE_URL;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* OpenAI itself needs a key. A compatible server at another base URL may
|
|
117
|
+
* not (a self-hosted engine on the local network), so a base URL alone
|
|
118
|
+
* configures the handler and the request goes without an Authorization header.
|
|
119
|
+
*/
|
|
32
120
|
isConfigured() {
|
|
33
|
-
return this.apiKey !== null;
|
|
121
|
+
return this.apiKey !== null || !this.isOfficialEndpoint;
|
|
34
122
|
}
|
|
35
123
|
getSupportedFormats() {
|
|
36
124
|
// OpenAI Whisper transcription API accepts: flac, m4a, mp3, mp4, mpeg,
|
|
@@ -128,7 +216,7 @@ export class OpenAISTT {
|
|
|
128
216
|
];
|
|
129
217
|
}
|
|
130
218
|
async transcribe(audio, options = {}) {
|
|
131
|
-
if (!this.
|
|
219
|
+
if (!this.isConfigured()) {
|
|
132
220
|
throw STTError.providerNotConfigured("whisper");
|
|
133
221
|
}
|
|
134
222
|
const audioBuffer = Buffer.isBuffer(audio) ? audio : Buffer.from(audio);
|
|
@@ -146,13 +234,16 @@ export class OpenAISTT {
|
|
|
146
234
|
});
|
|
147
235
|
formData.append("file", audioBlob, `audio.${options.format ?? "wav"}`);
|
|
148
236
|
// Add model
|
|
149
|
-
formData.append("model", whisperOptions.model ??
|
|
150
|
-
// Add optional parameters
|
|
151
|
-
|
|
152
|
-
|
|
237
|
+
formData.append("model", whisperOptions.model ?? this.defaultModel);
|
|
238
|
+
// Add optional parameters. No language (or "auto") leaves detection to
|
|
239
|
+
// the server; sending a guess would pin every request to it.
|
|
240
|
+
const language = options.language?.trim();
|
|
241
|
+
if (language && language.toLowerCase() !== "auto") {
|
|
242
|
+
formData.append("language", language);
|
|
153
243
|
}
|
|
154
|
-
|
|
155
|
-
|
|
244
|
+
const prompt = OpenAISTT.buildPrompt(whisperOptions.prompt, options.vocabulary);
|
|
245
|
+
if (prompt) {
|
|
246
|
+
formData.append("prompt", prompt);
|
|
156
247
|
}
|
|
157
248
|
if (whisperOptions.temperature !== undefined) {
|
|
158
249
|
formData.append("temperature", whisperOptions.temperature.toString());
|
|
@@ -165,26 +256,39 @@ export class OpenAISTT {
|
|
|
165
256
|
formData.append("timestamp_granularities[]", "word");
|
|
166
257
|
formData.append("timestamp_granularities[]", "segment");
|
|
167
258
|
}
|
|
259
|
+
// OpenAI's own API rejects fields it does not know, so the diarization
|
|
260
|
+
// switch is sent only to compatible servers that advertise speakers on
|
|
261
|
+
// their segments (e.g. the NeuroLink diarization sidecar).
|
|
262
|
+
if (options.speakerDiarization && !this.isOfficialEndpoint) {
|
|
263
|
+
formData.append("diarize", "true");
|
|
264
|
+
if (options.speakerCount !== undefined) {
|
|
265
|
+
formData.append("max_speakers", String(options.speakerCount));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
168
268
|
// Choose endpoint based on translation option
|
|
169
269
|
const endpoint = whisperOptions.translate
|
|
170
270
|
? `${this.baseUrl}/audio/translations`
|
|
171
271
|
: `${this.baseUrl}/audio/transcriptions`;
|
|
272
|
+
// A request-supplied timeout is bounded (1 s … 1 h): it reaches a timer,
|
|
273
|
+
// and an unbounded value from a route body could pin a slot for ever
|
|
274
|
+
// or abort at once (CodeQL js/resource-exhaustion).
|
|
275
|
+
const timeoutMs = clampTimeoutMs(whisperOptions.timeoutMs, this.defaultTimeoutMs);
|
|
172
276
|
const controller = new AbortController();
|
|
173
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
277
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
174
278
|
let response;
|
|
175
279
|
try {
|
|
176
280
|
response = await fetch(endpoint, {
|
|
177
281
|
method: "POST",
|
|
178
|
-
headers:
|
|
179
|
-
Authorization: `Bearer ${this.apiKey}
|
|
180
|
-
|
|
282
|
+
headers: this.apiKey
|
|
283
|
+
? { Authorization: `Bearer ${this.apiKey}` }
|
|
284
|
+
: undefined,
|
|
181
285
|
body: formData,
|
|
182
286
|
signal: controller.signal,
|
|
183
287
|
});
|
|
184
288
|
}
|
|
185
289
|
catch (fetchErr) {
|
|
186
290
|
if (fetchErr instanceof Error && fetchErr.name === "AbortError") {
|
|
187
|
-
throw STTError.transcriptionFailed(
|
|
291
|
+
throw STTError.transcriptionFailed(`OpenAI STT request timed out after ${Math.round(timeoutMs / 1000)} seconds`, "whisper", fetchErr);
|
|
188
292
|
}
|
|
189
293
|
throw fetchErr;
|
|
190
294
|
}
|
|
@@ -197,6 +301,23 @@ export class OpenAISTT {
|
|
|
197
301
|
.catch(() => Object.create(null));
|
|
198
302
|
const errorMessage = errorData.error?.message ||
|
|
199
303
|
`HTTP ${response.status}`;
|
|
304
|
+
// Some OpenAI-compatible servers (vLLM behind a gateway, for one) serve
|
|
305
|
+
// a model that cannot produce `verbose_json` and say so with a 400.
|
|
306
|
+
// Ask once more for plain `json` — the text is what matters; segments
|
|
307
|
+
// and word timings are simply absent — rather than failing the call.
|
|
308
|
+
if (response.status === 400 &&
|
|
309
|
+
responseFormat === "verbose_json" &&
|
|
310
|
+
!whisperOptions.responseFormat &&
|
|
311
|
+
/verbose_json|response_format/i.test(errorMessage)) {
|
|
312
|
+
if (logger.shouldLog("debug")) {
|
|
313
|
+
logger.debug("[OpenAISTT] server refused verbose_json; retrying with json", { endpoint, errorMessage });
|
|
314
|
+
}
|
|
315
|
+
return this.transcribe(audio, {
|
|
316
|
+
...options,
|
|
317
|
+
responseFormat: "json",
|
|
318
|
+
wordTimestamps: false,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
200
321
|
throw STTError.transcriptionFailed(errorMessage, "whisper");
|
|
201
322
|
}
|
|
202
323
|
const latency = Date.now() - startTime;
|
|
@@ -209,7 +330,7 @@ export class OpenAISTT {
|
|
|
209
330
|
metadata: {
|
|
210
331
|
latency,
|
|
211
332
|
provider: "whisper",
|
|
212
|
-
model: whisperOptions.model ??
|
|
333
|
+
model: whisperOptions.model ?? this.defaultModel,
|
|
213
334
|
},
|
|
214
335
|
};
|
|
215
336
|
}
|
|
@@ -223,8 +344,9 @@ export class OpenAISTT {
|
|
|
223
344
|
metadata: {
|
|
224
345
|
latency,
|
|
225
346
|
provider: "whisper",
|
|
226
|
-
model: whisperOptions.model ??
|
|
347
|
+
model: whisperOptions.model ?? this.defaultModel,
|
|
227
348
|
task: data.task,
|
|
349
|
+
...OpenAISTT.readLanguageExtensions(data),
|
|
228
350
|
},
|
|
229
351
|
};
|
|
230
352
|
// Add word timings if available
|
|
@@ -235,17 +357,26 @@ export class OpenAISTT {
|
|
|
235
357
|
endTime: word.end,
|
|
236
358
|
}));
|
|
237
359
|
}
|
|
238
|
-
// Add segments
|
|
360
|
+
// Add segments. OpenAI-compatible servers may omit the decoder stats
|
|
361
|
+
// and may label each segment with a speaker; both are passed through.
|
|
239
362
|
if (data.segments && data.segments.length > 0) {
|
|
240
363
|
result.segments = data.segments.map((segment, index) => ({
|
|
241
364
|
index,
|
|
242
365
|
text: segment.text,
|
|
243
366
|
isFinal: true,
|
|
244
|
-
|
|
367
|
+
...(typeof segment.avg_logprob === "number"
|
|
368
|
+
? { confidence: Math.exp(segment.avg_logprob) } // log prob -> confidence
|
|
369
|
+
: {}),
|
|
245
370
|
startTime: segment.start,
|
|
246
371
|
endTime: segment.end,
|
|
372
|
+
...(segment.speaker !== undefined
|
|
373
|
+
? { speaker: segment.speaker }
|
|
374
|
+
: {}),
|
|
247
375
|
}));
|
|
248
376
|
}
|
|
377
|
+
if (data.speakers && data.speakers.length > 0) {
|
|
378
|
+
result.speakers = data.speakers;
|
|
379
|
+
}
|
|
249
380
|
logger.info(`[WhisperSTTHandler] Transcribed ${data.duration?.toFixed(1) ?? "?"}s audio in ${latency}ms`);
|
|
250
381
|
return result;
|
|
251
382
|
}
|
|
@@ -258,6 +389,50 @@ export class OpenAISTT {
|
|
|
258
389
|
throw STTError.transcriptionFailed(errorMessage, "whisper", err instanceof Error ? err : undefined);
|
|
259
390
|
}
|
|
260
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* The context prompt: the caller's own text, then the vocabulary as one
|
|
394
|
+
* sentence. Whisper-style decoders treat the prompt as preceding text, so a
|
|
395
|
+
* plain list of names biases spelling without being read as an instruction.
|
|
396
|
+
*/
|
|
397
|
+
static buildPrompt(prompt, vocabulary) {
|
|
398
|
+
const terms = (vocabulary ?? [])
|
|
399
|
+
.map((term) => term.trim())
|
|
400
|
+
.filter((term) => term.length > 0);
|
|
401
|
+
const parts = [
|
|
402
|
+
prompt?.trim(),
|
|
403
|
+
terms.length > 0 ? `Names and terms used: ${terms.join(", ")}.` : "",
|
|
404
|
+
].filter((part) => !!part);
|
|
405
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Optional language-identification fields a self-hosted server may add to
|
|
409
|
+
* `verbose_json`. Each is read only when it has the expected shape; a
|
|
410
|
+
* server that sends none (OpenAI itself) contributes nothing.
|
|
411
|
+
*/
|
|
412
|
+
static readLanguageExtensions(data) {
|
|
413
|
+
const out = {};
|
|
414
|
+
if (typeof data.language_detected === "boolean") {
|
|
415
|
+
out.languageDetected = data.language_detected;
|
|
416
|
+
}
|
|
417
|
+
if (typeof data.language_confidence === "number" &&
|
|
418
|
+
Number.isFinite(data.language_confidence)) {
|
|
419
|
+
out.languageConfidence = data.language_confidence;
|
|
420
|
+
}
|
|
421
|
+
if (Array.isArray(data.language_scores)) {
|
|
422
|
+
const scores = data.language_scores.filter((entry) => typeof entry === "object" &&
|
|
423
|
+
entry !== null &&
|
|
424
|
+
typeof entry.language === "string" &&
|
|
425
|
+
typeof entry.score === "number" &&
|
|
426
|
+
Number.isFinite(entry.score));
|
|
427
|
+
if (scores.length > 0) {
|
|
428
|
+
out.languageScores = scores.map(({ language, score }) => ({
|
|
429
|
+
language,
|
|
430
|
+
score,
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
261
436
|
/**
|
|
262
437
|
* Get MIME type for audio format. Whisper auto-detects from headers, but
|
|
263
438
|
* sending a correct MIME helps providers / proxies that sniff Content-Type.
|