@juspay/neurolink 12.48.2 → 12.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +2 -2
  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/providerDescriptors.js +5 -4
  12. package/dist/factories/sttDescriptors.d.ts +63 -0
  13. package/dist/factories/sttDescriptors.js +286 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +7 -1
  16. package/dist/neurolink.d.ts +68 -1
  17. package/dist/neurolink.js +202 -4
  18. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  19. package/dist/processors/media/AudioProcessor.js +105 -276
  20. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  21. package/dist/processors/media/VideoProcessor.js +52 -51
  22. package/dist/providers/cloudflareClef.js +9 -1
  23. package/dist/server/abstract/baseServerAdapter.d.ts +7 -0
  24. package/dist/server/abstract/baseServerAdapter.js +13 -1
  25. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  26. package/dist/server/adapters/expressAdapter.js +48 -4
  27. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  28. package/dist/server/adapters/fastifyAdapter.js +38 -1
  29. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  30. package/dist/server/adapters/honoAdapter.js +83 -1
  31. package/dist/server/adapters/koaAdapter.d.ts +1 -0
  32. package/dist/server/adapters/koaAdapter.js +43 -0
  33. package/dist/server/index.d.ts +3 -2
  34. package/dist/server/index.js +3 -2
  35. package/dist/server/routes/index.d.ts +1 -0
  36. package/dist/server/routes/index.js +3 -0
  37. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  38. package/dist/server/routes/transcribeRoutes.js +506 -0
  39. package/dist/server/utils/rawBody.d.ts +32 -0
  40. package/dist/server/utils/rawBody.js +76 -0
  41. package/dist/server/utils/validation.d.ts +192 -0
  42. package/dist/server/utils/validation.js +100 -0
  43. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  44. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  45. package/dist/types/cli.d.ts +46 -0
  46. package/dist/types/index.d.ts +1 -0
  47. package/dist/types/index.js +1 -0
  48. package/dist/types/providers.d.ts +7 -1
  49. package/dist/types/server.d.ts +117 -1
  50. package/dist/types/stt.d.ts +160 -7
  51. package/dist/types/transcribe.d.ts +506 -0
  52. package/dist/types/transcribe.js +29 -0
  53. package/dist/utils/speakerTurns.d.ts +28 -0
  54. package/dist/utils/speakerTurns.js +93 -0
  55. package/dist/utils/sttProcessor.d.ts +20 -2
  56. package/dist/utils/sttProcessor.js +73 -5
  57. package/dist/voice/correction/dictionary.d.ts +49 -0
  58. package/dist/voice/correction/dictionary.js +134 -0
  59. package/dist/voice/correction/guard.d.ts +35 -0
  60. package/dist/voice/correction/guard.js +161 -0
  61. package/dist/voice/correction/index.d.ts +47 -0
  62. package/dist/voice/correction/index.js +211 -0
  63. package/dist/voice/correction/rewrite.d.ts +92 -0
  64. package/dist/voice/correction/rewrite.js +322 -0
  65. package/dist/voice/errors.d.ts +15 -0
  66. package/dist/voice/errors.js +45 -0
  67. package/dist/voice/index.d.ts +20 -2
  68. package/dist/voice/index.js +75 -9
  69. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  70. package/dist/voice/providers/AzureSTT.js +23 -75
  71. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  72. package/dist/voice/providers/DeepgramSTT.js +109 -13
  73. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  74. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  75. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  76. package/dist/voice/providers/GoogleSTT.js +21 -75
  77. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  78. package/dist/voice/providers/OpenAISTT.js +196 -21
  79. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  80. package/dist/voice/providers/WhistleSTT.js +313 -0
  81. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  82. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  83. package/dist/voice/streaming/endpointer.d.ts +81 -0
  84. package/dist/voice/streaming/endpointer.js +247 -0
  85. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  86. package/dist/voice/streaming/localAgreement.js +44 -0
  87. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  88. package/dist/voice/sttEnvCredentials.js +12 -0
  89. package/dist/voice/transcribe.d.ts +47 -0
  90. package/dist/voice/transcribe.js +519 -0
  91. package/dist/voice/transcribeStream.d.ts +4 -0
  92. package/dist/voice/transcribeStream.js +584 -0
  93. package/dist/voice/whistle/assets.d.ts +47 -0
  94. package/dist/voice/whistle/assets.js +270 -0
  95. package/dist/voice/whistle/audio.d.ts +32 -0
  96. package/dist/voice/whistle/audio.js +267 -0
  97. package/dist/voice/whistle/engine.d.ts +32 -0
  98. package/dist/voice/whistle/engine.js +193 -0
  99. package/dist/voice/whistle/worker.d.ts +18 -0
  100. package/dist/voice/whistle/worker.js +294 -0
  101. package/docs-site/static/search-index.json +71 -12
  102. package/models/whistle/README.md +18 -0
  103. package/models/whistle/needle.js +2 -0
  104. package/models/whistle/needle.wasm +0 -0
  105. package/models/whistle/whistle.cact +0 -0
  106. package/package.json +7 -3
