@juspay/neurolink 12.48.2 → 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 (104) 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 +0 -3
  4. package/dist/cli/commands/serve.js +85 -3
  5. package/dist/cli/commands/transcribe.d.ts +24 -0
  6. package/dist/cli/commands/transcribe.js +718 -0
  7. package/dist/cli/factories/commandFactory.d.ts +10 -0
  8. package/dist/cli/factories/commandFactory.js +13 -0
  9. package/dist/cli/parser.js +3 -0
  10. package/dist/factories/mediaHandlerCatalog.js +11 -9
  11. package/dist/factories/sttDescriptors.d.ts +63 -0
  12. package/dist/factories/sttDescriptors.js +286 -0
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.js +7 -1
  15. package/dist/neurolink.d.ts +68 -1
  16. package/dist/neurolink.js +202 -4
  17. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  18. package/dist/processors/media/AudioProcessor.js +105 -276
  19. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  20. package/dist/processors/media/VideoProcessor.js +52 -51
  21. package/dist/server/abstract/baseServerAdapter.d.ts +7 -0
  22. package/dist/server/abstract/baseServerAdapter.js +13 -1
  23. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  24. package/dist/server/adapters/expressAdapter.js +48 -4
  25. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  26. package/dist/server/adapters/fastifyAdapter.js +38 -1
  27. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  28. package/dist/server/adapters/honoAdapter.js +83 -1
  29. package/dist/server/adapters/koaAdapter.d.ts +1 -0
  30. package/dist/server/adapters/koaAdapter.js +43 -0
  31. package/dist/server/index.d.ts +3 -2
  32. package/dist/server/index.js +3 -2
  33. package/dist/server/routes/index.d.ts +1 -0
  34. package/dist/server/routes/index.js +3 -0
  35. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  36. package/dist/server/routes/transcribeRoutes.js +506 -0
  37. package/dist/server/utils/rawBody.d.ts +32 -0
  38. package/dist/server/utils/rawBody.js +76 -0
  39. package/dist/server/utils/validation.d.ts +192 -0
  40. package/dist/server/utils/validation.js +100 -0
  41. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  42. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  43. package/dist/types/cli.d.ts +46 -0
  44. package/dist/types/index.d.ts +1 -0
  45. package/dist/types/index.js +1 -0
  46. package/dist/types/providers.d.ts +7 -1
  47. package/dist/types/server.d.ts +117 -1
  48. package/dist/types/stt.d.ts +160 -7
  49. package/dist/types/transcribe.d.ts +506 -0
  50. package/dist/types/transcribe.js +29 -0
  51. package/dist/utils/speakerTurns.d.ts +28 -0
  52. package/dist/utils/speakerTurns.js +93 -0
  53. package/dist/utils/sttProcessor.d.ts +20 -2
  54. package/dist/utils/sttProcessor.js +73 -5
  55. package/dist/voice/correction/dictionary.d.ts +49 -0
  56. package/dist/voice/correction/dictionary.js +134 -0
  57. package/dist/voice/correction/guard.d.ts +35 -0
  58. package/dist/voice/correction/guard.js +161 -0
  59. package/dist/voice/correction/index.d.ts +47 -0
  60. package/dist/voice/correction/index.js +211 -0
  61. package/dist/voice/correction/rewrite.d.ts +92 -0
  62. package/dist/voice/correction/rewrite.js +322 -0
  63. package/dist/voice/errors.d.ts +15 -0
  64. package/dist/voice/errors.js +45 -0
  65. package/dist/voice/index.d.ts +20 -2
  66. package/dist/voice/index.js +75 -9
  67. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  68. package/dist/voice/providers/AzureSTT.js +23 -75
  69. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  70. package/dist/voice/providers/DeepgramSTT.js +109 -13
  71. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  72. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  73. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  74. package/dist/voice/providers/GoogleSTT.js +21 -75
  75. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  76. package/dist/voice/providers/OpenAISTT.js +196 -21
  77. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  78. package/dist/voice/providers/WhistleSTT.js +313 -0
  79. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  80. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  81. package/dist/voice/streaming/endpointer.d.ts +81 -0
  82. package/dist/voice/streaming/endpointer.js +247 -0
  83. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  84. package/dist/voice/streaming/localAgreement.js +44 -0
  85. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  86. package/dist/voice/sttEnvCredentials.js +12 -0
  87. package/dist/voice/transcribe.d.ts +47 -0
  88. package/dist/voice/transcribe.js +519 -0
  89. package/dist/voice/transcribeStream.d.ts +4 -0
  90. package/dist/voice/transcribeStream.js +584 -0
  91. package/dist/voice/whistle/assets.d.ts +47 -0
  92. package/dist/voice/whistle/assets.js +270 -0
  93. package/dist/voice/whistle/audio.d.ts +32 -0
  94. package/dist/voice/whistle/audio.js +267 -0
  95. package/dist/voice/whistle/engine.d.ts +32 -0
  96. package/dist/voice/whistle/engine.js +193 -0
  97. package/dist/voice/whistle/worker.d.ts +18 -0
  98. package/dist/voice/whistle/worker.js +294 -0
  99. package/docs-site/static/search-index.json +66 -8
  100. package/models/whistle/README.md +18 -0
  101. package/models/whistle/needle.js +2 -0
  102. package/models/whistle/needle.wasm +0 -0
  103. package/models/whistle/whistle.cact +0 -0
  104. 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
- constructor(apiKey?: string);
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
- constructor(apiKey) {
35
- const resolvedKey = (apiKey ?? process.env.ELEVENLABS_API_KEY ?? "").trim();
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(process.env.ELEVENLABS_BASE_URL ?? DEFAULT_BASE_URL);
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 = typeof elevenOptions.timeoutMs === "number" &&
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
- : DEFAULT_TIMEOUT_MS;
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, TranscriptionSegment } from "../../types/index.js";
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 yet implemented).
27
- * transcribeStream() uses a chunk-and-batch workaround.
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
- constructor(apiKey?: string, credentialsPath?: string);
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 yet implemented).
28
- * transcribeStream() uses a chunk-and-batch workaround.
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
- constructor(apiKey, credentialsPath) {
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
- languageCode: options.language ?? "en-US",
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
- constructor(apiKey?: string);
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 = "https://api.openai.com/v1";
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
- constructor(apiKey) {
29
- const resolvedKey = (apiKey ?? process.env.OPENAI_API_KEY ?? "").trim();
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.apiKey) {
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 ?? "whisper-1");
150
- // Add optional parameters
151
- if (options.language) {
152
- formData.append("language", options.language);
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
- if (whisperOptions.prompt) {
155
- formData.append("prompt", whisperOptions.prompt);
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(), 30000);
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("OpenAI STT request timed out after 30 seconds", "whisper", fetchErr);
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 ?? "whisper-1",
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 ?? "whisper-1",
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
- confidence: Math.exp(segment.avg_logprob), // Convert log prob to confidence
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.