@juspay/neurolink 12.45.3 → 12.46.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.
@@ -0,0 +1,390 @@
1
+ /** 60db workspace text-to-speech over its HTTP API. */
2
+ import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { TTSError, TTS_ERROR_CODES } from "../../utils/ttsProcessor.js";
5
+ import { createWavFile } from "../audio-utils.js";
6
+ const RATE = 24_000;
7
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ function record(value) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
11
+ throw new Error("Invalid 60db response record");
12
+ }
13
+ return value;
14
+ }
15
+ function validateRecord(value) {
16
+ if (value.success === false || value.error || value.type === "error") {
17
+ throw new Error("60db returned a synthesis error");
18
+ }
19
+ if (value.sample_rate !== undefined && value.sample_rate !== RATE) {
20
+ throw new Error("60db returned an unexpected sample rate");
21
+ }
22
+ for (const encoding of [value.encoding, value.output_format]) {
23
+ if (encoding !== undefined &&
24
+ !["LINEAR16", "PCM", "WAV"].includes(String(encoding).toUpperCase())) {
25
+ throw new Error("60db returned compressed audio for a LINEAR16 request");
26
+ }
27
+ }
28
+ }
29
+ /** Remove a PCM WAV container, including optional RIFF chunks. */
30
+ function pcmFromWav(bytes) {
31
+ if (bytes.length < 12 ||
32
+ bytes.toString("ascii", 8, 12) !== "WAVE" ||
33
+ bytes.readUInt32LE(4) + 8 !== bytes.length) {
34
+ throw new Error("Invalid 60db WAV container");
35
+ }
36
+ let validFormat = false;
37
+ let audio;
38
+ let offset = 12;
39
+ for (; offset + 8 <= bytes.length;) {
40
+ const name = bytes.toString("ascii", offset, offset + 4);
41
+ const size = bytes.readUInt32LE(offset + 4);
42
+ const start = offset + 8;
43
+ if (start + size > bytes.length) {
44
+ throw new Error("Truncated 60db WAV chunk");
45
+ }
46
+ if (name === "fmt ") {
47
+ validFormat =
48
+ size >= 16 &&
49
+ bytes.readUInt16LE(start) === 1 &&
50
+ bytes.readUInt16LE(start + 2) === 1 &&
51
+ bytes.readUInt32LE(start + 4) === RATE &&
52
+ bytes.readUInt32LE(start + 8) === RATE * 2 &&
53
+ bytes.readUInt16LE(start + 12) === 2 &&
54
+ bytes.readUInt16LE(start + 14) === 16;
55
+ }
56
+ if (name === "data") {
57
+ if (audio) {
58
+ throw new Error("Multiple 60db WAV data chunks");
59
+ }
60
+ audio = bytes.subarray(start, start + size);
61
+ }
62
+ offset = start + size + (size % 2);
63
+ }
64
+ if (offset !== bytes.length ||
65
+ !validFormat ||
66
+ !audio?.length ||
67
+ audio.length % 2) {
68
+ throw new Error("Expected nonempty mono PCM16 WAV at 24 kHz");
69
+ }
70
+ return audio;
71
+ }
72
+ function audioFromRecord(value, depth = 0) {
73
+ const envelope = record(value);
74
+ validateRecord(envelope);
75
+ const backend = envelope.backendResponse === undefined
76
+ ? envelope
77
+ : record(envelope.backendResponse);
78
+ validateRecord(backend);
79
+ const result = backend.result === undefined ? backend : record(backend.result);
80
+ validateRecord(result);
81
+ const encoded = backend.audio_base64 ?? result.audioContent;
82
+ if (encoded === undefined) {
83
+ return undefined;
84
+ }
85
+ if (typeof encoded !== "string" ||
86
+ !encoded.length ||
87
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
88
+ throw new Error("Invalid 60db audio encoding");
89
+ }
90
+ const bytes = Buffer.from(encoded, "base64");
91
+ if (bytes[0] === 123 && bytes[1] === 34) {
92
+ if (depth >= 1) {
93
+ throw new Error("Excessive nested 60db audio encoding");
94
+ }
95
+ return audioFromRecord(JSON.parse(bytes.toString("utf8")), depth + 1);
96
+ }
97
+ const signature = bytes.toString("ascii", 0, 4);
98
+ if (signature === "RIFF") {
99
+ return pcmFromWav(bytes);
100
+ }
101
+ if (signature === "OggS" || signature.startsWith("ID3")) {
102
+ throw new Error("Expected 60db PCM audio");
103
+ }
104
+ if (!bytes.length || bytes.length % 2) {
105
+ throw new Error("Incomplete 60db PCM audio");
106
+ }
107
+ return bytes;
108
+ }
109
+ /** Buffered synthesis; NeuroLink's processor handles sentence streaming. */
110
+ export class SixtyDBTTS {
111
+ maxTextLength = 5000;
112
+ apiKey;
113
+ voices;
114
+ baseUrl;
115
+ /**
116
+ * Explicit endpoint supports deployments using their own API proxy.
117
+ * HTTP is accepted only for a loopback host (offline tests dial
118
+ * http://127.0.0.1:<port>); every other override must be HTTPS, since
119
+ * requests carry `Authorization: Bearer <key>` (CodeRabbit finding on
120
+ * the PR this was taken over from, applied here). The loopback list
121
+ * matches proxyReplay.ts's own HTTPS-except-loopback check, including
122
+ * "localhost" for a deployer's local proxy, not just the IP forms.
123
+ */
124
+ constructor(apiKey, baseUrl = "https://api.60db.ai") {
125
+ let parsed;
126
+ try {
127
+ parsed = new URL(baseUrl);
128
+ }
129
+ catch {
130
+ throw new TTSError({
131
+ code: TTS_ERROR_CODES.INVALID_INPUT,
132
+ message: `SixtyDBTTS endpoint is not a valid URL: ${baseUrl}`,
133
+ category: ErrorCategory.CONFIGURATION,
134
+ severity: ErrorSeverity.HIGH,
135
+ retriable: false,
136
+ });
137
+ }
138
+ const loopback = ["127.0.0.1", "::1", "[::1]", "localhost"].includes(parsed.hostname);
139
+ if (parsed.protocol !== "https:" &&
140
+ !(parsed.protocol === "http:" && loopback)) {
141
+ throw new TTSError({
142
+ code: TTS_ERROR_CODES.INVALID_INPUT,
143
+ message: `SixtyDBTTS endpoint must use HTTPS unless it targets loopback, got: ${parsed.protocol}//${parsed.host}`,
144
+ category: ErrorCategory.CONFIGURATION,
145
+ severity: ErrorSeverity.HIGH,
146
+ retriable: false,
147
+ });
148
+ }
149
+ this.baseUrl = baseUrl;
150
+ this.apiKey = (apiKey ?? process.env.SIXTYDB_API_KEY ?? "").trim() || null;
151
+ }
152
+ isConfigured() {
153
+ return this.apiKey !== null;
154
+ }
155
+ requireKey() {
156
+ if (!this.apiKey) {
157
+ throw new TTSError({
158
+ code: TTS_ERROR_CODES.PROVIDER_NOT_CONFIGURED,
159
+ message: "SIXTYDB_API_KEY not configured",
160
+ category: ErrorCategory.CONFIGURATION,
161
+ severity: ErrorSeverity.HIGH,
162
+ retriable: false,
163
+ });
164
+ }
165
+ return this.apiKey;
166
+ }
167
+ async request(path, body) {
168
+ const key = this.requireKey();
169
+ const controller = new AbortController();
170
+ let reader;
171
+ const timeout = setTimeout(() => {
172
+ controller.abort();
173
+ void reader?.cancel().catch(() => undefined);
174
+ }, 30_000);
175
+ let status;
176
+ try {
177
+ const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}${path}`, {
178
+ method: body ? "POST" : "GET",
179
+ redirect: "error",
180
+ headers: {
181
+ Authorization: `Bearer ${key}`,
182
+ "Content-Type": "application/json",
183
+ },
184
+ body: body ? JSON.stringify(body) : undefined,
185
+ signal: controller.signal,
186
+ });
187
+ status = response.status;
188
+ if (!response.ok) {
189
+ await response.body?.cancel().catch(() => undefined);
190
+ const retriable = status === 408 || status === 429 || status >= 500;
191
+ throw new TTSError({
192
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
193
+ message: `60db request failed (HTTP ${status})`,
194
+ category: retriable ? ErrorCategory.NETWORK : ErrorCategory.EXECUTION,
195
+ severity: ErrorSeverity.HIGH,
196
+ retriable,
197
+ context: { status },
198
+ });
199
+ }
200
+ if (!response.body) {
201
+ throw new Error("60db returned no response body");
202
+ }
203
+ reader = response.body.getReader();
204
+ const decoder = new TextDecoder("utf-8", { fatal: true });
205
+ let size = 0;
206
+ let text = "";
207
+ let complete = false;
208
+ try {
209
+ while (true) {
210
+ const { done, value } = await reader.read();
211
+ if (done) {
212
+ break;
213
+ }
214
+ size += value.length;
215
+ if (size > MAX_RESPONSE_BYTES) {
216
+ throw new Error("60db response exceeds 64 MiB");
217
+ }
218
+ text += decoder.decode(value, { stream: true });
219
+ }
220
+ if (controller.signal.aborted) {
221
+ throw new Error("60db response timed out");
222
+ }
223
+ text += decoder.decode();
224
+ complete = true;
225
+ return text;
226
+ }
227
+ finally {
228
+ if (!complete) {
229
+ await reader.cancel().catch(() => undefined);
230
+ }
231
+ reader.releaseLock();
232
+ }
233
+ }
234
+ catch (error) {
235
+ if (error instanceof TTSError) {
236
+ throw error;
237
+ }
238
+ throw new TTSError({
239
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
240
+ message: controller.signal.aborted
241
+ ? "60db request timed out after 30s"
242
+ : "60db request failed",
243
+ category: ErrorCategory.NETWORK,
244
+ severity: ErrorSeverity.HIGH,
245
+ retriable: status === undefined,
246
+ context: { status },
247
+ originalError: error instanceof Error ? error : undefined,
248
+ });
249
+ }
250
+ finally {
251
+ clearTimeout(timeout);
252
+ }
253
+ }
254
+ async synthesize(text, options = {}) {
255
+ this.requireKey();
256
+ const voice = options.voice ?? process.env.SIXTYDB_DEFAULT_VOICE;
257
+ const speed = options.speed ?? 1;
258
+ const format = options.format ?? "wav";
259
+ if (!text.trim() ||
260
+ text.length > this.maxTextLength ||
261
+ !voice ||
262
+ !UUID.test(voice) ||
263
+ !Number.isFinite(speed) ||
264
+ speed < 0.5 ||
265
+ speed > 2 ||
266
+ !["wav", "pcm16"].includes(format)) {
267
+ throw new TTSError({
268
+ code: TTS_ERROR_CODES.INVALID_INPUT,
269
+ message: "60db requires 1–5000 text characters, a workspace voice UUID, speed 0.5–2, and wav or pcm16 format",
270
+ category: ErrorCategory.VALIDATION,
271
+ retriable: false,
272
+ });
273
+ }
274
+ const start = Date.now();
275
+ const response = await this.request("/tts-synthesize", {
276
+ text,
277
+ voice_id: voice,
278
+ audio_config: { audio_encoding: "LINEAR16", sample_rate_hertz: RATE },
279
+ speed,
280
+ timestamp_type: "NONE",
281
+ });
282
+ let pcm;
283
+ try {
284
+ // JSON can be pretty-printed; NDJSON records are parsed individually.
285
+ let records;
286
+ try {
287
+ records = [JSON.parse(response)];
288
+ }
289
+ catch {
290
+ records = response
291
+ .split(/\r?\n/)
292
+ .filter((line) => line.trim())
293
+ .map((line) => JSON.parse(line));
294
+ }
295
+ const chunks = records
296
+ .map((value) => audioFromRecord(value))
297
+ .filter((value) => value !== undefined);
298
+ pcm = Buffer.concat(chunks);
299
+ if (!pcm.length) {
300
+ throw new Error("60db returned no audio");
301
+ }
302
+ }
303
+ catch (error) {
304
+ throw new TTSError({
305
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
306
+ message: "Invalid 60db synthesis response",
307
+ category: ErrorCategory.EXECUTION,
308
+ severity: ErrorSeverity.HIGH,
309
+ retriable: false,
310
+ originalError: error instanceof Error ? error : undefined,
311
+ });
312
+ }
313
+ const buffer = format === "wav" ? createWavFile(pcm, RATE) : pcm;
314
+ logger.info(`[SixtyDBTTS] Synthesized ${buffer.length} bytes in ${Date.now() - start}ms`);
315
+ return {
316
+ buffer,
317
+ format,
318
+ size: buffer.length,
319
+ voice,
320
+ sampleRate: RATE,
321
+ duration: pcm.length / (RATE * 2),
322
+ metadata: { latency: Date.now() - start, provider: "sixtydb" },
323
+ };
324
+ }
325
+ async getVoices(languageCode) {
326
+ this.requireKey();
327
+ if (!this.voices || Date.now() - this.voices.fetchedAt >= 300_000) {
328
+ const voices = new Map();
329
+ for (const tier of ["quality", "fast"]) {
330
+ try {
331
+ const response = record(JSON.parse(await this.request(`/voices?model=${tier}`)));
332
+ if (response.success !== true || !Array.isArray(response.data)) {
333
+ throw new Error("Invalid voice catalog");
334
+ }
335
+ for (const value of response.data) {
336
+ const voice = record(value);
337
+ if (typeof voice.voice_id !== "string" ||
338
+ !UUID.test(voice.voice_id) ||
339
+ typeof voice.name !== "string") {
340
+ throw new Error("Invalid voice record");
341
+ }
342
+ const labels = voice.labels === undefined ? {} : record(voice.labels);
343
+ const language = typeof labels.language === "string" ? labels.language : "";
344
+ voices.set(voice.voice_id, {
345
+ id: voice.voice_id,
346
+ name: voice.name,
347
+ languageCode: language,
348
+ languageCodes: language ? [language] : [],
349
+ gender: labels.gender === "male" || labels.gender === "female"
350
+ ? labels.gender
351
+ : "neutral",
352
+ description: typeof voice.description === "string"
353
+ ? voice.description
354
+ : undefined,
355
+ });
356
+ }
357
+ }
358
+ catch (error) {
359
+ if (error instanceof TTSError) {
360
+ throw error;
361
+ }
362
+ throw new TTSError({
363
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
364
+ message: "Invalid 60db voice catalog",
365
+ category: ErrorCategory.EXECUTION,
366
+ retriable: false,
367
+ });
368
+ }
369
+ }
370
+ this.voices = { fetchedAt: Date.now(), values: [...voices.values()] };
371
+ }
372
+ if (!languageCode) {
373
+ return this.voices.values;
374
+ }
375
+ // 60db's catalog stores bare codes ("en", "hi"); a caller may pass a
376
+ // regional one ("en-US"), matching this handler's own documented
377
+ // example (TTSProcessor.getVoices JSDoc). Match on the primary subtag
378
+ // unconditionally (not just when one side lacks a region), so "en"
379
+ // and "en-US" match both ways; this also matches "en-US" against a
380
+ // catalog entry of "en-GB", which 60db's bare-code catalog never
381
+ // presents in practice (AzureTTS does the narrower startsWith match,
382
+ // in the other direction, since its catalog does carry regions).
383
+ const wanted = languageCode.toLowerCase();
384
+ const wantedPrimary = wanted.split("-")[0];
385
+ return this.voices.values.filter((voice) => {
386
+ const have = voice.languageCode.toLowerCase();
387
+ return have === wanted || have.split("-")[0] === wantedPrimary;
388
+ });
389
+ }
390
+ }