@@ -171,6 +171,51 @@ export class STTError extends VoiceError {
171
171
  context: { provider, availableProviders },
172
172
  });
173
173
  }
174
+ /**
175
+ * Create an error for a provider name that is neither a shipped STT
176
+ * provider nor a registered handler. Lists what is configured and what
177
+ * would configure the rest, so the caller can act on the message alone.
178
+ */
179
+ static unknownProvider(provider, configuredProviders, howToConfigure) {
180
+ const configured = configuredProviders.length > 0
181
+ ? `Configured: ${configuredProviders.join(", ")}.`
182
+ : "No STT provider is configured.";
183
+ return new STTError({
184
+ code: STT_ERROR_CODES.PROVIDER_NOT_SUPPORTED,
185
+ message: `STT provider "${provider}" is not supported. ${configured}` +
186
+ (howToConfigure ? ` To configure another, set ${howToConfigure}.` : ""),
187
+ category: ErrorCategory.VALIDATION,
188
+ severity: ErrorSeverity.HIGH,
189
+ retriable: false,
190
+ context: { provider, configuredProviders },
191
+ });
192
+ }
193
+ /**
194
+ * Create an error for audio that could not be read (a missing file, a URL
195
+ * that failed, an input of the wrong kind).
196
+ */
197
+ static audioLoadFailed(reason, originalError) {
198
+ return new STTError({
199
+ code: STT_ERROR_CODES.INVALID_AUDIO_FORMAT,
200
+ message: `Could not load audio: ${reason}`,
201
+ category: ErrorCategory.VALIDATION,
202
+ severity: ErrorSeverity.MEDIUM,
203
+ retriable: false,
204
+ originalError,
205
+ });
206
+ }
207
+ /**
208
+ * Create an error for a stream of frames handed to a batch entry point.
209
+ */
210
+ static streamInputNotSupported() {
211
+ return new STTError({
212
+ code: STT_ERROR_CODES.STREAMING_NOT_SUPPORTED,
213
+ message: "transcribe() takes a whole recording (bytes, a path or a URL); for an async stream of audio frames use transcribeStream()",
214
+ category: ErrorCategory.VALIDATION,
215
+ severity: ErrorSeverity.MEDIUM,
216
+ retriable: false,
217
+ });
218
+ }
174
219
  /**
175
220
  * Create an error for stream processing failure
176
221
  */
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * @module voice
20
20
  */
21
+ import type { STTCredentials, STTHandler } from "../types/index.js";
21
22
  export { AUDIO_FORMAT_DETAILS, DEFAULT_REALTIME_CONFIG, DEFAULT_STT_OPTIONS, isSTTResult, isTranscriptionSegment, isValidRealtimeConfig, isValidSTTOptions, REALTIME_ERROR_CODES, STT_ERROR_CODES, VOICE_ERROR_CODES, } from "../types/index.js";
22
23
  export { RealtimeError, STTError, VoiceError } from "./errors.js";
23
24
  export { BaseRealtimeHandler, RealtimeProcessor } from "./RealtimeVoiceAPI.js";
@@ -35,16 +36,33 @@ export { DeepgramSTT, DeepgramSTT as DeepgramSTTHandler, } from "./providers/Dee
35
36
  export { ElevenLabsSTT, ElevenLabsSTT as ElevenLabsSTTHandler, } from "./providers/ElevenLabsSTT.js";
36
37
  export { GoogleSTT, GoogleSTT as GoogleSTTHandler, } from "./providers/GoogleSTT.js";
37
38
  export { OpenAISTT, OpenAISTTHandler, WhisperSTT, WhisperSTTHandler, } from "./providers/OpenAISTT.js";
39
+ export { WhistleSTT, WhistleSTT as WhistleSTTHandler, } from "./providers/WhistleSTT.js";
38
40
  export { GeminiLive, GeminiLive as GeminiLiveHandler, } from "./providers/GeminiLive.js";
39
41
  export { OpenAIRealtime, OpenAIRealtime as OpenAIRealtimeHandler, } from "./providers/OpenAIRealtime.js";
42
+ /**
43
+ * A fresh STT handler for a shipped provider name or alias, built from
44
+ * `credentials` with the environment as fallback. Used for per-call
45
+ * credentials, where the registry's shared instance (built from the
46
+ * environment at registration time) is the wrong one.
47
+ *
48
+ * @throws STTError when the name is not a shipped STT provider
49
+ */
50
+ export declare function createSTTHandler(provider: string, credentials?: STTCredentials): STTHandler;
40
51
  /**
41
52
  * Register every shipped TTS handler whose backing credentials are
42
53
  * present in the environment. Safe to call multiple times.
43
54
  */
44
55
  export declare function registerDefaultTTSHandlers(): void;
