@juspay/neurolink 12.47.14 → 12.48.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.
- package/CHANGELOG.md +2 -2
- package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
- package/dist/browser/neurolink.min.js +578 -565
- package/dist/cli/factories/commandFactory.d.ts +7 -0
- package/dist/cli/factories/commandFactory.js +26 -0
- package/dist/cli/loop/optionsSchema.js +9 -0
- package/dist/context/budgetChecker.js +54 -2
- package/dist/context/contextDecision.js +2 -0
- package/dist/context/toolPairRepair.d.ts +12 -0
- package/dist/context/toolPairRepair.js +29 -3
- package/dist/core/baseProvider.d.ts +92 -3
- package/dist/core/baseProvider.js +254 -10
- package/dist/core/conversationMemoryManager.js +17 -2
- package/dist/core/loopEngine.js +1 -1
- package/dist/core/modules/MessageBuilder.js +11 -3
- package/dist/core/modules/TelemetryHandler.d.ts +2 -2
- package/dist/core/modules/ToolsManager.d.ts +35 -0
- package/dist/core/modules/ToolsManager.js +159 -55
- package/dist/core/nativeGenerateLoop.d.ts +58 -1
- package/dist/core/nativeGenerateLoop.js +277 -3
- package/dist/core/redisConversationMemoryManager.js +10 -3
- package/dist/core/resolveRequestKind.d.ts +24 -4
- package/dist/core/resolveRequestKind.js +31 -4
- package/dist/core/toolExecutionRecorder.d.ts +6 -0
- package/dist/core/toolExecutionRecorder.js +24 -2
- package/dist/core/toolRoutingDecision.js +1 -0
- package/dist/factories/mediaHandlerCatalog.js +5 -0
- package/dist/factories/providerDescriptors.js +15 -0
- package/dist/files/fileTools.d.ts +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +9 -2
- package/dist/memory/condensationPrompt.d.ts +8 -0
- package/dist/memory/condensationPrompt.js +29 -0
- package/dist/memory/hippocampusInitializer.d.ts +14 -2
- package/dist/memory/hippocampusInitializer.js +64 -3
- package/dist/neurolink.d.ts +252 -3
- package/dist/neurolink.js +898 -43
- package/dist/providers/amazonBedrock/client.js +6 -0
- package/dist/providers/amazonSagemaker.js +14 -1
- package/dist/providers/anthropic/client.js +148 -19
- package/dist/providers/anthropic/loopAdapter.js +2 -2
- package/dist/providers/googleAiStudio/client.js +2 -1
- package/dist/providers/openaiChatCompletionsBase.js +103 -23
- package/dist/providers/systemOneDecision.d.ts +6 -0
- package/dist/providers/systemOneDecision.js +14 -63
- package/dist/providers/typesafe.d.ts +6 -0
- package/dist/providers/typesafe.js +6 -0
- package/dist/rag/retrieval/searchDecision.js +1 -0
- package/dist/routing/classifierRouter.js +6 -5
- package/dist/routing/classifierStrategies.js +8 -1
- package/dist/types/classifierRouter.d.ts +16 -3
- package/dist/types/common.d.ts +2 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/context.d.ts +8 -1
- package/dist/types/conversation.d.ts +87 -0
- package/dist/types/decision.d.ts +210 -1
- package/dist/types/decision.js +9 -1
- package/dist/types/dispatch.d.ts +11 -4
- package/dist/types/generate.d.ts +118 -54
- package/dist/types/loopEngine.d.ts +7 -2
- package/dist/types/memory.d.ts +177 -9
- package/dist/types/openaiCompatible.d.ts +11 -3
- package/dist/types/stream.d.ts +13 -16
- package/dist/types/stt.d.ts +58 -1
- package/dist/types/tools.d.ts +14 -0
- package/dist/types/tools.js +8 -0
- package/dist/types/tts.d.ts +108 -0
- package/dist/types/voice.d.ts +41 -4
- package/dist/utils/decisionAnswers.d.ts +31 -1
- package/dist/utils/decisionAnswers.js +114 -0
- package/dist/utils/decisionLimits.d.ts +39 -0
- package/dist/utils/decisionLimits.js +116 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +9 -0
- package/dist/utils/messageBuilder.d.ts +45 -1
- package/dist/utils/messageBuilder.js +393 -16
- package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
- package/dist/utils/multimodalOptionsBuilder.js +1 -0
- package/dist/utils/speechText.d.ts +41 -0
- package/dist/utils/speechText.js +364 -0
- package/dist/utils/toolResultStatus.d.ts +26 -0
- package/dist/utils/toolResultStatus.js +73 -0
- package/dist/utils/ttsProcessor.d.ts +29 -0
- package/dist/utils/ttsProcessor.js +84 -2
- package/dist/voice/index.d.ts +1 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/providers/ElevenLabsSTT.d.ts +42 -0
- package/dist/voice/providers/ElevenLabsSTT.js +351 -0
- package/dist/voice/providers/ElevenLabsTTS.d.ts +81 -1
- package/dist/voice/providers/ElevenLabsTTS.js +415 -74
- package/docs-site/static/search-index.json +48 -33
- package/package.json +5 -2
- package/dist/utils/toolEndEmitter.d.ts +0 -25
- package/dist/utils/toolEndEmitter.js +0 -64
|
@@ -6,8 +6,76 @@
|
|
|
6
6
|
* @module voice/providers/ElevenLabsTTS
|
|
7
7
|
*/
|
|
8
8
|
import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
|
|
9
|
+
import { delay } from "../../utils/async/delay.js";
|
|
10
|
+
import { calculateBackoff } from "../../utils/async/retry.js";
|
|
9
11
|
import { logger } from "../../utils/logger.js";
|
|
10
12
|
import { TTS_ERROR_CODES, TTSError } from "../../utils/ttsProcessor.js";
|
|
13
|
+
const DEFAULT_BASE_URL = "https://api.elevenlabs.io/v1";
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
15
|
+
/** Largest delay `setTimeout` honours; above it the timer fires at once. */
|
|
16
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
17
|
+
const DEFAULT_RETRIES = 1;
|
|
18
|
+
const DEFAULT_MODEL = "eleven_multilingual_v2";
|
|
19
|
+
const DEFAULT_VOICE_ID = "21m00Tcm4TlvDq8ikWAM"; // Rachel
|
|
20
|
+
const DEFAULT_OPUS_BITRATE = 64;
|
|
21
|
+
const DEFAULT_MP3_BITRATE = 128;
|
|
22
|
+
/** The kbps values ElevenLabs serves on `opus_48000_*` / `mp3_44100_*`. */
|
|
23
|
+
const OPUS_BITRATES = new Set([32, 64, 96, 128, 192]);
|
|
24
|
+
const MP3_BITRATES = new Set([32, 64, 96, 128, 192]);
|
|
25
|
+
/** ElevenLabs accepts `voice_settings.speed` in this range only. */
|
|
26
|
+
const SPEED_MIN = 0.7;
|
|
27
|
+
const SPEED_MAX = 1.2;
|
|
28
|
+
/** Backoff between retries when the response carries no `Retry-After`. */
|
|
29
|
+
const RETRY_BASE_DELAY_MS = 500;
|
|
30
|
+
const RETRY_MAX_DELAY_MS = 5_000;
|
|
31
|
+
/** Upper bound honoured for a server-supplied `Retry-After`. */
|
|
32
|
+
const RETRY_AFTER_CAP_MS = 10_000;
|
|
33
|
+
/**
|
|
34
|
+
* Models that reject the `language_code` request field. The API reference
|
|
35
|
+
* names only `multilingual_v2`; on every other model an unsupported code is
|
|
36
|
+
* ignored server-side, so the field is sent rather than dropped — the old
|
|
37
|
+
* two-model allow-list dropped a pin that v3 and the v4 family honour.
|
|
38
|
+
*/
|
|
39
|
+
const LANGUAGE_CODE_REJECTING_MODELS = new Set([
|
|
40
|
+
"eleven_multilingual_v2",
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* Error codes a failed `fetch()` carries (directly or on `cause`) when the
|
|
44
|
+
* failure was the transport, not the request: worth a resend. Anything else
|
|
45
|
+
* a `fetch()` throws before a request is sent — an invalid header value, a
|
|
46
|
+
* malformed URL — is a permanent configuration error and is not retried.
|
|
47
|
+
*/
|
|
48
|
+
const TRANSPORT_ERROR_CODES = new Set([
|
|
49
|
+
"ECONNRESET",
|
|
50
|
+
"ECONNREFUSED",
|
|
51
|
+
"ECONNABORTED",
|
|
52
|
+
"ETIMEDOUT",
|
|
53
|
+
"EPIPE",
|
|
54
|
+
"EAI_AGAIN",
|
|
55
|
+
"ENOTFOUND",
|
|
56
|
+
"EHOSTUNREACH",
|
|
57
|
+
"ENETUNREACH",
|
|
58
|
+
"UND_ERR_SOCKET",
|
|
59
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
60
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
61
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
62
|
+
]);
|
|
63
|
+
/** The numeric sample-rate segment of an `output_format` (`opus_48000_64` → 48000). */
|
|
64
|
+
const SAMPLE_RATE_SEGMENT = /_(\d{4,5})(?:_|$)/;
|
|
65
|
+
/**
|
|
66
|
+
* Thrown inside the retry loop for a non-2xx response so the loop can decide
|
|
67
|
+
* on status alone whether another attempt is worth making.
|
|
68
|
+
*/
|
|
69
|
+
class ElevenLabsHttpError extends Error {
|
|
70
|
+
status;
|
|
71
|
+
retryAfterMs;
|
|
72
|
+
constructor(message, status, retryAfterMs) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.retryAfterMs = retryAfterMs;
|
|
76
|
+
this.name = "ElevenLabsHttpError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
11
79
|
/**
|
|
12
80
|
* ElevenLabs Text-to-Speech Handler
|
|
13
81
|
*
|
|
@@ -17,7 +85,11 @@ import { TTS_ERROR_CODES, TTSError } from "../../utils/ttsProcessor.js";
|
|
|
17
85
|
*/
|
|
18
86
|
export class ElevenLabsTTS {
|
|
19
87
|
apiKey;
|
|
20
|
-
baseUrl
|
|
88
|
+
baseUrl;
|
|
89
|
+
/** `ELEVENLABS_VOICE_ID`, else Rachel; a per-request `voice` still wins. */
|
|
90
|
+
defaultVoiceId;
|
|
91
|
+
/** `ELEVENLABS_MODEL`, else `eleven_multilingual_v2`; a per-request `model` still wins. */
|
|
92
|
+
defaultModel;
|
|
21
93
|
voicesCache = null;
|
|
22
94
|
static CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
23
95
|
/**
|
|
@@ -27,6 +99,16 @@ export class ElevenLabsTTS {
|
|
|
27
99
|
constructor(apiKey) {
|
|
28
100
|
const resolvedKey = (apiKey ?? process.env.ELEVENLABS_API_KEY ?? "").trim();
|
|
29
101
|
this.apiKey = resolvedKey.length > 0 ? resolvedKey : null;
|
|
102
|
+
this.baseUrl = ElevenLabsTTS.normalizeBaseUrl(process.env.ELEVENLABS_BASE_URL ?? DEFAULT_BASE_URL);
|
|
103
|
+
this.defaultVoiceId =
|
|
104
|
+
ElevenLabsTTS.envValue("ELEVENLABS_VOICE_ID") ?? DEFAULT_VOICE_ID;
|
|
105
|
+
this.defaultModel =
|
|
106
|
+
ElevenLabsTTS.envValue("ELEVENLABS_MODEL") ?? DEFAULT_MODEL;
|
|
107
|
+
}
|
|
108
|
+
/** A trimmed, non-empty environment value, else `undefined`. */
|
|
109
|
+
static envValue(name) {
|
|
110
|
+
const value = process.env[name]?.trim();
|
|
111
|
+
return value ? value : undefined;
|
|
30
112
|
}
|
|
31
113
|
isConfigured() {
|
|
32
114
|
return this.apiKey !== null;
|
|
@@ -49,7 +131,7 @@ export class ElevenLabsTTS {
|
|
|
49
131
|
}
|
|
50
132
|
try {
|
|
51
133
|
const voicesController = new AbortController();
|
|
52
|
-
const voicesTimeoutId = setTimeout(() => voicesController.abort(),
|
|
134
|
+
const voicesTimeoutId = setTimeout(() => voicesController.abort(), DEFAULT_TIMEOUT_MS);
|
|
53
135
|
let response;
|
|
54
136
|
try {
|
|
55
137
|
response = await fetch(`${this.baseUrl}/voices`, {
|
|
@@ -64,7 +146,7 @@ export class ElevenLabsTTS {
|
|
|
64
146
|
if (fetchErr instanceof Error && fetchErr.name === "AbortError") {
|
|
65
147
|
throw new TTSError({
|
|
66
148
|
code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
|
|
67
|
-
message:
|
|
149
|
+
message: `ElevenLabs voices request timed out after ${DEFAULT_TIMEOUT_MS}ms`,
|
|
68
150
|
category: ErrorCategory.NETWORK,
|
|
69
151
|
severity: ErrorSeverity.MEDIUM,
|
|
70
152
|
retriable: true,
|
|
@@ -145,65 +227,37 @@ export class ElevenLabsTTS {
|
|
|
145
227
|
const startTime = Date.now();
|
|
146
228
|
const elevenOptions = options;
|
|
147
229
|
try {
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
230
|
+
const voiceId = options.voice ?? this.defaultVoiceId;
|
|
231
|
+
const model = elevenOptions.model ?? this.defaultModel;
|
|
232
|
+
const outputFormat = this.mapFormat(options.format ?? "mp3", elevenOptions);
|
|
233
|
+
const baseUrl = elevenOptions.baseUrl
|
|
234
|
+
? ElevenLabsTTS.normalizeBaseUrl(elevenOptions.baseUrl)
|
|
235
|
+
: this.baseUrl;
|
|
236
|
+
const timeoutMs = ElevenLabsTTS.resolveTimeoutMs(elevenOptions.timeoutMs);
|
|
237
|
+
const retries = ElevenLabsTTS.resolveRetries(elevenOptions.retries);
|
|
153
238
|
const requestBody = {
|
|
154
239
|
text,
|
|
155
240
|
model_id: model,
|
|
156
|
-
voice_settings:
|
|
157
|
-
stability: elevenOptions.stability ?? 0.5,
|
|
158
|
-
similarity_boost: elevenOptions.similarityBoost ?? 0.75,
|
|
159
|
-
style: elevenOptions.style ?? 0.0,
|
|
160
|
-
use_speaker_boost: elevenOptions.useSpeakerBoost ?? true,
|
|
161
|
-
},
|
|
241
|
+
voice_settings: this.buildVoiceSettings(elevenOptions),
|
|
162
242
|
};
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
headers: {
|
|
172
|
-
"xi-api-key": this.apiKey,
|
|
173
|
-
"Content-Type": "application/json",
|
|
174
|
-
},
|
|
175
|
-
body: JSON.stringify(requestBody),
|
|
176
|
-
signal: controller.signal,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
catch (fetchErr) {
|
|
180
|
-
if (fetchErr instanceof Error && fetchErr.name === "AbortError") {
|
|
181
|
-
throw new TTSError({
|
|
182
|
-
code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
|
|
183
|
-
message: "ElevenLabs TTS request timed out after 30 seconds",
|
|
184
|
-
category: ErrorCategory.NETWORK,
|
|
185
|
-
severity: ErrorSeverity.HIGH,
|
|
186
|
-
retriable: true,
|
|
187
|
-
originalError: fetchErr,
|
|
188
|
-
});
|
|
243
|
+
const languageCode = elevenOptions.languageCode ?? options.language;
|
|
244
|
+
if (languageCode) {
|
|
245
|
+
if (LANGUAGE_CODE_REJECTING_MODELS.has(model)) {
|
|
246
|
+
logger.warn(`[ElevenLabsTTSHandler] language_code "${languageCode}" dropped: model "${model}" does not accept it`);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
requestBody.language_code =
|
|
250
|
+
ElevenLabsTTS.toIso639Primary(languageCode);
|
|
189
251
|
}
|
|
190
|
-
throw fetchErr;
|
|
191
|
-
}
|
|
192
|
-
finally {
|
|
193
|
-
clearTimeout(timeoutId);
|
|
194
|
-
}
|
|
195
|
-
if (!response.ok) {
|
|
196
|
-
const errorData = await response
|
|
197
|
-
.json()
|
|
198
|
-
.catch(() => Object.create(null));
|
|
199
|
-
const errorMessage = errorData.detail?.message ||
|
|
200
|
-
`HTTP ${response.status}`;
|
|
201
|
-
throw new Error(errorMessage);
|
|
202
252
|
}
|
|
253
|
+
const url = `${baseUrl}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${outputFormat}`;
|
|
254
|
+
const body = JSON.stringify(requestBody);
|
|
255
|
+
const headers = {
|
|
256
|
+
"xi-api-key": this.apiKey,
|
|
257
|
+
"Content-Type": "application/json",
|
|
258
|
+
};
|
|
259
|
+
const audioBuffer = await this.fetchWithRetry(url, { method: "POST", headers, body }, timeoutMs, retries, options.signal);
|
|
203
260
|
const latency = Date.now() - startTime;
|
|
204
|
-
// Get audio buffer
|
|
205
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
206
|
-
const audioBuffer = Buffer.from(arrayBuffer);
|
|
207
261
|
const result = {
|
|
208
262
|
buffer: audioBuffer,
|
|
209
263
|
// Use the *effective* output format from outputFormat, not the
|
|
@@ -235,12 +289,293 @@ export class ElevenLabsTTS {
|
|
|
235
289
|
message: `Synthesis failed: ${errorMessage}`,
|
|
236
290
|
category: ErrorCategory.EXECUTION,
|
|
237
291
|
severity: ErrorSeverity.HIGH,
|
|
238
|
-
retriable:
|
|
292
|
+
retriable: err instanceof ElevenLabsHttpError
|
|
293
|
+
? ElevenLabsTTS.isRetriableStatus(err.status)
|
|
294
|
+
: ElevenLabsTTS.isRetriableFailure(err),
|
|
239
295
|
context: { textLength: text.length },
|
|
240
296
|
originalError: err instanceof Error ? err : undefined,
|
|
241
297
|
});
|
|
242
298
|
}
|
|
243
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Issue the synthesis request, retrying on 429 / 5xx / network failure /
|
|
302
|
+
* per-attempt timeout up to `retries` more times. A `Retry-After` header
|
|
303
|
+
* sets the pause before the next attempt (capped); otherwise exponential
|
|
304
|
+
* backoff from 500 ms. Any other failure — 4xx, or a body that is not audio
|
|
305
|
+
* — is thrown on the first attempt.
|
|
306
|
+
*
|
|
307
|
+
* Total wall-clock is bounded by `timeoutMs × (1 + retries)` plus the pauses
|
|
308
|
+
* between attempts; `BaseProvider`'s outer synthesis timeout is raised to at
|
|
309
|
+
* least that figure (see `resolveTTSHandlerBudgetMs`).
|
|
310
|
+
*
|
|
311
|
+
* `signal` is the caller's cancellation: once it fires, the attempt in
|
|
312
|
+
* flight is aborted and no further attempt is made. Without it, a caller
|
|
313
|
+
* that had already given up (its own timeout, an aborted request) left the
|
|
314
|
+
* loop to pause and issue a second, billable request nobody consumed.
|
|
315
|
+
*/
|
|
316
|
+
async fetchWithRetry(url, init, timeoutMs, retries, signal) {
|
|
317
|
+
const totalAttempts = 1 + retries;
|
|
318
|
+
let lastError;
|
|
319
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
320
|
+
if (signal?.aborted) {
|
|
321
|
+
throw ElevenLabsTTS.cancelledError(signal);
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
return await this.fetchOnce(url, init, timeoutMs, signal);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
lastError = err;
|
|
328
|
+
const retriable = !signal?.aborted && ElevenLabsTTS.isRetriableFailure(err);
|
|
329
|
+
if (!retriable || attempt >= totalAttempts) {
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
const retryAfterMs = err instanceof ElevenLabsHttpError ? err.retryAfterMs : undefined;
|
|
333
|
+
const pauseMs = retryAfterMs !== undefined
|
|
334
|
+
? Math.min(retryAfterMs, RETRY_AFTER_CAP_MS)
|
|
335
|
+
: calculateBackoff(attempt, RETRY_BASE_DELAY_MS, RETRY_MAX_DELAY_MS);
|
|
336
|
+
logger.warn(`[ElevenLabsTTSHandler] attempt ${attempt}/${totalAttempts} failed (${err instanceof Error ? err.message : String(err)}); retrying in ${Math.round(pauseMs)}ms`);
|
|
337
|
+
await ElevenLabsTTS.pause(pauseMs, signal);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
throw lastError instanceof Error
|
|
341
|
+
? lastError
|
|
342
|
+
: new Error("ElevenLabs synthesis failed");
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* A backoff pause that ends as soon as `signal` fires, so a cancelled
|
|
346
|
+
* caller never waits it out. The timer is owned here and cleared on the
|
|
347
|
+
* way out: racing a bare `delay()` against the signal settled the promise
|
|
348
|
+
* at the abort but left the backoff timer armed for up to `RETRY_AFTER_CAP_MS`,
|
|
349
|
+
* holding the event loop open after the caller had already been answered.
|
|
350
|
+
*/
|
|
351
|
+
static pause(ms, signal) {
|
|
352
|
+
if (!signal) {
|
|
353
|
+
return delay(ms);
|
|
354
|
+
}
|
|
355
|
+
if (signal.aborted) {
|
|
356
|
+
return Promise.resolve();
|
|
357
|
+
}
|
|
358
|
+
return new Promise((resolve) => {
|
|
359
|
+
const timer = setTimeout(() => {
|
|
360
|
+
signal.removeEventListener("abort", onAbort);
|
|
361
|
+
resolve();
|
|
362
|
+
}, ms);
|
|
363
|
+
const onAbort = () => {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
resolve();
|
|
366
|
+
};
|
|
367
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/** The non-retriable error a caller-cancelled synthesis surfaces as. */
|
|
371
|
+
static cancelledError(signal) {
|
|
372
|
+
const reason = signal.reason;
|
|
373
|
+
return new TTSError({
|
|
374
|
+
code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
|
|
375
|
+
message: "ElevenLabs TTS synthesis cancelled by the caller's signal",
|
|
376
|
+
category: ErrorCategory.EXECUTION,
|
|
377
|
+
severity: ErrorSeverity.LOW,
|
|
378
|
+
retriable: false,
|
|
379
|
+
originalError: reason instanceof Error ? reason : undefined,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* One attempt, end to end: the per-attempt timer covers the response
|
|
384
|
+
* headers AND the audio body, so a stalled or truncated body is a timed-out
|
|
385
|
+
* attempt the retry loop can resend rather than a hang that only the outer
|
|
386
|
+
* provider budget ends. The caller's `signal` aborts the same controller,
|
|
387
|
+
* and an abort it caused is reported as a cancellation, not a timeout.
|
|
388
|
+
*/
|
|
389
|
+
async fetchOnce(url, init, timeoutMs, signal) {
|
|
390
|
+
const controller = new AbortController();
|
|
391
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
392
|
+
const onCallerAbort = () => controller.abort();
|
|
393
|
+
signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
394
|
+
const asAttemptError = (err) => signal?.aborted
|
|
395
|
+
? ElevenLabsTTS.cancelledError(signal)
|
|
396
|
+
: ElevenLabsTTS.asAttemptError(err, timeoutMs);
|
|
397
|
+
try {
|
|
398
|
+
let response;
|
|
399
|
+
try {
|
|
400
|
+
response = await fetch(url, { ...init, signal: controller.signal });
|
|
401
|
+
}
|
|
402
|
+
catch (fetchErr) {
|
|
403
|
+
throw asAttemptError(fetchErr);
|
|
404
|
+
}
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
// A body that PARSES to `null` (not one that fails to parse) isn't
|
|
407
|
+
// caught by `.catch()`, and `.detail` on `null` throws — a 429/503
|
|
408
|
+
// with a `null` body then failed on the first attempt with a
|
|
409
|
+
// confusing TypeError instead of being retried.
|
|
410
|
+
const errorData = (await response.json().catch(() => null)) ??
|
|
411
|
+
Object.create(null);
|
|
412
|
+
const errorMessage = errorData.detail?.message ||
|
|
413
|
+
`HTTP ${response.status}`;
|
|
414
|
+
throw new ElevenLabsHttpError(errorMessage, response.status, ElevenLabsTTS.parseRetryAfter(response.headers.get("retry-after")));
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
return Buffer.from(await response.arrayBuffer());
|
|
418
|
+
}
|
|
419
|
+
catch (bodyErr) {
|
|
420
|
+
// A body that stalls (abort) or breaks mid-stream is an attempt-level
|
|
421
|
+
// failure: retriable, like a dropped connection.
|
|
422
|
+
const attemptError = asAttemptError(bodyErr);
|
|
423
|
+
if (attemptError === bodyErr) {
|
|
424
|
+
throw new TTSError({
|
|
425
|
+
code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
|
|
426
|
+
message: `ElevenLabs TTS audio body failed: ${bodyErr instanceof Error ? bodyErr.message : String(bodyErr)}`,
|
|
427
|
+
category: ErrorCategory.NETWORK,
|
|
428
|
+
severity: ErrorSeverity.HIGH,
|
|
429
|
+
retriable: true,
|
|
430
|
+
originalError: bodyErr instanceof Error ? bodyErr : undefined,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
throw attemptError;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
clearTimeout(timeoutId);
|
|
438
|
+
signal?.removeEventListener("abort", onCallerAbort);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** Map an abort from the per-attempt timer to a retriable timeout error. */
|
|
442
|
+
static asAttemptError(err, timeoutMs) {
|
|
443
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
444
|
+
return new TTSError({
|
|
445
|
+
code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
|
|
446
|
+
message: `ElevenLabs TTS request timed out after ${timeoutMs}ms`,
|
|
447
|
+
category: ErrorCategory.NETWORK,
|
|
448
|
+
severity: ErrorSeverity.HIGH,
|
|
449
|
+
retriable: true,
|
|
450
|
+
originalError: err,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return err;
|
|
454
|
+
}
|
|
455
|
+
static isRetriableStatus(status) {
|
|
456
|
+
return status === 429 || status >= 500;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* 429 / 5xx, a per-attempt timeout or broken body (already a retriable
|
|
460
|
+
* `TTSError`), or a transport failure. Any other throw — a `TypeError` for
|
|
461
|
+
* an invalid header value or URL, thrown before a request exists — is
|
|
462
|
+
* permanent and is not retried; it used to be, on the grounds that it was
|
|
463
|
+
* an `Error`.
|
|
464
|
+
*/
|
|
465
|
+
static isRetriableFailure(err) {
|
|
466
|
+
if (err instanceof ElevenLabsHttpError) {
|
|
467
|
+
return ElevenLabsTTS.isRetriableStatus(err.status);
|
|
468
|
+
}
|
|
469
|
+
if (err instanceof TTSError) {
|
|
470
|
+
return err.retriable === true;
|
|
471
|
+
}
|
|
472
|
+
return ElevenLabsTTS.isTransportError(err);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* A socket-level failure: undici wraps one as `TypeError("fetch failed")`
|
|
476
|
+
* with the system error on `cause`, and the codes are checked on both.
|
|
477
|
+
*/
|
|
478
|
+
static isTransportError(err) {
|
|
479
|
+
if (!(err instanceof Error)) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
const code = ElevenLabsTTS.errorCode(err) ?? ElevenLabsTTS.errorCode(err.cause);
|
|
483
|
+
if (code !== undefined && TRANSPORT_ERROR_CODES.has(code)) {
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
return err instanceof TypeError && /\bfetch failed\b/i.test(err.message);
|
|
487
|
+
}
|
|
488
|
+
static errorCode(value) {
|
|
489
|
+
if (typeof value !== "object" || value === null) {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
const code = value.code;
|
|
493
|
+
return typeof code === "string" ? code : undefined;
|
|
494
|
+
}
|
|
495
|
+
/** A BCP-47 tag reduced to its ISO 639-1 primary subtag: `en-US` → `en`, `pt_BR` → `pt`. */
|
|
496
|
+
static toIso639Primary(language) {
|
|
497
|
+
const primary = language.trim().split(/[-_]/, 1)[0] ?? language;
|
|
498
|
+
return primary.toLowerCase();
|
|
499
|
+
}
|
|
500
|
+
/** One of `allowed`, else `fallback` with a warning — never an unlisted value in `output_format`. */
|
|
501
|
+
static resolveBitrate(value, allowed, fallback, label) {
|
|
502
|
+
if (value === undefined) {
|
|
503
|
+
return fallback;
|
|
504
|
+
}
|
|
505
|
+
if (allowed.has(value)) {
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
logger.warn(`[ElevenLabsTTSHandler] ${label} ${String(value)} is not one of ${[...allowed].join(", ")}; using ${fallback}`);
|
|
509
|
+
return fallback;
|
|
510
|
+
}
|
|
511
|
+
/** `Retry-After` as delta-seconds or an HTTP-date, in milliseconds. */
|
|
512
|
+
static parseRetryAfter(header) {
|
|
513
|
+
if (!header) {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
const seconds = Number(header);
|
|
517
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
518
|
+
return Math.round(seconds * 1000);
|
|
519
|
+
}
|
|
520
|
+
const at = Date.parse(header);
|
|
521
|
+
if (Number.isFinite(at)) {
|
|
522
|
+
return Math.max(0, at - Date.now());
|
|
523
|
+
}
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
/** A positive finite `timeoutMs`, clamped to the largest delay a timer honours; otherwise the default. */
|
|
527
|
+
static resolveTimeoutMs(timeoutMs) {
|
|
528
|
+
return typeof timeoutMs === "number" &&
|
|
529
|
+
Number.isFinite(timeoutMs) &&
|
|
530
|
+
timeoutMs > 0
|
|
531
|
+
? Math.min(timeoutMs, MAX_TIMEOUT_MS)
|
|
532
|
+
: DEFAULT_TIMEOUT_MS;
|
|
533
|
+
}
|
|
534
|
+
static resolveRetries(retries) {
|
|
535
|
+
return typeof retries === "number" &&
|
|
536
|
+
Number.isInteger(retries) &&
|
|
537
|
+
retries >= 0
|
|
538
|
+
? retries
|
|
539
|
+
: DEFAULT_RETRIES;
|
|
540
|
+
}
|
|
541
|
+
static normalizeBaseUrl(baseUrl) {
|
|
542
|
+
// A loop rather than /\/+$/: the input is caller-supplied and CodeQL
|
|
543
|
+
// flags that regex as polynomial on long runs of "/".
|
|
544
|
+
let end = baseUrl.length;
|
|
545
|
+
while (end > 0 && baseUrl.charCodeAt(end - 1) === 47) {
|
|
546
|
+
end--;
|
|
547
|
+
}
|
|
548
|
+
return baseUrl.slice(0, end);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Assemble `voice_settings`. The camelCase options are the documented
|
|
552
|
+
* surface; `voiceSettings` passes raw fields through and wins on conflict.
|
|
553
|
+
* `speed` comes from the generic `TTSOptions.speed` (0.25–4.0) clamped into
|
|
554
|
+
* ElevenLabs' accepted 0.7–1.2 — clamped with a warning rather than
|
|
555
|
+
* rejected, because the generic validator has already accepted the value.
|
|
556
|
+
*/
|
|
557
|
+
buildVoiceSettings(options) {
|
|
558
|
+
// Only DEFINED raw fields win: a spread copies an own key whose value is
|
|
559
|
+
// `undefined`, which would erase the default (or the camelCase value)
|
|
560
|
+
// and drop the field from the request body.
|
|
561
|
+
const rawSettings = Object.fromEntries(Object.entries(options.voiceSettings ?? {}).filter(([, value]) => value !== undefined));
|
|
562
|
+
const settings = {
|
|
563
|
+
stability: options.stability ?? 0.5,
|
|
564
|
+
similarity_boost: options.similarityBoost ?? 0.75,
|
|
565
|
+
style: options.style ?? 0.0,
|
|
566
|
+
use_speaker_boost: options.useSpeakerBoost ?? true,
|
|
567
|
+
...rawSettings,
|
|
568
|
+
};
|
|
569
|
+
const requestedSpeed = settings.speed ?? options.speed;
|
|
570
|
+
if (typeof requestedSpeed === "number" && Number.isFinite(requestedSpeed)) {
|
|
571
|
+
const clamped = Math.min(SPEED_MAX, Math.max(SPEED_MIN, requestedSpeed));
|
|
572
|
+
if (clamped !== requestedSpeed) {
|
|
573
|
+
logger.warn(`[ElevenLabsTTSHandler] speed ${requestedSpeed} clamped to ${clamped}: ElevenLabs accepts ${SPEED_MIN}–${SPEED_MAX}`);
|
|
574
|
+
}
|
|
575
|
+
settings.speed = clamped;
|
|
576
|
+
}
|
|
577
|
+
return settings;
|
|
578
|
+
}
|
|
244
579
|
/**
|
|
245
580
|
* Map gender string to standard type
|
|
246
581
|
*/
|
|
@@ -258,29 +593,35 @@ export class ElevenLabsTTS {
|
|
|
258
593
|
return "neutral";
|
|
259
594
|
}
|
|
260
595
|
/**
|
|
261
|
-
* Map TTSAudioFormat to ElevenLabs
|
|
596
|
+
* Map TTSAudioFormat to an ElevenLabs `output_format`.
|
|
597
|
+
*
|
|
598
|
+
* ElevenLabs has no `ogg_*` family: Ogg/Opus is `opus_48000_<kbps>`. Both
|
|
599
|
+
* `ogg` and `opus` map there. `wav` maps to raw `pcm_44100` (headerless —
|
|
600
|
+
* see `effectiveFormat`). Anything else falls back to `mp3_44100_128`.
|
|
262
601
|
*/
|
|
263
|
-
mapFormat(format) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
602
|
+
mapFormat(format, options) {
|
|
603
|
+
switch (format) {
|
|
604
|
+
case "mp3":
|
|
605
|
+
case "mpeg":
|
|
606
|
+
case "mpga":
|
|
607
|
+
return `mp3_44100_${ElevenLabsTTS.resolveBitrate(options.mp3Bitrate, MP3_BITRATES, DEFAULT_MP3_BITRATE, "mp3Bitrate")}`;
|
|
608
|
+
case "wav":
|
|
609
|
+
case "pcm16":
|
|
610
|
+
return "pcm_44100";
|
|
611
|
+
case "ogg":
|
|
612
|
+
case "opus":
|
|
613
|
+
return `opus_48000_${ElevenLabsTTS.resolveBitrate(options.opusBitrate, OPUS_BITRATES, DEFAULT_OPUS_BITRATE, "opusBitrate")}`;
|
|
614
|
+
default:
|
|
615
|
+
return "mp3_44100_128";
|
|
616
|
+
}
|
|
271
617
|
}
|
|
272
618
|
/**
|
|
273
619
|
* Get sample rate from format string
|
|
274
620
|
*/
|
|
275
621
|
getSampleRate(format) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (format.includes("22050")) {
|
|
280
|
-
return 22050;
|
|
281
|
-
}
|
|
282
|
-
if (format.includes("24000")) {
|
|
283
|
-
return 24000;
|
|
622
|
+
const parsed = SAMPLE_RATE_SEGMENT.exec(format)?.[1];
|
|
623
|
+
if (parsed) {
|
|
624
|
+
return Number(parsed);
|
|
284
625
|
}
|
|
285
626
|
return 44100;
|
|
286
627
|
}
|
|
@@ -302,7 +643,7 @@ export class ElevenLabsTTS {
|
|
|
302
643
|
if (outputFormat.startsWith("pcm")) {
|
|
303
644
|
return "pcm16";
|
|
304
645
|
}
|
|
305
|
-
if (outputFormat.startsWith("ogg")) {
|
|
646
|
+
if (outputFormat.startsWith("opus") || outputFormat.startsWith("ogg")) {
|
|
306
647
|
return "opus";
|
|
307
648
|
}
|
|
308
649
|
return "mp3";
|