@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
package/dist/voice/errors.js
CHANGED
|
@@ -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
|
*/
|
package/dist/voice/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
/**
|
package/dist/voice/index.js
CHANGED
|
@@ -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
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
320
|
-
|
|
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 =
|
|
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
|
*/
|