45
56
  /**
46
- * Register every shipped STT handler whose backing credentials are
47
- * present in the environment. Safe to call multiple times.
57
+ * Register every shipped STT handler whose backing credentials are present in
58
+ * the ENVIRONMENT, plus the built-in local engine (Whistle), which is always
59
+ * registered because it is what a transcription with nothing configured falls
60
+ * back to, plus the env-declared named endpoints (`NEUROLINK_STT_ENDPOINTS`).
61
+ * Safe to call multiple times; a name that is already registered is left
62
+ * alone. Deliberately takes no credentials: the registry is process-wide, so
63
+ * a handler built from one caller's keys or base URL would be served to every
64
+ * later caller — per-call and per-instance credentials are resolved at call
65
+ * time by `STTProcessor.resolveHandler` / `createSTTHandler` instead.
48
66
  */
49
67
  export declare function registerDefaultSTTHandlers(): void;
50
68
  /**
@@ -19,6 +19,7 @@
19
19
  * @module voice
20
20
  */
21
21
  import { MEDIA_HANDLER_CATALOG } from "../factories/mediaHandlerCatalog.js";
22
+ import { resolveCallSTTEndpoints, resolveSTTEndpoints, resolveSTTProviderName, } from "../factories/sttDescriptors.js";
22
23
  import { logger } from "../utils/logger.js";
23
24
  import { STTProcessor } from "../utils/sttProcessor.js";
24
25
  import { TTSProcessor } from "../utils/ttsProcessor.js";
@@ -34,6 +35,7 @@ isSTTResult, isTranscriptionSegment, isValidRealtimeConfig, isValidSTTOptions, R
34
35
  // ERRORS
35
36
  // ============================================================================
36
37
  export { RealtimeError, STTError, VoiceError } from "./errors.js";
38
+ import { STTError } from "./errors.js";
37
39
  // ============================================================================
38
40
  // REALTIME VOICE API
39
41
  // ============================================================================
@@ -64,6 +66,7 @@ export { DeepgramSTT, DeepgramSTT as DeepgramSTTHandler, } from "./providers/Dee
64
66
  export { ElevenLabsSTT, ElevenLabsSTT as ElevenLabsSTTHandler, } from "./providers/ElevenLabsSTT.js";
65
67
  export { GoogleSTT, GoogleSTT as GoogleSTTHandler, } from "./providers/GoogleSTT.js";
66
68
  export { OpenAISTT, OpenAISTTHandler, WhisperSTT, WhisperSTTHandler, } from "./providers/OpenAISTT.js";
69
+ export { WhistleSTT, WhistleSTT as WhistleSTTHandler, } from "./providers/WhistleSTT.js";
67
70
  // ============================================================================
68
71
  // REALTIME PROVIDERS
69
72
  // ============================================================================
@@ -83,6 +86,7 @@ import { DeepgramSTT } from "./providers/DeepgramSTT.js";
83
86
  import { ElevenLabsSTT } from "./providers/ElevenLabsSTT.js";
84
87
  import { GoogleSTT } from "./providers/GoogleSTT.js";
85
88
  import { OpenAISTT } from "./providers/OpenAISTT.js";
89
+ import { WhistleSTT } from "./providers/WhistleSTT.js";
86
90
  import { GeminiLive } from "./providers/GeminiLive.js";
87
91
  import { OpenAIRealtime } from "./providers/OpenAIRealtime.js";
88
92
  // Provider names + aliases are the Task-8 catalog's job — only the factory
@@ -104,13 +108,21 @@ const TTS_HANDLER_CANDIDATES = MEDIA_HANDLER_CATALOG.filter((entry) => entry.kin
104
108
  }
105
109
  return { name: entry.name, aliases: entry.aliases, factory };
106
110
  });
111
+ /**
112
+ * One factory per STT descriptor name. Each takes the request's (or
113
+ * instance's) `credentials.stt` and hands its own slice to the handler; the
114
+ * handler fills whatever the slice leaves out from the environment.
115
+ */
107
116
  const STT_HANDLER_FACTORIES = {
108
- whisper: () => new OpenAISTT(),
109
- deepgram: () => new DeepgramSTT(),
110
- "google-stt": () => new GoogleSTT(),
111
- "azure-stt": () => new AzureSTT(),
112
- "elevenlabs-stt": () => new ElevenLabsSTT(),
117
+ whisper: (credentials) => new OpenAISTT(credentials?.whisper),
118
+ deepgram: (credentials) => new DeepgramSTT(credentials?.deepgram),
119
+ "elevenlabs-stt": (credentials) => new ElevenLabsSTT(credentials?.elevenlabs),
120
+ "google-stt": (credentials) => new GoogleSTT(credentials?.google),
121
+ "azure-stt": (credentials) => new AzureSTT(credentials?.azure),
122
+ whistle: (credentials) => new WhistleSTT(credentials?.whistle),
113
123
  };
124
+ /** Registered even when `isConfigured()` is false: it is the no-config default. */
125
+ const ALWAYS_REGISTERED_STT = new Set(["whistle"]);
114
126
  const STT_HANDLER_CANDIDATES = MEDIA_HANDLER_CATALOG.filter((entry) => entry.kind === "stt").map((entry) => {
115
127
  const factory = STT_HANDLER_FACTORIES[entry.name];
116
128
  if (!factory) {
@@ -118,6 +130,28 @@ const STT_HANDLER_CANDIDATES = MEDIA_HANDLER_CATALOG.filter((entry) => entry.kin
118
130
  }
119
131
  return { name: entry.name, aliases: entry.aliases, factory };
120
132
  });
133
+ /**
134
+ * A fresh STT handler for a shipped provider name or alias, built from
135
+ * `credentials` with the environment as fallback. Used for per-call
136
+ * credentials, where the registry's shared instance (built from the
137
+ * environment at registration time) is the wrong one.
138
+ *
139
+ * @throws STTError when the name is not a shipped STT provider
140
+ */
141
+ export function createSTTHandler(provider, credentials) {
142
+ // A named endpoint in the caller's credentials wins over everything and
143
+ // never touches the shared registry.
144
+ const endpoint = resolveCallSTTEndpoints(credentials)[provider.trim().toLowerCase()];
145
+ if (endpoint) {
146
+ return endpointHandler(endpoint);
147
+ }
148
+ const canonical = resolveSTTProviderName(provider);
149
+ const factory = canonical ? STT_HANDLER_FACTORIES[canonical] : undefined;
150
+ if (!factory) {
151
+ throw STTError.providerNotSupported(provider, Object.keys(STT_HANDLER_FACTORIES));
152
+ }
153
+ return factory(credentials);
154
+ }
121
155
  const REALTIME_HANDLER_FACTORIES = {
122
156
  "openai-realtime": () => new OpenAIRealtime(),
123
157
  "gemini-live": () => new GeminiLive(),
@@ -152,7 +186,10 @@ function registerCandidates(candidates, supports, getRegistered, register, scope
152
186
  }
153
187
  if (!handler) {
154
188
  handler = factory();
155
- if (requireConfigured && !handler.isConfigured()) {
189
+ const mustBeConfigured = typeof requireConfigured === "function"
190
+ ? requireConfigured(name)
191
+ : requireConfigured;
192
+ if (mustBeConfigured && !handler.isConfigured()) {
156
193
  continue;
157
194
  }
158
195
  }
@@ -176,11 +213,40 @@ export function registerDefaultTTSHandlers() {
176
213
  registerCandidates(TTS_HANDLER_CANDIDATES, (name) => TTSProcessor.supports(name), (name) => TTSProcessor.getHandler(name), (name, handler) => TTSProcessor.registerHandler(name, handler), "voice/tts", true);
177
214
  }
178
215
  /**
179
- * Register every shipped STT handler whose backing credentials are
180
- * present in the environment. Safe to call multiple times.
216
+ * Register every shipped STT handler whose backing credentials are present in
217
+ * the ENVIRONMENT, plus the built-in local engine (Whistle), which is always
218
+ * registered because it is what a transcription with nothing configured falls
219
+ * back to, plus the env-declared named endpoints (`NEUROLINK_STT_ENDPOINTS`).
220
+ * Safe to call multiple times; a name that is already registered is left
221
+ * alone. Deliberately takes no credentials: the registry is process-wide, so
222
+ * a handler built from one caller's keys or base URL would be served to every
223
+ * later caller — per-call and per-instance credentials are resolved at call
224
+ * time by `STTProcessor.resolveHandler` / `createSTTHandler` instead.
181
225
  */
182
226
  export function registerDefaultSTTHandlers() {
183
- registerCandidates(STT_HANDLER_CANDIDATES, (name) => STTProcessor.supports(name), (name) => STTProcessor.getHandler(name), (name, handler) => STTProcessor.registerHandler(name, handler), "voice/stt", true);
227
+ registerCandidates(STT_HANDLER_CANDIDATES.map((candidate) => ({
228
+ name: candidate.name,
229
+ aliases: candidate.aliases,
230
+ factory: () => candidate.factory(),
231
+ })), (name) => STTProcessor.supports(name), (name) => STTProcessor.getHandler(name), (name, handler) => STTProcessor.registerHandler(name, handler), "voice/stt", (name) => !ALWAYS_REGISTERED_STT.has(name));
232
+ // Named OpenAI-compatible endpoints become providers of their own, so two
233
+ // self-hosted engines can be told apart and used as each other's fallback.
234
+ for (const [name, endpoint] of Object.entries(resolveSTTEndpoints())) {
235
+ if (!STTProcessor.supports(name)) {
236
+ STTProcessor.registerHandler(name, endpointHandler(endpoint));
237
+ }
238
+ }
239
+ }
240
+ /** The OpenAI-compatible handler for one named endpoint. */
241
+ function endpointHandler(endpoint) {
242
+ // A named endpoint sends its own key or none: it never borrows the
243
+ // `whisper` provider's env keys, which belong to a different host.
244
+ return new OpenAISTT({
245
+ apiKey: endpoint.apiKey ?? "",
246
+ baseURL: endpoint.baseURL,
247
+ timeoutMs: endpoint.timeoutMs,
248
+ model: endpoint.model,
249
+ });
184
250
  }
185
251
  /**
186
252
  * Register every shipped Realtime handler. Realtime handlers don't gate
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module voice/providers/AzureSTT
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
  * Azure Cognitive Services Speech-to-Text Handler
11
11
  *
@@ -24,18 +24,21 @@ export declare class AzureSTT implements STTHandler {
24
24
  */
25
25
  readonly maxAudioDuration = 60;
26
26
  /**
27
- * Azure STT implementation buffers chunks via REST — not true streaming
27
+ * REST short-audio recognition has no stream. `transcribeStream()` on the
28
+ * SDK streams this handler through the generic chunked adapter.
28
29
  */
29
30
  readonly supportsStreaming = false;
30
- constructor(apiKey?: string, region?: string);
31
+ /**
32
+ * @param apiKeyOrCredentials - The `credentials.stt.azure` slice (`apiKey`,
33
+ * `region`), or an API key (the original positional form).
34
+ * `AZURE_SPEECH_KEY` / `AZURE_SPEECH_REGION` fill whatever is left out.
35
+ * @param region - Region, positional form only.
36
+ */
37
+ constructor(apiKeyOrCredentials?: string | STTCredentials["azure"], region?: string);
31
38
  isConfigured(): boolean;
32
39
  getSupportedFormats(): TTSAudioFormat[];
33
40
  getSupportedLanguages(): Promise<STTLanguage[]>;
34
41
  transcribe(audio: Buffer | ArrayBuffer, options?: STTOptions): Promise<STTResult>;
35
- /**
36
- * Streaming transcription (placeholder - requires SDK)
37
- */
38
- transcribeStream(audioStream: AsyncIterable<Buffer>, options: STTOptions): AsyncIterable<TranscriptionSegment>;
39
42
  /**
40
43
  * Get Content-Type header for audio format
41
44
  */
@@ -25,13 +25,28 @@ export class AzureSTT {
25
25
  */
26
26
  maxAudioDuration = 60;
27
27
  /**
28
- * Azure STT implementation buffers chunks via REST — not true streaming
28
+ * REST short-audio recognition has no stream. `transcribeStream()` on the
29
+ * SDK streams this handler through the generic chunked adapter.
29
30
  */
30
31
  supportsStreaming = false;
31
- constructor(apiKey, region) {
32
- const resolvedKey = (apiKey ?? process.env.AZURE_SPEECH_KEY ?? "").trim();
32
+ /**
33
+ * @param apiKeyOrCredentials - The `credentials.stt.azure` slice (`apiKey`,
34
+ * `region`), or an API key (the original positional form).
35
+ * `AZURE_SPEECH_KEY` / `AZURE_SPEECH_REGION` fill whatever is left out.
36
+ * @param region - Region, positional form only.
37
+ */
38
+ constructor(apiKeyOrCredentials, region) {
39
+ // `typeof x === "string"` narrows the same way with and without
40
+ // strictNullChecks (the react-hooks build runs tsc without it); an
41
+ // `=== undefined` test does not.
42
+ const slice = typeof apiKeyOrCredentials === "string"
43
+ ? { apiKey: apiKeyOrCredentials, region }
44
+ : { region, ...(apiKeyOrCredentials ?? {}) };
45
+ const resolvedKey = (slice.apiKey ??
46
+ process.env.AZURE_SPEECH_KEY ??
47
+ "").trim();
33
48
  this.apiKey = resolvedKey.length > 0 ? resolvedKey : null;
34
- const resolvedRegion = (region ??
49
+ const resolvedRegion = (slice.region ??
35
50
  process.env.AZURE_SPEECH_REGION ??
36
51
  "").trim();
37
52
  this.region = resolvedRegion.length > 0 ? resolvedRegion : "eastus";
@@ -148,7 +163,10 @@ export class AzureSTT {
148
163
  try {
149
164
  // Build the URL with query parameters
150
165
  const params = new URLSearchParams();
151
- params.set("language", options.language ?? "en-US");
166
+ // Azure needs a language; "auto" (no pin) falls back to the default.
167
+ params.set("language", options.language && options.language.toLowerCase() !== "auto"
168
+ ? options.language
169
+ : "en-US");
152
170
  // Add detailed output format
153
171
  if (azureOptions.detailed || options.wordTimestamps) {
154
172
  params.set("format", "detailed");
@@ -251,76 +269,6 @@ export class AzureSTT {
251
269
  throw STTError.transcriptionFailed(errorMessage, "azure-stt", err instanceof Error ? err : undefined);
252
270
  }
253
271
  }
254
- /**
255
- * Streaming transcription (placeholder - requires SDK)
256
- */
257
- async *transcribeStream(audioStream, options) {
258
- // Azure streaming requires the Microsoft Speech SDK
259
- // For now, buffer and transcribe in chunks
260
- const chunks = [];
261
- let chunkIndex = 0;
262
- // Track buffered byte count incrementally — `chunks.reduce()` per incoming
263
- // chunk is O(n²) over long streams (Copilot/CodeRabbit review). Reset to 0
264
- // every time we flush.
265
- let bufferedBytes = 0;
266
- for await (const chunk of audioStream) {
267
- chunks.push(chunk);
268
- bufferedBytes += chunk.length;
269
- // Process every ~5 seconds of audio
270
- const bytesPerSecond = (options.sampleRate ?? 16000) * 2;
271
- if (bufferedBytes >= bytesPerSecond * 5) {
272
- const audio = Buffer.concat(chunks);
273
- chunks.length = 0;
274
- bufferedBytes = 0;
275
- try {
276
- const result = await this.transcribe(audio, options);
277
- yield {
278
- index: chunkIndex++,
279
- text: result.text,
280
- isFinal: false,
281
- confidence: result.confidence,
282
- };
283
- }
284
- catch (err) {
285
- // M5: distinguish permanent (auth, schema, 4xx) from transient
286
- // (5xx, 429, network) errors. Without this, an expired API key
287
- // would silently retry every chunk for the entire stream.
288
- const msg = err instanceof Error ? err.message : String(err);
289
- const isPermanent = /\b(401|403|404|Forbidden|Unauthorized|Invalid.*subscription|Invalid.*key|Wrong.*key|InvalidAudioFormat)\b/i.test(msg);
290
- if (isPermanent) {
291
- logger.error(`[AzureSTTHandler] Permanent chunk error — terminating stream: ${msg}`);
292
- throw err;
293
- }
294
- logger.warn(`[AzureSTTHandler] Transient chunk failure (skipping): ${msg}`);
295
- }
296
- }
297
- }
298
- // Process remaining audio
299
- if (chunks.length > 0) {
300
- const audio = Buffer.concat(chunks);
301
- try {
302
- const result = await this.transcribe(audio, options);
303
- yield {
304
- index: chunkIndex,
305
- text: result.text,
306
- isFinal: true,
307
- confidence: result.confidence,
308
- };
309
- }
310
- catch (err) {
311
- // Mirror the permanent-vs-transient split from the chunk loop above so
312
- // auth/format failures don't masquerade as a successful empty
313
- // transcription on short streams (≤5s buffer flush).
314
- const msg = err instanceof Error ? err.message : String(err);
315
- const isPermanent = /\b(401|403|404|Forbidden|Unauthorized|Invalid.*subscription|Invalid.*key|Wrong.*key|InvalidAudioFormat)\b/i.test(msg);
316
- if (isPermanent) {
317
- logger.error(`[AzureSTTHandler] Permanent final-chunk error — surfacing: ${msg}`);
318
- throw err;
319
- }
320
- logger.warn(`[AzureSTTHandler] Final chunk transcription failed (transient): ${msg}`);
321
- }
322
- }
323
- }
324
272
  /**
325
273
  * Get Content-Type header for audio format
326
274
  */
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module voice/providers/DeepgramSTT
7
7
  */
8
- import type { TTSAudioFormat, STTHandler, STTLanguage, STTOptions, STTResult, TranscriptionSegment } from "../../types/index.js";
8
+ import type { TTSAudioFormat, STTCredentials, STTHandler, STTLanguage, STTOptions, STTResult, TranscriptionSegment } from "../../types/index.js";
9
9
  /**
10
10
  * Deepgram Speech-to-Text Handler
11
11
  *
@@ -24,7 +24,16 @@ export declare class DeepgramSTT implements STTHandler {
24
24
  * Deepgram supports streaming
25
25
  */
26
26
  readonly supportsStreaming = true;
27
- constructor(apiKey?: string);
27
+ /**
28
+ * @param apiKeyOrCredentials - The `credentials.stt.deepgram` slice
29
+ * (`apiKey`, `baseURL`), or an API key (the original positional form).
30
+ * `DEEPGRAM_API_KEY` / `DEEPGRAM_BASE_URL` fill whatever is left out.
31
+ */
32
+ constructor(apiKeyOrCredentials?: string | STTCredentials["deepgram"]);
33
+ /** The streaming endpoint: the REST base URL on the WebSocket scheme. */
34
+ private get wsBaseUrl();
35
+ /** A language the caller pinned, or `undefined` for "let Deepgram detect". */
36
+ private static pinnedLanguage;
28
37
  isConfigured(): boolean;
29
38
  getSupportedFormats(): TTSAudioFormat[];
30
39
  getSupportedLanguages(): Promise<STTLanguage[]>;
@@ -33,6 +42,12 @@ export declare class DeepgramSTT implements STTHandler {
33
42
  * Streaming transcription using WebSocket
34
43
  */
35
44
  transcribeStream(audioStream: AsyncIterable<Buffer>, options: STTOptions): AsyncIterable<TranscriptionSegment>;
45
+ /**
46
+ * Timing, words and speaker of one live result, taken only from fields the
47
+ * payload actually carries. The segment's speaker is its words' speaker
48
+ * when they all agree.
49
+ */
50
+ private static streamSegmentDetails;
36
51
  /**
37
52
  * Get MIME type for audio format
38
53
  */
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { logger } from "../../utils/logger.js";
9
9
  import { STTError } from "../errors.js";
10
+ const DEFAULT_BASE_URL = "https://api.deepgram.com/v1";
11
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
12
  /**
11
13
  * Deepgram Speech-to-Text Handler
12
14
  *
@@ -16,7 +18,7 @@ import { STTError } from "../errors.js";
16
18
  */
17
19
  export class DeepgramSTT {
18
20
  apiKey;
19
- baseUrl = "https://api.deepgram.com/v1";
21
+ baseUrl;
20
22
  /**
21
23
  * Maximum audio duration in seconds (2 hours)
22
24
  */
@@ -25,12 +27,45 @@ export class DeepgramSTT {
25
27
  * Deepgram supports streaming
26
28
  */
27
29
  supportsStreaming = true;
28
- constructor(apiKey) {
30
+ /**
31
+ * @param apiKeyOrCredentials - The `credentials.stt.deepgram` slice
32
+ * (`apiKey`, `baseURL`), or an API key (the original positional form).
33
+ * `DEEPGRAM_API_KEY` / `DEEPGRAM_BASE_URL` fill whatever is left out.
34
+ */
35
+ constructor(apiKeyOrCredentials) {
36
+ // `typeof x === "string"` narrows the same way with and without
37
+ // strictNullChecks (the react-hooks build runs tsc without it); an
38
+ // `=== undefined` test does not.
39
+ const slice = typeof apiKeyOrCredentials === "string"
40
+ ? { apiKey: apiKeyOrCredentials }
41
+ : (apiKeyOrCredentials ?? {});
29
42
  // Normalize: trim surrounding whitespace and treat empty string as null
30
43
  // so isConfigured() and transcribe()/transcribeStream() agree on the
31
44
  // contract (other voice providers all do this — Deepgram was missed).
32
- const resolvedKey = (apiKey ?? process.env.DEEPGRAM_API_KEY ?? "").trim();
45
+ const resolvedKey = (slice.apiKey ??
46
+ process.env.DEEPGRAM_API_KEY ??
47
+ "").trim();
33
48
  this.apiKey = resolvedKey.length > 0 ? resolvedKey : null;
49
+ const baseURL = ("baseURL" in slice ? slice.baseURL : undefined) ??
50
+ process.env.DEEPGRAM_BASE_URL ??
51
+ DEFAULT_BASE_URL;
52
+ // Linear trailing-slash strip; a `/\/+$/` regex is quadratic on a long
53
+ // run of slashes in a caller-supplied URL (CodeQL js/polynomial-redos).
54
+ let end = baseURL.trim().length;
55
+ const trimmed = baseURL.trim();
56
+ while (end > 0 && trimmed.charCodeAt(end - 1) === 47) {
57
+ end--;
58
+ }
59
+ this.baseUrl = trimmed.slice(0, end) || DEFAULT_BASE_URL;
60
+ }
61
+ /** The streaming endpoint: the REST base URL on the WebSocket scheme. */
62
+ get wsBaseUrl() {
63
+ return this.baseUrl.replace(/^http(s?):\/\//i, (_m, secure) => secure ? "wss://" : "ws://");
64
+ }
65
+ /** A language the caller pinned, or `undefined` for "let Deepgram detect". */
66
+ static pinnedLanguage(language) {
67
+ const trimmed = language?.trim();
68
+ return trimmed && trimmed.toLowerCase() !== "auto" ? trimmed : undefined;
34
69
  }
35
70
  isConfigured() {
36
71
  return this.apiKey !== null;
@@ -142,9 +177,13 @@ export class DeepgramSTT {
142
177
  const params = new URLSearchParams();
143
178
  // Add model
144
179
  params.set("model", deepgramOptions.model ?? "nova-2");
145
- // Add language
146
- if (options.language) {
147
- params.set("language", options.language);
180
+ // Add language, or ask Deepgram to detect it
181
+ const language = DeepgramSTT.pinnedLanguage(options.language);
182
+ if (language) {
183
+ params.set("language", language);
184
+ }
185
+ else {
186
+ params.set("detect_language", "true");
148
187
  }
149
188
  // Add punctuation
150
189
  if (options.punctuation !== false) {
@@ -176,7 +215,9 @@ export class DeepgramSTT {
176
215
  if (deepgramOptions.fillerWords) {
177
216
  params.set("filler_words", "true");
178
217
  }
179
- // Add keywords
218
+ // Add keywords. Generic `vocabulary` maps onto the same boost when
219
+ // the caller gave no Deepgram-specific list; Nova-3 takes it as
220
+ // `keyterm`, earlier models as `keywords`.
180
221
  if (deepgramOptions.keywords && deepgramOptions.keywords.length > 0) {
181
222
  for (const keyword of deepgramOptions.keywords) {
182
223
  params.append("keywords", keyword);
@@ -185,6 +226,16 @@ export class DeepgramSTT {
185
226
  params.set("keyword_boost", deepgramOptions.keywordBoost);
186
227
  }
187
228
  }
229
+ else if (options.vocabulary && options.vocabulary.length > 0) {
230
+ const param = (deepgramOptions.model ?? "nova-2").startsWith("nova-3")
231
+ ? "keyterm"
232
+ : "keywords";
233
+ for (const term of options.vocabulary) {
234
+ if (term.trim()) {
235
+ params.append(param, term.trim());
236
+ }
237
+ }
238
+ }
188
239
  // Add redaction
189
240
  if (deepgramOptions.redact && deepgramOptions.redact.length > 0) {
190
241
  for (const redactType of deepgramOptions.redact) {
@@ -197,7 +248,10 @@ export class DeepgramSTT {
197
248
  }
198
249
  const url = `${this.baseUrl}/listen?${params.toString()}`;
199
250
  const controller = new AbortController();
200
- const timeoutId = setTimeout(() => controller.abort(), 30000);
251
+ const timeoutMs = typeof options.timeoutMs === "number" && options.timeoutMs > 0
252
+ ? options.timeoutMs
253
+ : DEFAULT_TIMEOUT_MS;
254
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
201
255
  let response;
202
256
  try {
203
257
  response = await fetch(url, {
@@ -212,7 +266,7 @@ export class DeepgramSTT {
212
266
  }
213
267
  catch (fetchErr) {
214
268
  if (fetchErr instanceof Error && fetchErr.name === "AbortError") {
215
- throw STTError.transcriptionFailed("Deepgram STT request timed out after 30 seconds", "deepgram", fetchErr);
269
+ throw STTError.transcriptionFailed(`Deepgram STT request timed out after ${Math.round(timeoutMs / 1000)} seconds`, "deepgram", fetchErr);
216
270
  }
217
271
  throw fetchErr;
218
272
  }
@@ -252,13 +306,18 @@ export class DeepgramSTT {
252
306
  const result = {
253
307
  text: firstAlternative.transcript,
254
308
  confidence: firstAlternative.confidence,
255
- language: options.language,
309
+ language: typeof firstChannel.detected_language === "string"
310
+ ? firstChannel.detected_language
311
+ : options.language,
256
312
  duration: data.metadata?.duration,
257
313
  metadata: {
258
314
  latency,
259
315
  provider: "deepgram",
260
316
  model: deepgramOptions.model ?? "nova-2",
261
317
  requestId: data.metadata?.request_id,
318
+ ...(typeof firstChannel.language_confidence === "number"
319
+ ? { languageConfidence: firstChannel.language_confidence }
320
+ : {}),
262
321
  },
263
322
  };
264
323
  // Add word timings
@@ -316,8 +375,11 @@ export class DeepgramSTT {
316
375
  // Build query parameters
317
376
  const params = new URLSearchParams();
318
377
  params.set("model", deepgramOptions.model ?? "nova-2");
319
- if (options.language) {
320
- params.set("language", options.language);
378
+ // The live endpoint has no `detect_language`; an unpinned stream runs on
379
+ // the model's default language.
380
+ const streamLanguage = DeepgramSTT.pinnedLanguage(options.language);
381
+ if (streamLanguage) {
382
+ params.set("language", streamLanguage);
321
383
  }
322
384
  if (options.punctuation !== false) {
323
385
  params.set("punctuate", "true");
@@ -330,7 +392,7 @@ export class DeepgramSTT {
330
392
  }
331
393
  // Indicate interim results
332
394
  params.set("interim_results", "true");
333
- const wsUrl = `wss://api.deepgram.com/v1/listen?${params.toString()}`;
395
+ const wsUrl = `${this.wsBaseUrl}/listen?${params.toString()}`;
334
396
  // Create WebSocket connection
335
397
  const WebSocket = (await import("ws")).default;
336
398
  const ws = new WebSocket(wsUrl, {
@@ -358,6 +420,7 @@ export class DeepgramSTT {
358
420
  text: alt.transcript,
359
421
  isFinal: response.is_final ?? false,
360
422
  confidence: alt.confidence ?? 0,
423
+ ...DeepgramSTT.streamSegmentDetails(response, alt.words),
361
424
  };
362
425
  if (resolveNext) {
363
426
  resolveNext({ value: segment, done: false });
@@ -534,6 +597,39 @@ export class DeepgramSTT {
534
597
  }
535
598
  }
536
599
  }
600
+ /**
601
+ * Timing, words and speaker of one live result, taken only from fields the
602
+ * payload actually carries. The segment's speaker is its words' speaker
603
+ * when they all agree.
604
+ */
605
+ static streamSegmentDetails(response, words) {
606
+ const details = {};
607
+ if (typeof response.start === "number") {
608
+ details.startTime = response.start;
609
+ if (typeof response.duration === "number") {
610
+ details.endTime = response.start + response.duration;
611
+ }
612
+ }
613
+ if (Array.isArray(words) && words.length > 0) {
614
+ details.words = words.map((word) => {
615
+ const timing = {
616
+ word: word.punctuated_word ?? word.word,
617
+ startTime: word.start,
618
+ endTime: word.end,
619
+ confidence: word.confidence,
620
+ };
621
+ if (word.speaker !== undefined) {
622
+ timing.speaker = `Speaker ${word.speaker}`;
623
+ }
624
+ return timing;
625
+ });
626
+ const speakers = new Set(details.words.map((w) => w.speaker).filter((s) => s !== undefined));
627
+ if (speakers.size === 1) {
628
+ details.speaker = [...speakers][0];
629
+ }
630
+ }
631
+ return details;
632
+ }
537
633
  /**
538
634
  * Get MIME type for audio format
539
635
  */